Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion scripts/check-skills-token-ratchet.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 1 addition & 6 deletions skills/objectstack-data/rules/field-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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}"` |

Expand Down
40 changes: 30 additions & 10 deletions skills/objectstack-ui/evals/analytics-inline-vs-dataset.json
Original file line number Diff line number Diff line change
@@ -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"],
Expand All @@ -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"],
Expand All @@ -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"],
Expand All @@ -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"],
Expand All @@ -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 <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.",
"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).",
"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'"]
}
}
]
}
51 changes: 24 additions & 27 deletions skills/objectstack-ui/rules/dashboards.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
`<ObjectChart>` 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
Expand All @@ -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
<ObjectChart objectName="showcase_invoice" type="bar"
aggregate={{ field: 'total', function: 'sum', groupBy: 'status' }}
xAxis={{ field: 'status' }} yAxis={[{ field: 'total', format: '$0,0' }]}
series={[{ name: 'total' }]} />
// 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`,
Expand Down
Loading