diff --git a/scripts/check-skills-token-ratchet.mjs b/scripts/check-skills-token-ratchet.mjs
index f386e08a56d..5e3435686bf 100644
--- a/scripts/check-skills-token-ratchet.mjs
+++ b/scripts/check-skills-token-ratchet.mjs
@@ -573,7 +573,28 @@ export const CEILINGS = new Map([
// is generator-owned and carries no row here on purpose (see the boundary
// section). Its JSON twin was retired by #14296 item 3 and is gone entirely.
['skills/objectstack-ui/evals/README.md', 289],
- ['skills/objectstack-ui/evals/analytics-inline-vs-dataset.json', 1102],
+ // 1102 -> 1229: the aggregate × field-type rule and the multi-value-dimension
+ // rule (card #24425, the skills half of cloud#2861 ruling B) each gain one
+ // regression case — a `formula` under `sum` and a `multiselect` as the
+ // group-by axis — because the published teaching is the only place an agent
+ // meets the rule before the refusal does, and this fixture is the only
+ // instrument that makes the wrong answer fail loudly at scoring (the repo has
+ // no eval runner). Maintainer ruling 2026-10-19, decision batch #369 item 1,
+ // letter A (the ruling comment on the card is 6259315766), verbatim and
+ // untranslated: 「其他同意」 — the card was presented as 「推荐:A」.
+ // Arithmetic, in bytes because that is what this convention divides, and
+ // PAID DOWN FIRST as the convention asks: on `origin/main` the file was 4388
+ // bytes (1097 tokens, headroom 5). The existing text was folded losslessly
+ // to 3780 bytes (945 tokens) — the description, the five expected_output
+ // strings, eval 2's note and prompts 1/3/4 tightened with every fact and
+ // every assertion kept — and the two cases then added +1135 bytes, so the
+ // LANDED file is 4915 bytes -> 1229 tokens: the 5 tokens of existing headroom
+ // are absorbed and the ceiling moves 1102 -> 1229 (+127). Pinned AT the
+ // measurement — zero headroom, so the next token added here is paid for by
+ // deleting one from this file. Independently reproducible without running
+ // this script:
+ // ceil($(wc -c < skills/objectstack-ui/evals/analytics-inline-vs-dataset.json) / 4) == ceil(4915 / 4)
+ ['skills/objectstack-ui/evals/analytics-inline-vs-dataset.json', 1229],
// objectstack-ui `rules/` — the five files the #14296 item 1 split created.
// Each row is an INITIAL measurement AT its landed count, so every one starts
diff --git a/skills/objectstack-data/rules/field-types.md b/skills/objectstack-data/rules/field-types.md
index e1aa71ad634..09290cbb0e9 100644
--- a/skills/objectstack-data/rules/field-types.md
+++ b/skills/objectstack-data/rules/field-types.md
@@ -97,11 +97,6 @@ options: [
| `tree` | Hierarchical self-reference | `reference` — optional; when written, this object's own name (any other object is refused at parse) |
| `user` | Person picker — a lookup specialized to `sys_user` (assignee, watchers). Stored identically to `lookup` | `multiple` (collaborators), `defaultValue: 'current_user'` |
-> **`multiple: true` lookup ≠ junction object.** A multi-value lookup is stored
-> and read as an **array of ids** on the record — it is NOT a junction table.
-> Reach for a **junction object** (two lookups) only when the relationship
-> itself carries attributes (position, added_at, …).
-
## Media
| Type | When to Use | Config |
@@ -126,7 +121,7 @@ Stored as JSON on the parent row — no separate table / FK:
| Type | When to Use | Config |
|:-----|:------------|:-------|
-| `formula` | Computed from an expression referencing other fields | `expression` (CEL, `record.` prefixes), `returnType` (`'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'`); `'currency'` carries its own `currencyConfig` |
+| `formula` | Computed from an expression referencing other fields — virtual: not aggregatable, a dataset measure needs a stored field (objectstack-ui → Dashboards → Dataset-Bound Widgets) | `expression` (CEL, `record.` prefixes), `returnType` (`'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'`); `'currency'` carries its own `currencyConfig` |
| `summary` | Roll-up aggregation from child records | `summaryOperations` ({ object, field, function, relationshipField?, filter? }) |
| `autonumber` | Auto-incrementing display format ({0000} counter + optional date / {field} tokens, resets per scope) | `format` (shorthand) / `autonumberFormat` (canonical) — e.g., `"CASE-{0000}"`, `"AD{YYYYMMDD}{0000}"` |
diff --git a/skills/objectstack-ui/evals/analytics-inline-vs-dataset.json b/skills/objectstack-ui/evals/analytics-inline-vs-dataset.json
index 756b73ad097..f63e92304ca 100644
--- a/skills/objectstack-ui/evals/analytics-inline-vs-dataset.json
+++ b/skills/objectstack-ui/evals/analytics-inline-vs-dataset.json
@@ -1,11 +1,11 @@
{
"skill_name": "objectstack-ui",
- "description": "Inline-vs-dataset / dataset-envelope-ceiling decisions. Every persisted chart is dataset-backed (ADR-0021); the real decision is whether the data need fits the dataset envelope, and if not, which lower layer to escalate to. Naming an inline draft vs a saved dataset is governance, not expressibility.",
+ "description": "Dataset-envelope decisions (ADR-0021: every persisted chart is dataset-backed): does the need fit the envelope and, if not, which lower layer takes it; inline draft vs saved dataset is governance, not expressibility.",
"evals": [
{
"id": 1,
- "prompt": "On the sales dashboard, add several widgets that all report the same certified 'revenue' metric (sum of amount) — one grouped by region, one by month, one as a KPI total. Emit the ObjectStack metadata.",
- "expected_output": "Defines the metric ONCE in a dataset (defineDataset with an amount_sum measure + region and a month-granularity date dimension), then binds each widget to that dataset and selects dimensions/values BY NAME. Does not restate the aggregate inline per widget (the ADR-0021 inline object/valueField/aggregate shape was removed).",
+ "prompt": "On the sales dashboard add widgets that all report the certified 'revenue' metric (sum of amount): by region, by month, and a KPI total. Emit the metadata.",
+ "expected_output": "Defines the metric ONCE (defineDataset: an amount_sum measure, region and a month-granularity date dimension); each widget binds it and selects dimensions/values BY NAME — never an inline per-widget aggregate (object/valueField/aggregate was removed, ADR-0021).",
"files": [],
"assertions": {
"must_contain": ["defineDataset", "dataset", "dimensions", "values"],
@@ -15,8 +15,8 @@
{
"id": 2,
"prompt": "Add a dashboard chart showing 30-day rolling revenue (a moving average / running total over time). Model the data need.",
- "expected_output": "Recognizes that a window/rolling calculation is BEYOND the dataset envelope (a dataset has no window/having grammar and compareTo only does previous-period/year). Escalates to a stored/materialized rolling field or app code, not a Cube — it does not pretend a dataset date-bucket or a plain measure expresses a moving window.",
- "note": "The must_not_contain on 'dateGranularity' is deliberately strict: proposing date bucketing as the fix is the classic wrong answer. It can flag an otherwise-correct answer that merely mentions the field in passing — treat such a flag as a review cue, not an automatic fail.",
+ "expected_output": "Recognizes a rolling window as BEYOND the dataset envelope (no window/having grammar; compareTo only shifts a period/year): escalates to a stored rolling field or app code, not a Cube, and never passes off a date bucket or a plain measure as a moving window.",
+ "note": "'dateGranularity' in must_not_contain is deliberately strict (date bucketing is the classic wrong answer); it can flag a correct answer that merely mentions the key — a review cue, not an automatic fail.",
"files": [],
"assertions": {
"must_contain": ["window"],
@@ -25,8 +25,8 @@
},
{
"id": 3,
- "prompt": "On a projects dashboard I want each project row to show its count of related child tasks (task has a lookup to project). Model it.",
- "expected_output": "Explains that a to-many rollup ONTO the parent grain is not expressible in a dataset (datasets only do to-one joins; to-many traversal is out of scope). Recommends a stored rollup/summary field on project, OR basing the dataset on the child task and grouping by the project lookup (grain = child). Does not author a to-many join in include.",
+ "prompt": "On a projects dashboard each project row shows its count of child tasks (task has a lookup to project). Model it.",
+ "expected_output": "A to-many rollup ONTO the parent grain is not dataset-expressible (datasets join to-one only): recommends a stored rollup/summary field on project, OR a dataset on the child task grouped by the project lookup (grain = child); never a to-many join in include.",
"files": [],
"assertions": {
"must_contain": ["rollup", "to-many"],
@@ -35,8 +35,8 @@
},
{
"id": 4,
- "prompt": "A dashboard has a top-level date-range filter that must re-scope six charts built over different objects. How should each chart pick up the filter? Emit the metadata.",
- "expected_output": "Uses a dashboard-level variable/filter plus per-chart filterBindings mapping the filter to each widget's own date field (e.g. invoice→created_at, account→signed_at), broadcast into each chart's WHERE. Charts stay self-contained; it does NOT invent a shared dataset for the filter (a dataset is only implied when a parameter reshapes the query grain/window/join).",
+ "prompt": "A dashboard's top-level date-range filter must re-scope six charts over different objects. How does each chart pick it up? Emit the metadata.",
+ "expected_output": "A dashboard-level filter plus per-chart filterBindings mapping it to each widget's own date field (invoice→created_at, account→signed_at), broadcast into each chart's WHERE; charts stay self-contained and no shared dataset is invented for the filter (a dataset is implied only when a parameter reshapes grain/window/join).",
"files": [],
"assertions": {
"must_contain": ["filterBindings"],
@@ -46,12 +46,32 @@
{
"id": 5,
"prompt": "In a kind:'react' page, drop a quick one-off bar chart of tasks grouped by status. Write the block.",
- "expected_output": "Uses the inline block directly — an ad-hoc in-page chart does not require a dataset, and the react ObjectChart block has no dataset prop. Naming a dataset would only be for reuse/governance (Level B), not needed here. Note the axes name the aggregate's RESULT COLUMNS: `status` (its groupBy) and `count` (the fieldless-count column) — a fieldless count needs no field.",
+ "expected_output": "An inline block: an ad-hoc in-page chart needs no dataset (the react block has no dataset prop; naming one is Level B governance), and the axes name the aggregate's RESULT COLUMNS — status (its groupBy) and count (the fieldless count, which needs no field).",
"files": [],
"assertions": {
"must_contain": ["ObjectChart", "aggregate", "groupBy"],
"must_not_contain": ["defineDataset"]
}
+ },
+ {
+ "id": 6,
+ "prompt": "leave_request shows days_requested, a formula (end_date - start_date, returnType 'number'), on its form; keep it. Add a KPI widget totalling days requested this quarter. Emit the metadata.",
+ "expected_output": "Never sums the formula (virtual, whatever its returnType): adds a stored number a beforeInsert/beforeUpdate hook keeps in step and sums that, or titles the widget as the count it is.",
+ "files": [],
+ "assertions": {
+ "must_contain": ["number"],
+ "must_not_contain": ["field: 'days_requested'", "sql: 'days_requested'"]
+ }
+ },
+ {
+ "id": 7,
+ "prompt": "support_ticket has tags, a multiselect. Add a dashboard chart of open tickets by tag. Emit the metadata.",
+ "expected_output": "Refuses tags as the axis (a multi-value field is no dataset, cube or groupBy dimension): one widget per tag, each filtered { tags: { $contains: TAG } } over a count measure.",
+ "files": [],
+ "assertions": {
+ "must_contain": ["$contains"],
+ "must_not_contain": ["field: 'tags'", "groupBy: 'tags'", "sql: 'tags'"]
+ }
}
]
}
diff --git a/skills/objectstack-ui/rules/dashboards.md b/skills/objectstack-ui/rules/dashboards.md
index 990102821b2..8a685608582 100644
--- a/skills/objectstack-ui/rules/dashboards.md
+++ b/skills/objectstack-ui/rules/dashboards.md
@@ -82,22 +82,30 @@ escalate to. Decide on **expressibility**; reuse/governance is Level B.
Standardized answers to the recurring ambiguous cases:
-- **"Count of child tasks per project."** Base the dataset on the **child** (`task`)
- and group by the parent lookup (`project`) — grain = child. A rollup onto the
- **parent** grain ("on `project`, count related tasks") is a to-many rollup:
- **not** dataset-expressible — use a **stored rollup field** on `project`.
-- **To-one enrichment** ("revenue by `account.industry`") is fine and does **not**
- change grain — put `account` in `include`, add `account.industry` as a dimension.
-- **Computed column.** Formatting/currency → a measure's `format`/`currency`;
- arithmetic over declared measures (`margin = difference(revenue, cost)`) → a
- **derived measure**; CASE / bins / `revenue*0.8` / computed dimensions → **not** a
- dataset.
-- **Filter by a parent's attribute** → model it as a **dimension** (guaranteed to
- join); a lookup-path *filter* is not a reliable analytics-path construct.
-- **A dashboard filter driving several charts** (date/region) → **not** a dataset:
- a dashboard variable + per-chart `filterBindings` broadcast into each chart's
- WHERE. A dataset is implied only when a parameter **reshapes** the query
- (grain/window/join) — and those are beyond the envelope anyway.
+- **Count of child tasks per project.** Base the dataset on the **child** (`task`)
+ and group by the `project` lookup — grain = child. A rollup onto the **parent**
+ grain is to-many: **not** a dataset — a **stored rollup field** on `project`.
+- **To-one enrichment** ("revenue by `account.industry`") keeps grain — `account`
+ in `include`, `account.industry` as a dimension.
+- **Computed column.** Format/currency → the measure's `format`/`currency`;
+ arithmetic over measures → a **derived measure**; CASE / bins /
+ `revenue*0.8` / computed dimensions → **not** a dataset.
+- **Total over a computed value.** `sum`/`avg`/`min`/`max` need a STORED numeric
+ or boolean field (`percent` not under `sum`; `min`/`max` also dates); a `formula`
+ has no column: refused whatever its `returnType` says. Store the number
+ (user-entered, a `summary` rollup, or hook-kept) or title the widget the `count`
+ it is. Leave days: `sum` over the `formula` `days_requested` (`end - start`) is
+ refused ⇒ a stored `number` `days_count` a `beforeInsert`/`beforeUpdate` hook
+ writes, then `{ name: 'days_sum', aggregate: 'sum', field: 'days_count' }`.
+- **Breakdown by a multi-value field.** `multiselect`/`checkboxes`/`tags` and
+ any `multiple: true` field is refused as a dataset or cube dimension and as
+ `groupBy` (`INVALID_FIELD`; no per-member buckets). One widget per member,
+ filtered `{ tags: { $contains: 'urgent' } }`, or a single-value axis.
+- **Filter by a parent's attribute** → a **dimension** (guaranteed to join); a
+ lookup-path *filter* is not a reliable analytics-path construct.
+- **A dashboard filter driving several charts** → **not** a dataset: `globalFilters`
+ + per-widget `filterBindings` (below); a parameter that **reshapes**
+ grain/window/join is beyond the envelope.
**Level B — naming is governance, not expressibility.** An inline dataset draft
(Studio Live Canvas) and a saved named dataset have **identical** expressibility;
@@ -106,9 +114,6 @@ labels/formats → `defineDataset`). Persisted widgets already require a named
dataset, so Level B only surfaces in Studio previews and hand-coded react-page
`` blocks (a single-object inline `aggregate`, no dataset binding).
-- Dataset-bound widgets need at least one `values` entry, and every
- `dataset`/`dimensions`/`values` name must resolve to its `defineDataset` —
- `os validate` fails on an unresolved name (an empty chart otherwise).
- **The two paths key their result rows differently — this is the #1 way a
chart renders blank.** A DATASET returns rows keyed by the declared measure
NAME (`sum_amount`), because a measure has an author-chosen `name`. An
@@ -119,14 +124,6 @@ dataset, so Level B only surfaces in Studio previews and hand-coded react-page
names — never to a `sum_`-style measure name, and never to a field the
aggregate did not select. `os validate` checks both halves
(`react-chart-field-unknown`, `react-chart-axis-unknown`).
-
- ```jsx
-
- // rows: [{ status: 'open', total: 1200 }, …] ← keyed by the raw field names
- ```
- **Charts speak the spec `ChartConfig` shape on every surface** — the same
`type` / `xAxis` / `yAxis` / `series` you write on a dashboard widget or a
report. Axis presentation rides on the axis (`format`, `min`/`max`,