Skip to content

Commit 31ce45c

Browse files
skills(objectstack-ui, objectstack-data): teach the aggregate × field-type rule and the multi-value dimension rule (#24439)
Fixes #24425 Clause-②: no ## 维护者速读(草稿) **改了什么** — 对外发布的两个 skill 各补一条规则,规则只在 objectstack-ui 陈述一次,objectstack-data 只做交叉引用。① 数据集度量的 `sum`/`avg`/`min`/`max` 必须落在**已存储**的数值或布尔字段上;`formula` 字段是虚拟列(不落库),不管它声明的 `returnType` 是什么都会被拒 —— 要合计一个计算值,就把数存下来(用户录入、`summary` 汇总、或由 hook 维护的存储字段),或者把部件如实标成 `count`。② 多值字段(`multiselect` / `checkboxes` / `tags`,以及任何 `multiple: true` 的字段)不能当分组维度,数据集维度、cube 维度、引擎 `groupBy` 一视同仁 —— 改用按成员的 `$contains` 过滤(一个成员一个部件)或换单值轴。各配一个几行的例子(请假天数合计、按标签分布)。`rules/field-types.md` 的 `formula` 行加一句指向该规则。评测夹具加两条用例,`must_not_contain` 钉住错误答案必然写出的拼法。补丁轮(裁决 A 之后):`scripts/check-skills-token-ratchet.mjs` 里该评测夹具的上限按实测值从 1102 移到 1229,行旁按文件自身惯例写入裁决原话与算术;契约复核的两处小修 —— 请假天数例子里存储字段另起名(`days_count`,公式留在表单上)并对它求和,评测 7 的 `must_not_contain` 补上 cube 维度拼法 `sql: 'tags'`。 **为什么改** — 维护者在 cloud 卡 2861 裁决 B 后的原话:「2861 这个应该是 skills 也要优化吧」。平台早已拒绝这两种写法(spec 的 aggregate × field-type 表、分析维度门与引擎 groupBy 门、以及 `os validate` 的 lint 腿),发布的 skill 却没教,AI 会先写出被拒的形状、再从拒绝里学。本 PR 把规则放到作者决定部件时已在读的位置(Dataset-Bound Widgets 的标准答案列表)。上限上移的依据是维护者 2026-10-19 对决策批次 369 第 1 项(字母 A)的裁决「其他同意」(卡上裁决评论 6259315766),按棘轮自身惯例「先付再抬」。 **风险与代价(含回滚)** — 四个文件:三个 `skills/**` 文本文件加棘轮脚本一行上限及其记录块;无平台代码,无 changeset(`skip-changeset`,`scripts/` 不发布)。token 棘轮在最终 head 全绿:`rules/dashboards.md` 6251/6252(余量 1),`rules/field-types.md` 3116/3158,`evals/analytics-inline-vs-dataset.json` 1229/1229(按实测钉死,余量 0);新增 token 全部由同文件内的无损折叠或删除他处已有陈述支付(下表逐条列出存活位置),评测夹具付到底后的余额由裁决抬上限补齐(+127)。回滚 = revert 本 PR 的两个 commit,无数据与运行时影响。 **席位意见** — **你要做的** — 批准并手合(Tier H,`skills/**`):本 PR 保持 draft,由你手工合并。 ## Ceiling raise — letter A, paid down first, raised for the remainder Maintainer ruling 2026-10-19, decision batch 369 item 1, letter A (the card was presented as 「推荐:A」), verbatim and untranslated: 「其他同意」 — ruling comment 6259315766 on the card; the director's delta 6259193569 agreed. The row `skills/objectstack-ui/evals/analytics-inline-vs-dataset.json` in `scripts/check-skills-token-ratchet.mjs` moves from 1102 to 1229, pinned AT the measurement taken on the final head (zero headroom). Arithmetic, in bytes because that is what the convention divides: on `origin/main` (`e22defb13`) the file was 4388 bytes → 1097 tokens (headroom 5). The existing text was folded losslessly to 3780 bytes → 945 tokens first (the description, the five existing `expected_output` strings, eval 2's note and prompts 1 / 3 / 4 tightened, every fact and every assertion kept — the round-1 estimate said "about 1020"; measured, it is 945); the two cases then add +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). Reproducible without the script: `wc -c` on the fixture answers 4915, and `ceil(4915 / 4)` is 1229. The record block beside the row mirrors the file's 「17310 允许增加」 convention; the shrink-only testimony above `CEILINGS` is untouched (`pnpm check:ratchet-remedy-authority` exit 0; `node scripts/check-skills-token-ratchet.mjs --self-test` exit 0, 79 cases). No other ceiling moves, no new fixture, no new gate. ## What this teaches, measured against `main` at e22defb **Rule 1 — a dataset measure's `sum` / `avg` / `min` / `max` needs a STORED field of an accepted type; a `formula` is virtual and refused whatever its `returnType` says.** Sources read: `packages/spec/src/data/aggregate-field-type-compatibility.ts` (the one table: numeric class plus booleans, `percent` not under `sum`, `min`/`max` also the temporal class; `formula` named as VIRTUAL in SQL storage); `packages/services/service-analytics/src/dataset-compiler.ts` (`DATASET_INVALID` / 400 before any query is built); `packages/lint/src/validate-dataset-measure-aggregates.ts` (the authoring-time leg behind `os validate`); `packages/spec/src/migrations/entries/semantic/18.dataset-measure-aggregate-field-type-refused.ts` (the remedy: store the value as a number and aggregate that). Worked example: leave days — `sum` over the `formula` `days_requested` (`end - start`) is refused; a stored `number` `days_count`, written by a `beforeInsert` / `beforeUpdate` hook (the `defineHook` event names of objectstack-data's data-hooks reference), is summed instead, so the example and eval 6's prompt (the formula stays on the form) agree. **Rule 2 — a multi-value field cannot be a group-by dimension: dataset `dimensions`, a cube dimension and the engine's `groupBy` alike.** Sources read: `packages/services/service-analytics/src/structured-json-dimension-door.ts` and `packages/objectql/src/group-by-structured-json-door.ts` (`INVALID_FIELD` / 400 at both doors; the class is `isMultiValueField` in `packages/spec/src/data/field-value.zod.ts`: `multiselect` / `checkboxes` / `tags`, or `multiple: true` on `select` / `radio` / `lookup` / `user` / `file` / `image`; no bucket-per-member grouping exists). The route the rule names, one widget per member filtered `{ tags: { $contains: 'urgent' } }`, is a membership test on a dataset-bound widget too: `packages/services/service-analytics/src/contains-membership-sql.ts` lowers `$contains` on a multi-value or JSON-stored field as membership in the analytics where clause (`whereContainsMembershipSql` in `strategies/native-sql-strategy.ts`) and in the read scope; the engine path lowers it the same way. Both rules live in `skills/objectstack-ui/rules/dashboards.md`, Dataset-Bound Widgets, as two entries of the standardized-answers list (right after the computed-column entry). `skills/objectstack-data/rules/field-types.md` cross-references from the `formula` row. `skills/objectstack-ui/SKILL.md` is untouched: its Detailed Rules line already points at the section. No new file, no platform code, no changeset. ## Token ratchet — every added token paid in the same file; the fixture's remainder raised by ruling | file | tokens before | tokens after | ceiling | headroom after | |:--|--:|--:|--:|--:| | `skills/objectstack-ui/rules/dashboards.md` | 6243 | 6251 | 6252 | 1 | | `skills/objectstack-ui/SKILL.md` (untouched) | 3843 | 3843 | 3851 | 8 | | `skills/objectstack-ui/evals/analytics-inline-vs-dataset.json` | 1097 | 1229 | 1229 (was 1102; letter A) | 0 | | `skills/objectstack-data/rules/field-types.md` | 3157 | 3116 | 3158 | 42 | Deletions and folds, each with the site where the fact survives: | # | file | deleted / folded | survives at | |:--|:--|:--|:--| | 1 | dashboards.md | ObjectChart jsx fence under the result-row-keying bullet (7 lines) | the bullet's own prose directly above it (rows keyed by the raw field names; bind `xAxis.field` / `yAxis[].field` / `series[].name` to those names; `os validate` checks both halves); `references/react-blocks.md`, ObjectChart section (`aggregate` prop row states the same keying); eval 5's expected output carries a worked block; the `format: '$0,0'` axis key survives in the next bullet (axis presentation rides on the axis) | | 2 | dashboards.md | bullet "Dataset-bound widgets need at least one `values` entry … `os validate` fails on an unresolved name (an empty chart otherwise)" | the section's opening paragraph (a widget requires `dataset` + `values`; one lacking `dataset` fails `os validate`) and `SKILL.md` → Verify your work → Dashboard widget binding (an unresolved `dataset` / `dimensions` / `values` renders an empty chart) | | 3 | dashboards.md | child-count entry: dropped the quoted restatement "on `project`, count related tasks" and "to-many rollup: not dataset-expressible — use a" | the Level A table's right column (a to-many rollup onto the parent → escalate) and the iron rule (escalate to a stored field / a `summary` rollup) | | 4 | dashboards.md | computed-column entry: dropped the pseudo-example `margin = difference(revenue, cost)`; "Formatting/currency → a measure's" folded to "Format/currency → the measure's"; patch round: "arithmetic over declared measures" → "arithmetic over measures" | the Level A table (derived measures — `ratio/sum/difference/product` of other measures) | | 5 | dashboards.md | dashboard-filter entry: dropped "(date/region)" and "a dashboard variable + per-chart `filterBindings` broadcast into each chart's WHERE"; now points at `globalFilters` + `filterBindings` (below); patch round: "reshapes the query (grain/window/join)" → "reshapes grain/window/join" | the Dashboard filters section of the same file (broadcast into every widget's query; the `filterBindings` table) | | 6 | dashboards.md | to-one-enrichment and filter-by-parent entries re-phrased ("is fine and does not change grain" → "keeps grain"; "model it as a dimension" → "a dimension") | same entries, same facts | | 7 | field-types.md | the blockquote "`multiple: true` lookup ≠ junction object …" after the Relational table | objectstack-data `SKILL.md` → Relationship Patterns: the same blockquote, a superset (it adds the positional `{record.tags.0}` reading), plus the Many-to-Many (simple) table row | | 8 | evals json | `description` and the five existing `expected_output` strings, eval 2's `note`, and prompts 1 / 3 / 4 shortened (4388 → 3780 bytes) | every fact and every assertion-bearing clause kept; the caveat in eval 2's note kept (no other home) | ## Line budget | file | lines before | lines after | |:--|--:|--:| | `skills/objectstack-ui/rules/dashboards.md` | 468 | 465 | | `skills/objectstack-ui/SKILL.md` | 308 | 308 | | `skills/objectstack-ui/evals/analytics-inline-vs-dataset.json` | 57 | 77 | | `skills/objectstack-data/rules/field-types.md` | 327 | 322 | | `scripts/check-skills-token-ratchet.mjs` (the ceiling row + its record block; unpublished) | 1456 | 1477 | Net +12 lines across the four `skills/**` files (budget ≤ +30); +21 in the ratchet script (the record block, in the file's own convention). `git diff --numstat e22defb...HEAD`: 4 files, 77 insertions / 44 deletions; 52 of the insertions are new lines (two entries 11, two eval cases 20, the record block 21), the other 25 are in-place folds or edits of existing lines (the token payment, the two nits, the ceiling row) — the gross-additions line (≤ 45) is exceeded by those folds and the record block and is reported as a deviation. Bundle readings: sum of all `SKILL.md` lines 4394 → 4394 (unchanged); whole `skills/` tree 13338 → 13350 lines over 65 files; ratcheted authored tokens 144061 → 144160 of 155015 (the ceiling sum moved by the +127; shipped tree 155247 → 155346). ## Evals No in-repo eval runner exists: `git grep -l must_not_contain origin/main -- scripts packages/spec/scripts packages/lint` answers 0 files, no `package.json` script is named `eval`, and `skills/objectstack-ui/evals/README.md` describes the fixtures as scored by an assistant harness. The card's acceptance line "the eval cases pass with the skill's eval runner" is therefore unmet by construction; the fixture's validity is JSON validity (parsed with the ids 1–7 read back), the token ratchet and `check:skill-docs`. - Case 6 (leave-days total): the prompt keeps the `formula` on the form, so a right answer stores the number under another name (the rule's example now does the same: `days_count`); `must_not_contain` `field: 'days_requested'` (a dataset measure or an inline aggregate over the formula) and `sql: 'days_requested'` (a cube measure over it) are what the wrong answer must spell; `must_contain` `number`. - Case 7 (by-tag breakdown): `must_not_contain` `field: 'tags'` (a dataset dimension declared over the multiselect), `groupBy: 'tags'` (an inline aggregate or an engine query) and, since the patch round, `sql: 'tags'` (a cube dimension, `analytics.zod.ts`) are what the wrong answer must spell; a right answer filters `{ tags: { $contains: TAG } }` and never declares a dimension on `tags`; `must_contain` `$contains`. ## Verification — head `6acc9e915` - Derivation at `6acc9e915`: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths; change set from the merge base `e22defb13`, 4 files) names 50 families (the ratchet script adds 19 to round 1's 31). `--ran` reconciliation: 50 derived, 46 run, 4 NOT MEASURED, 0 UNRUN. The 4 NOT MEASURED answered exit 3 (PREREQUISITE NOT MET: the tree is not built, and the diff touches no package): `check:dts-closure`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:sourcemap-no-sources-content` — CI's `Lint & Repo Gates` carries them (success on this head). - Exit codes captured before any pipe, all 0 unless named: `node scripts/check-skills-token-ratchet.mjs` 0 — GREEN at the final head ("54 authored bundle file(s) within their ceilings"; the eval file "is 1229 tokens (ceiling 1229; headroom 0)", dashboards.md "6251 tokens (ceiling 6252; headroom 1)"); its `--self-test` 0 (79 cases); `pnpm check:ratchet-remedy-authority` 0 (275 scripts swept); `pnpm check:pm-dispatch-gates` 0 (the battery: 1913 fast-tier cases, 280 s, run via nohup and `tail --pid`); `check:corpus-claim-drift`, `check:skill-identifier-liveness`, `check:doc-authoring`, `check:pm-skill-id-lint`, `check:nul-bytes`, `check:skill-frame-sync`, `check:skill-compatibility`, `check:skill-docs`, `check:doc-formula-expressions` (after building `@objectstack/formula` + `@objectstack/lint` under the verify lock), the 19 added families (`check-declaration-mirrors`, `check-position-name-fold-loaders`, `check-scripts-symbol-anchors`, `check-self-test-wired`, `check-self-test-workflow-commands`, `check-whole-set-label-write` and their self-tests, `bare-root-worklist --self-test`, `check:bash32-floor`, `check:cli-command-ids`, `check:entry-guard`, `check:parse-guard`, `check:pnpm-filter-targets`, `check:pnpm-flag-spellings`) and every other derived family: 0. - No package is touched, so no build closure or package test is owed; `test:repo` is declared to CI. No ablation: no tested behaviour changed (published skill text, an eval fixture and a ceiling row). - The published commit `1b0c5012b` is untouched (no rebase, no amend, no force-push); the patch round is one commit on top, `6acc9e915`. ## Acceptance notes - PM assumption falsified: the card's `validate-dataset-measure-aggregates.ts` does exist on `main` — `git cat-file -e e22defb:packages/lint/src/validate-dataset-measure-aggregates.ts` succeeds. It is the authoring-time leg of the aggregate × field-type contract and also carries the dimension leg (`dimension-json-stored-field-refused`, a dataset dimension over a JSON-stored or multi-value field, and a cube dimension over such a column), so `os validate` refuses both shapes at authoring time. Cited above by its real path. - Observation, not filed (a stale code comment, no runtime effect; carrier: none): the module comment of `structured-json-dimension-door.ts` ("The multi-value route is a RECORD query's membership filter … It is not this service's own `where`") predates `contains-membership-sql.ts`, which now lowers `$contains` as membership in `NativeSQLStrategy.buildFilterClause` as well. - The harness's commit-attribution reminder names a model; the repository's pre-push hook refused that trailer on the first (unpublished) push of round 1, and the commit was amended to the model-free pair before anything reached the remote — no published history was rewritten. - Labels other than `skip-changeset` on this PR (`documentation`, `size/m`) are the labeler's, not set by the dev, left alone. --- _Generated by [Claude Code](https://claude.ai/code/session_01V1upgJbYCZ5xed9pEQF4Tz)_ --------- Co-authored-by: Claude Code <noreply@anthropic.com>
1 parent 83e31cd commit 31ce45c

4 files changed

Lines changed: 77 additions & 44 deletions

File tree

‎scripts/check-skills-token-ratchet.mjs‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -573,7 +573,28 @@ export const CEILINGS = new Map([
573573
// is generator-owned and carries no row here on purpose (see the boundary
574574
// section). Its JSON twin was retired by #14296 item 3 and is gone entirely.
575575
['skills/objectstack-ui/evals/README.md', 289],
576-
['skills/objectstack-ui/evals/analytics-inline-vs-dataset.json', 1102],
576+
// 1102 -> 1229: the aggregate × field-type rule and the multi-value-dimension
577+
// rule (card #24425, the skills half of cloud#2861 ruling B) each gain one
578+
// regression case — a `formula` under `sum` and a `multiselect` as the
579+
// group-by axis — because the published teaching is the only place an agent
580+
// meets the rule before the refusal does, and this fixture is the only
581+
// instrument that makes the wrong answer fail loudly at scoring (the repo has
582+
// no eval runner). Maintainer ruling 2026-10-19, decision batch #369 item 1,
583+
// letter A (the ruling comment on the card is 6259315766), verbatim and
584+
// untranslated: 「其他同意」 — the card was presented as 「推荐:A」.
585+
// Arithmetic, in bytes because that is what this convention divides, and
586+
// PAID DOWN FIRST as the convention asks: on `origin/main` the file was 4388
587+
// bytes (1097 tokens, headroom 5). The existing text was folded losslessly
588+
// to 3780 bytes (945 tokens) — the description, the five expected_output
589+
// strings, eval 2's note and prompts 1/3/4 tightened with every fact and
590+
// every assertion kept — and the two cases then added +1135 bytes, so the
591+
// LANDED file is 4915 bytes -> 1229 tokens: the 5 tokens of existing headroom
592+
// are absorbed and the ceiling moves 1102 -> 1229 (+127). Pinned AT the
593+
// measurement — zero headroom, so the next token added here is paid for by
594+
// deleting one from this file. Independently reproducible without running
595+
// this script:
596+
// ceil($(wc -c < skills/objectstack-ui/evals/analytics-inline-vs-dataset.json) / 4) == ceil(4915 / 4)
597+
['skills/objectstack-ui/evals/analytics-inline-vs-dataset.json', 1229],
577598

578599
// objectstack-ui `rules/` — the five files the #14296 item 1 split created.
579600
// Each row is an INITIAL measurement AT its landed count, so every one starts

‎skills/objectstack-data/rules/field-types.md‎

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -97,11 +97,6 @@ options: [
9797
| `tree` | Hierarchical self-reference | `reference` — optional; when written, this object's own name (any other object is refused at parse) |
9898
| `user` | Person picker — a lookup specialized to `sys_user` (assignee, watchers). Stored identically to `lookup` | `multiple` (collaborators), `defaultValue: 'current_user'` |
9999

100-
> **`multiple: true` lookup ≠ junction object.** A multi-value lookup is stored
101-
> and read as an **array of ids** on the record — it is NOT a junction table.
102-
> Reach for a **junction object** (two lookups) only when the relationship
103-
> itself carries attributes (position, added_at, …).
104-
105100
## Media
106101

107102
| Type | When to Use | Config |
@@ -126,7 +121,7 @@ Stored as JSON on the parent row — no separate table / FK:
126121

127122
| Type | When to Use | Config |
128123
|:-----|:------------|:-------|
129-
| `formula` | Computed from an expression referencing other fields | `expression` (CEL, `record.` prefixes), `returnType` (`'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'`); `'currency'` carries its own `currencyConfig` |
124+
| `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` |
130125
| `summary` | Roll-up aggregation from child records | `summaryOperations` ({ object, field, function, relationshipField?, filter? }) |
131126
| `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}"` |
132127

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
{
22
"skill_name": "objectstack-ui",
3-
"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.",
3+
"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.",
44
"evals": [
55
{
66
"id": 1,
7-
"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.",
8-
"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).",
7+
"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.",
8+
"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).",
99
"files": [],
1010
"assertions": {
1111
"must_contain": ["defineDataset", "dataset", "dimensions", "values"],
@@ -15,8 +15,8 @@
1515
{
1616
"id": 2,
1717
"prompt": "Add a dashboard chart showing 30-day rolling revenue (a moving average / running total over time). Model the data need.",
18-
"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.",
19-
"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.",
18+
"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.",
19+
"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.",
2020
"files": [],
2121
"assertions": {
2222
"must_contain": ["window"],
@@ -25,8 +25,8 @@
2525
},
2626
{
2727
"id": 3,
28-
"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.",
29-
"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.",
28+
"prompt": "On a projects dashboard each project row shows its count of child tasks (task has a lookup to project). Model it.",
29+
"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.",
3030
"files": [],
3131
"assertions": {
3232
"must_contain": ["rollup", "to-many"],
@@ -35,8 +35,8 @@
3535
},
3636
{
3737
"id": 4,
38-
"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.",
39-
"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).",
38+
"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.",
39+
"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).",
4040
"files": [],
4141
"assertions": {
4242
"must_contain": ["filterBindings"],
@@ -46,12 +46,32 @@
4646
{
4747
"id": 5,
4848
"prompt": "In a kind:'react' page, drop a quick one-off bar chart of tasks grouped by status. Write the block.",
49-
"expected_output": "Uses the inline <ObjectChart objectName=\"task\" type=\"bar\" aggregate={{ function: 'count', groupBy: 'status' }} xAxis={{ field: 'status' }} series={[{ name: 'count' }]} /> 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.",
49+
"expected_output": "An inline <ObjectChart objectName=\"task\" type=\"bar\" aggregate={{ function: 'count', groupBy: 'status' }} xAxis={{ field: 'status' }} series={[{ name: 'count' }]} /> 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).",
5050
"files": [],
5151
"assertions": {
5252
"must_contain": ["ObjectChart", "aggregate", "groupBy"],
5353
"must_not_contain": ["defineDataset"]
5454
}
55+
},
56+
{
57+
"id": 6,
58+
"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.",
59+
"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.",
60+
"files": [],
61+
"assertions": {
62+
"must_contain": ["number"],
63+
"must_not_contain": ["field: 'days_requested'", "sql: 'days_requested'"]
64+
}
65+
},
66+
{
67+
"id": 7,
68+
"prompt": "support_ticket has tags, a multiselect. Add a dashboard chart of open tickets by tag. Emit the metadata.",
69+
"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.",
70+
"files": [],
71+
"assertions": {
72+
"must_contain": ["$contains"],
73+
"must_not_contain": ["field: 'tags'", "groupBy: 'tags'", "sql: 'tags'"]
74+
}
5575
}
5676
]
5777
}

‎skills/objectstack-ui/rules/dashboards.md‎

Lines changed: 24 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -82,22 +82,30 @@ escalate to. Decide on **expressibility**; reuse/governance is Level B.
8282
8383
Standardized answers to the recurring ambiguous cases:
8484

85-
- **"Count of child tasks per project."** Base the dataset on the **child** (`task`)
86-
and group by the parent lookup (`project`) — grain = child. A rollup onto the
87-
**parent** grain ("on `project`, count related tasks") is a to-many rollup:
88-
**not** dataset-expressible — use a **stored rollup field** on `project`.
89-
- **To-one enrichment** ("revenue by `account.industry`") is fine and does **not**
90-
change grain — put `account` in `include`, add `account.industry` as a dimension.
91-
- **Computed column.** Formatting/currency → a measure's `format`/`currency`;
92-
arithmetic over declared measures (`margin = difference(revenue, cost)`) → a
93-
**derived measure**; CASE / bins / `revenue*0.8` / computed dimensions → **not** a
94-
dataset.
95-
- **Filter by a parent's attribute** → model it as a **dimension** (guaranteed to
96-
join); a lookup-path *filter* is not a reliable analytics-path construct.
97-
- **A dashboard filter driving several charts** (date/region) → **not** a dataset:
98-
a dashboard variable + per-chart `filterBindings` broadcast into each chart's
99-
WHERE. A dataset is implied only when a parameter **reshapes** the query
100-
(grain/window/join) — and those are beyond the envelope anyway.
85+
- **Count of child tasks per project.** Base the dataset on the **child** (`task`)
86+
and group by the `project` lookup — grain = child. A rollup onto the **parent**
87+
grain is to-many: **not** a dataset — a **stored rollup field** on `project`.
88+
- **To-one enrichment** ("revenue by `account.industry`") keeps grain — `account`
89+
in `include`, `account.industry` as a dimension.
90+
- **Computed column.** Format/currency → the measure's `format`/`currency`;
91+
arithmetic over measures → a **derived measure**; CASE / bins /
92+
`revenue*0.8` / computed dimensions → **not** a dataset.
93+
- **Total over a computed value.** `sum`/`avg`/`min`/`max` need a STORED numeric
94+
or boolean field (`percent` not under `sum`; `min`/`max` also dates); a `formula`
95+
has no column: refused whatever its `returnType` says. Store the number
96+
(user-entered, a `summary` rollup, or hook-kept) or title the widget the `count`
97+
it is. Leave days: `sum` over the `formula` `days_requested` (`end - start`) is
98+
refused ⇒ a stored `number` `days_count` a `beforeInsert`/`beforeUpdate` hook
99+
writes, then `{ name: 'days_sum', aggregate: 'sum', field: 'days_count' }`.
100+
- **Breakdown by a multi-value field.** `multiselect`/`checkboxes`/`tags` and
101+
any `multiple: true` field is refused as a dataset or cube dimension and as
102+
`groupBy` (`INVALID_FIELD`; no per-member buckets). One widget per member,
103+
filtered `{ tags: { $contains: 'urgent' } }`, or a single-value axis.
104+
- **Filter by a parent's attribute** → a **dimension** (guaranteed to join); a
105+
lookup-path *filter* is not a reliable analytics-path construct.
106+
- **A dashboard filter driving several charts** → **not** a dataset: `globalFilters`
107+
+ per-widget `filterBindings` (below); a parameter that **reshapes**
108+
grain/window/join is beyond the envelope.
101109

102110
**Level B — naming is governance, not expressibility.** An inline dataset draft
103111
(Studio Live Canvas) and a saved named dataset have **identical** expressibility;
@@ -106,9 +114,6 @@ labels/formats → `defineDataset`). Persisted widgets already require a named
106114
dataset, so Level B only surfaces in Studio previews and hand-coded react-page
107115
`<ObjectChart>` blocks (a single-object inline `aggregate`, no dataset binding).
108116

109-
- Dataset-bound widgets need at least one `values` entry, and every
110-
`dataset`/`dimensions`/`values` name must resolve to its `defineDataset` —
111-
`os validate` fails on an unresolved name (an empty chart otherwise).
112117
- **The two paths key their result rows differently — this is the #1 way a
113118
chart renders blank.** A DATASET returns rows keyed by the declared measure
114119
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
119124
names — never to a `sum_`-style measure name, and never to a field the
120125
aggregate did not select. `os validate` checks both halves
121126
(`react-chart-field-unknown`, `react-chart-axis-unknown`).
122-
123-
```jsx
124-
<ObjectChart objectName="showcase_invoice" type="bar"
125-
aggregate={{ field: 'total', function: 'sum', groupBy: 'status' }}
126-
xAxis={{ field: 'status' }} yAxis={[{ field: 'total', format: '$0,0' }]}
127-
series={[{ name: 'total' }]} />
128-
// rows: [{ status: 'open', total: 1200 }, …] ← keyed by the raw field names
129-
```
130127
- **Charts speak the spec `ChartConfig` shape on every surface** — the same
131128
`type` / `xAxis` / `yAxis` / `series` you write on a dashboard widget or a
132129
report. Axis presentation rides on the axis (`format`, `min`/`max`,

0 commit comments

Comments
 (0)