You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(spec)!: a dataset-bound dashboard widget owns appearance, the dataset owns structure (#19363)
Fixes#17385
Clause-②: yes (narrowing)
⚠️ **The claim comment on the card declares `Clause-②: no`, and this
PR's measurement disagrees.** The refusals narrow, as the claim and
ruling item 5 say — but implementing ruling item 2 forces ONE widening,
so the honest declaration carries the `yes` value with the `narrowing`
arm (the spelling `check-adr-0087-registration.mjs` pins for "a diff
that widens AND narrows"). ⛔ Nothing here touches the card's declaration
or the `needs:contract-review` label; the correction is the PM seat's,
and the measurement it rests on is the accept-set table below.
## What is ruled, and what this implements
Maintainer ruling 2026-09-12, decision batch #121 item 1, verbatim 「同意」,
on options C+D together. `DashboardWidgetSchema.dataset` is REQUIRED, so
every dashboard widget is dataset-bound, and ADR-0021 already made the
dataset the owner of the chart's structure. `chartConfig` nonetheless
declared `type` / `xAxis` / `yAxis` / `series`, with no rule between the
two answers.
That was not merely inert. An authored `yAxis[].field` was a live
MEMBERSHIP channel — the renderer synthesised a series from the authored
axes when the chart declared none — so one authored axis could silently
re-point a dataset-bound series at a different column while the chart
still drew.
1. **The describe text** (ruling item 1) states the split on
`ChartConfigSchema` and on the widget's `chartConfig`, citing ADR-0021:
the dataset decides which series exist and which column each one reads;
`chartConfig` carries appearance.
2. **The by-name refusals** (ruling item 2) live on a new per-carrier
`DashboardWidgetChartConfigSchema` — `ChartConfigSchema.extend(…)`, the
`ReportChartSchema` spelling — which tombstones the four keys with
`retiredKey()`. Each refusal names the dataset selection the intent
belongs in.
3. **The ADR-0087 kit** in PR #17835's shape: the D2 conversion
`dashboard-widget-chart-config-structure-removed` (dashboards only), the
D3 semantic entry `dashboard-widget-chart-config-structure-refused`
carrying the structured TODO, four `RETIRED_KEYS_BY_MAJOR[18]` entries,
all registered through `gen:migration-registry`.
4. **A `minor` changeset** with the `**BREAKING**` banner and a FROM →
TO table.
5. **Regenerated artefacts** — the repo's own generators only; nothing
here is hand-edited.
## ⭐ The accept-set probe, before and after, on both faces
Run against the built `dist` at each state, exit codes captured before
any pipe.
| document | before | after |
| --- | --- | --- |
| dataset-bound widget · `chartConfig.type` | ACCEPT | **REFUSE** at
`chartConfig.type` |
| dataset-bound widget · `chartConfig.xAxis` | ACCEPT | **REFUSE** at
`chartConfig.xAxis` |
| dataset-bound widget · `chartConfig.yAxis` | ACCEPT | **REFUSE** at
`chartConfig.yAxis` |
| dataset-bound widget · `chartConfig.series` | ACCEPT | **REFUSE** at
`chartConfig.series` |
| dataset-bound widget · appearance only, WITH `type` | ACCEPT |
**REFUSE** (the `type` tombstone) |
| dataset-bound widget · appearance only, NO `type` | **REFUSE** (`type`
was required) | **ACCEPT** ← the widening |
| dataset-bound widget · no `chartConfig` at all | ACCEPT | ACCEPT |
| inline-data chart · `type` | ACCEPT | ACCEPT |
| inline-data chart · `type` + `xAxis` | ACCEPT | ACCEPT |
| inline-data chart · `type` + `yAxis` | ACCEPT | ACCEPT |
| inline-data chart · `type` + `series` | ACCEPT | ACCEPT |
| inline-data chart · all four at once | ACCEPT | ACCEPT |
| report chart · `type` + `xAxis`/`yAxis` as dataset names | ACCEPT |
ACCEPT |
| report chart · + `series` | ACCEPT | ACCEPT |
The inline-data arm is unchanged in every row, which is the half ruling
item 1 requires and the half a tombstone on the shared
`ChartConfigSchema` would have silently broken.
⚠️ **On "an inline-data widget", as the acceptance list words it.**
There is no such thing on this face, measured rather than assumed:
`DashboardWidgetSchema.dataset` is required, so a dashboard widget is
dataset-bound by construction. The inline-data face is the react tier's
`ObjectChart` with a `data` binding, whose contract IS
`ChartConfigSchema` (`react-blocks.ts` sets `schema: ChartConfigSchema`
and publishes all four keys in its `dataProps`) — which is the face the
ruling's own wording names ("On an inline-data CHART"). Rows 8–12 are
that arm; rows 13–14 add the third carrier for completeness.
## ⭐ The one widening, and why it is not optional
`ChartConfigSchema.type` is REQUIRED. Before this change a `chartConfig`
with no `type` was refused as incomplete — on every face. Ruling item 2
refuses `type` on the widget; ruling item 1 says `chartConfig` still
carries appearance there. Both can hold only if absence becomes legal on
that carrier, which `retiredKey()` (a `z.never().optional()`) does. So
exactly one document class moves from refused to accepted: `chartConfig`
with no `type` on a dashboard widget.
⛔ The alternative is not a narrower reading, it is a contradiction:
leaving `type` required while refusing every value makes `chartConfig`
UNAUTHORABLE on a dashboard widget, which deletes the appearance channel
ruling item 1 grants in the same sentence.
## Reverse verification — the refusal can fail
One-shot, on the committed tree, with the mutation proved on disk by
blob hash rather than by an editor's exit code, and restoration proved
by hash equality plus an empty `git diff HEAD`.
- Predicted direction: turns red. Observed direction: turns red.
- Mutation: drop the `xAxis` tombstone from the widget carrier. Anchor
occurrences 1 → 0, injected marker 0 → 1, blob `919d93f07fa8` →
`1f57132a1ace`.
- MUTATED: `vitest run src/ui/dashboard-chart-structure-refusal.test.ts`
exit **1** — 9 failed / 6 passed.
- RESTORED (`git checkout HEAD -- PATH`, never a bare checkout, under a
trap with absolute paths): blob back to `919d93f07fa8`, `git diff HEAD`
empty, exit **0** — 15 passed.
No `dist` leg: the pin imports `./dashboard.zod` from source inside its
own package, so there is no `exports` resolution to preflight.
## Two door verdicts MOVED, and they are re-pinned rather than relaxed
`chart.zod.ts`'s header claimed a BFS from every metadata root reaches
all five chart shapes. Re-measured on this branch, that sentence is now
false and is amended in place:
- `ChartConfigSchema` → `derived-clone`. No root reaches the base shape
itself; both carriers are `.extend()` clones that carry its strictness
and error map. Still a door, in the testkit's own vocabulary.
- `ChartAxisSchema` → **`unreachable`**. A change of fact, not of route:
the widget clone tombstones `xAxis`/`yAxis` and `ReportChartSchema`
re-declares both as dataset-name strings, so no authoring path from a
metadata root parses an axis OBJECT any more. Its remaining carrier is
the react tier's published `dataProps`, which is a declaration, not a
parse — so the #4583 question is genuinely open for that one shape.
Recorded, not answered here.
`ChartSeriesSchema` / `ChartAnnotationSchema` / `ChartInteractionSchema`
stay `direct`, which is what keeps the assertion from being satisfiable
by a broken walker.
## What this costs, stated rather than discovered
The four keys carried presentation alongside the binding — axis titles,
formats, bounds, grid lines, log scale, and per-series labels, colours,
stacking and mark types. Refusing the keys takes the presentation with
the binding. **The combo chart a dataset-bound widget could author
through `series[].type` has no authoring channel on this face any
more.** That is ruled, not incidental: the option that kept it was on
the table and was not taken. The showcase's `combo_count_vs_progress`
widget records the loss at its site.
## The changeset level
`minor`, not the `major` ruling item 3 asks for, and the authority was
re-read on this branch's own base rather than inherited:
- `docs/adr/0087-metadata-protocol-upgrade-contract.md`, 「Amended
2026-09-13 (#18003) — the level half」: **"Pre-GA, a metadata-facing
retirement or break ships `minor`"**, carrying the `**BREAKING**` banner
and its ADR-0087 disposition; **"An npm `major` is a planned act … never
a side effect of one retirement card"**. Present verbatim at
`e3b3cdd2df3`.
- #18064 closed `completed` 2026-09-14T00:36:28Z with the director
class-one adjudication naming that amendment: *"Ruling ② (#16885 item 5,
`major`) is superseded by that text; ruling ① (#16929, `minor`)
stands."*
- #16929 closed `completed` 2026-09-14T00:03:49Z, landed by PR #17835
(merged 00:03:48Z), whose changeset for the identical retirement class
reads `'@objectstack/spec': minor`.
- `packages/spec/package.json` is `17.4.0` and `PROTOCOL_VERSION` is
`'17.0.0'` — the migration entries are numbered at protocol 18 while the
package stays on the 17.x line, which is the amendment working as
written. The tombstones therefore name the npm release `17.5.0`, never
the protocol major (the same amendment's third bullet).
## Verification
- `pnpm --filter @objectstack/spec test` — 502 files / 14672 tests, exit
0.
- `pnpm --filter @objectstack/lint test` — 106 files / 3996 tests, exit
0.
- `pnpm --filter @objectstack/metadata-protocol test` — 182 files / 2596
tests, exit 0.
- `pnpm --filter @objectstack/dogfood test` — 137 files / 1103 tests,
exit 0.
- `typecheck`: `@objectstack/spec`, `@objectstack/lint`, and all three
example apps, exit 0.
- `validate` (the parse door) on all three example apps, exit 0.
- `eslint --no-inline-config` over the WHOLE repo — 6932 files judged, 0
messages, exit 0. Not a narrowed run, so no narrowing claim is owed.
- The derived gate family: `dispatch-gates.mjs --commands` on this tree,
116 families, each exit captured before any pipe and reconciled with
`--ran`. 110 exit 0; 6 exit 3 = PREREQUISITE NOT MET in a partially
built tree (NOT MEASURED, not red) — `check:doc-formula-expressions`,
`check:doc-security-posture`, `check:docs-transcript-drift`,
`check:dual-build-cjs-loads`, `check:lean-entry-closure`,
`check:type-check-debt`, each needing a full repo build.
`check:durability-log-level` entered the family on the final head and
was run separately, exit 0. The ratchet family was re-run on the final
commit after the merge.
- `origin/main` was merged through `scripts/pm/os-regen-merge.sh` (merge
committed BEFORE regeneration) and the registries were diffed against
`origin/main` afterwards: 0 entry ids lost, exactly the 2 this PR adds.
## `skills/**` budget
This PR touches one published-skill file and it is generated
(`gen:react-blocks`). Net change is zero lines on all three readings:
the changed file 115 → 115; every `SKILL.md` in `skills/` summed, 6145 →
6145; the whole `skills/` tree, 12766 → 12766. Three table cells were
rewritten in place.
## 维护者速读(草稿)
**改了什么。** 仪表盘上的图表,数据从「数据集」自动来。以前作者还能在 `chartConfig`
里自己写「画哪几条线、每条读哪一列、画成什么图」——
这跟数据集说的可能不一样,而且**不一样的时候没有任何提示**:图照画,数字悄悄换了一列。现在这四个键在仪表盘部件上被**按名字拒收**,每条拒收都告诉作者该去哪里写(部件自己的
`type`、`dimensions`、`values`)。外观(标题、颜色、高度、图例、数据标签、标注、交互)仍然归作者。
**为什么改。** 执行 2026-09-12 决策批次 #121 第 1 项您的裁定「同意」(C+D 合并)。方向不是本轮重开的。
**风险与代价(含回滚)。** ①
**有能力损失,且是裁定过的**:数据集绑定的部件上,「这个指标画柱、那个画线」的组合图**没有任何写法了**;showcase
里那个组合图部件已就地记录。② 轴标题、数字格式、坐标轴上下界、每条线的颜色/标签也一并没了 —— 这些现在由数据集自己的维度/度量声明决定。③
**接受集有一个方向相反的变化**:`chartConfig` 里以前**必须**写 `type`,现在不写才合法 ——
这是裁定本身逼出来的,不是选出来的,正文里有完整推导。④ 已存的仪表盘不会读崩:ADR-0087 转换会在读取时自动剥掉这四个键。⑤ 回滚 =
还原这一个 PR;没有数据迁移,没有不可逆动作。
**席位意见。** (待补)
**你要做的。** 这个 PR 的 diff 碰到了 `skills/` 下一个**生成**文件,按 Prime Directive #14
属于 Tier H 受管面 —— 落地需要您的一句话。净增 0 行(115 → 115),内容是三行表格文案随 schema 描述重新生成。
## Acceptance notes
- `noted, not filed:` `packages/lint`'s `chart-field-unknown` still
fires on a legacy document carrying the four keys, which is correct —
but the parse door now refuses those documents outright, so whether the
advisory is still reachable depends on whether lint runs before or after
the parse on each entry path. Not measured here; the hints were
corrected to say delete-and-migrate either way. 承接者:the next card on
`validate-widget-bindings.ts`, whose rule inventory this belongs to.
- `noted, not filed:` `.claude/skills/spec-property-retirement/SKILL.md`
§4 still instructs `@objectstack/spec` 用 `major` for a retirement
changeset, which ADR-0087's 2026-09-13 amendment supersedes. Reported in
the round report with dedupe words rather than filed from here — it is a
governed `.claude/**` surface and a seat's write.
- `noted, not filed:` `pnpm --filter PKG build --concurrency=2` forwards
the flag into each package's own script; `packages/cli`'s shell-`if`
build script then dies with `sh: 1: Syntax error: word unexpected`. Hit
once in this round and worked around with `--workspace-concurrency=2`
placed BEFORE the filters. Already documented as a toolchain trap; no
code change is owed. 承接者:无 —— it is a caller-side usage error, not a
repo defect.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
**BREAKING** — a dataset-bound dashboard widget's `chartConfig` carries appearance only: `type`, `xAxis`, `yAxis` and `series` are refused by name, each refusal naming the dataset selection the intent belongs in.
7
+
8
+
Clause-②: yes (narrowing)
9
+
10
+
`DashboardWidgetSchema.dataset` is REQUIRED, so **every** dashboard widget is dataset-bound, and ADR-0021 already made the dataset the owner of the chart's structure: it decides which series exist and which column each one reads. `chartConfig` nonetheless declared `type` / `xAxis` / `yAxis` / `series`, and the two answers met with no rule between them. That was not merely inert. An authored `yAxis[].field` was a live MEMBERSHIP channel — the renderer synthesised a series from the authored axes when the chart declared none — so one authored axis could silently re-point a dataset-bound series at a different column while the chart still drew, which reads as a true statement about the data. Maintainer ruling 2026-09-12, decision batch #121 item 1, verbatim 「同意」, on options C+D together: state the ownership split in the protocol AND refuse the four keys by name.
11
+
12
+
## FROM → TO
13
+
14
+
| you wrote inside `chartConfig` (17.4 and earlier) | write instead |
15
+
| --- | --- |
16
+
|`type: 'line'`|`type: 'line'` on the WIDGET, beside `dataset` — the widget's own `type` is the chart family and it always won; nothing on this face ever read the chart config's |
17
+
|`xAxis: { field: 'stage' }`|`dimensions: ['stage']` on the widget — the dataset dimension the category axis plots |
18
+
|`yAxis: [{ field: 'amount' }]`|`values: ['amount']` on the widget — the dataset measures, one entry per mark. A second axis is a second measure, not a second axis declaration |
19
+
|`series: [{ name: 'amount' }]`|`values` (plus a second `dimensions` entry to split) — series membership follows the selection; an entry naming a measure outside it was already being ignored |
20
+
21
+
**The one-line fix:** delete the four keys; the widget's `type` and its `dimensions` / `values` are the chart's structure.
22
+
23
+
`os migrate meta --from 17` lists the mechanical edits for existing sources; apply them by hand.
24
+
25
+
## What is NOT retired
26
+
27
+
The keys stay authorable on `ChartConfigSchema` itself, and that is the half a blanket refusal would have broken. A react-tier `<ObjectChart data={…} />` binds inline rows with no dataset behind them, so its axes are the author's and are unchanged — `react-blocks.ts` still publishes all four in that block's `dataProps`. `ReportChartSchema` keeps its own `xAxis` / `yAxis`, narrowed to its bound dataset's dimension and measure names. The refusal lives on a new per-carrier `DashboardWidgetChartConfigSchema` (`ChartConfigSchema.extend(…)`, the `ReportChartSchema` spelling) precisely so it cannot reach those two.
28
+
29
+
## What this costs, stated rather than discovered
30
+
31
+
`xAxis` / `yAxis` / `series` carried presentation alongside the binding — axis titles, number formats, bounds, grid lines, log scale, and per-series labels, colours, stacking and mark types. Refusing the keys takes the presentation with the binding: a dataset-bound chart takes those from the dataset's own dimension and measure declarations, and `colors` on the chart config remains the palette channel. **The combo chart a dataset-bound widget could author through `series[].type` has no authoring channel on this face any more.** That capability loss is ruled, not incidental — the option that kept it was on the table and was not taken.
32
+
33
+
## Accept-set movement, both directions
34
+
35
+
Narrowing, on a dataset-bound widget: the four keys move from accepted to refused. **And one widening, which is forced by the ruling rather than chosen:**`ChartConfigSchema.type` is REQUIRED, so before this change a `chartConfig` without a `type` was refused as incomplete. Refusing `type` while keeping the bag authorable for appearance — which ruling item 1 requires in as many words — means absence must now be legal. So `chartConfig: { title: 'Revenue' }` on a dashboard widget moves from refused to accepted. That is why the declaration reads `yes (narrowing)` rather than `no`.
36
+
37
+
## The retirement kit
38
+
39
+
-**Four `retiredKey()` tombstones on the widget carrier**, registered as `ui/DashboardWidgetChartConfig:type` / `:xAxis` / `:yAxis` / `:series` under protocol 18. `tsc` types each key `never`, so every authoring site in a consumer's tree fails to compile before anything runs, and a value that reaches a parse raises the prescription rather than a bare unrecognized-key report.
40
+
-**The ADR-0087 pair.** The D2 conversion `dashboard-widget-chart-config-structure-removed` strips the four keys from stored dashboard widgets (dashboards only — reports and the react tier keep theirs); the D3 semantic entry `dashboard-widget-chart-config-structure-refused` carries the judgement, because moving what the keys MEANT into the dataset selection needs facts the widget does not hold — an authored axis field can name a dataset dimension the widget never selected.
41
+
-**The liveness rows stay and are regraded `dead`**, the `retiredKey` route's discipline: the tombstone keeps the key in the walked shape, so the row remains and records why. Three of them were graded `live` on their presentation half on 2026-09-12 and that measurement is recorded as overridden, not withdrawn.
42
+
-**`chart-config-missing` is withdrawn from `@objectstack/lint`.** It advised a `combo` widget with no `chartConfig` to declare `chartConfig: { series: [{ name, type }] }` — metadata the schema now refuses — and after the ruling there is nothing a `combo` author can do about the finding. The rule ID stays exported, so an existing `suppressWarnings: ['chart-config-missing']` entry keeps parsing. `chart-field-unknown` still fires on a legacy document and its hints now say delete-and-migrate instead of describing what the keys used to carry.
43
+
44
+
## What an operator with a STORED dashboard sees
45
+
46
+
A `sys_metadata``dashboard` row written before this release can carry any of the four. Nothing breaks at read: the conversion replays on rehydration and strips them, so the row is served canonical, and `os migrate meta --stored --apply` rewrites the rows. ⚠️ The strip is the mechanical half only. A widget whose authored axes AGREED with its selection renders identically afterwards — that is the expected case. A widget that renders differently was relying on the membership channel this removes, which is the case the ruling was made about.
|**xAxis**|`{ field: string; title?: string \| Record<string, string>; format?: string; min?: number; … }`| optional | X-Axis configuration. Structure, not appearance — authorable where the chart has inline data; refused by name on a dataset-bound dashboard widget, where the dataset decides it (ADR-0021).|
112
+
|**yAxis**|`{ field: string; title?: string \| Record<string, string>; format?: string; min?: number; … }[]`| optional | Y-Axis configuration (support dual axis). Structure, not appearance — authorable where the chart has inline data; refused by name on a dataset-bound dashboard widget, where the dataset decides it (ADR-0021).|
113
+
|**series**|`{ name: string; label?: string \| Record<string, string>; type?: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| …>; color?: string; … }[]`| optional | Defined series configuration. Structure, not appearance — authorable where the chart has inline data; refused by name on a dataset-bound dashboard widget, where the dataset decides it (ADR-0021).|
114
114
|**colors**|`string[] \| Record<string, string>`| optional | Color palette (string[]) or value→color map (`{ value: color }`) |
115
115
|**height**|`number`| optional | Fixed plot height in pixels (overrides the container default) |
0 commit comments