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`,