fix(spec)!: refuse dashboard.widgets[].options.stageOrder on every widget type that does not read it - #17616
Conversation
…widget type that reads it WIP checkpoint before the heavy verification run. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>
…rence page The ADR-0087 protocol-18 semantic entry, the regenerated migration registry, the `gen:schema` -> `gen:docs` projection of the changed `.describe()`, and the `minor` changeset carrying the BREAKING banner and the disposition marker. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>
…napshots `check:objectui-pin-citations` refuses a sha a reader cannot find mechanically; `check:api-surface` / `check:export-origins` record the one added export, `checkDashboardWidgetStageOrder`, with nothing removed or narrowed. Two more pins: the door runs the EXPORT (declared once, attached once, by identifier) and `@objectstack/spec/ui` ships that same function object. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 135 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 9e0b3a3c7513df46f55c90b24cdc71b8ee54f621 && git checkout 9e0b3a3c7513df46f55c90b24cdc71b8ee54f621
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c1123cf2ad0c9f4e2e0d7b2c8bc6282e27eeb4f2 47599ccc66d6a19a3dfe39f1d57056ac6b1093c0 && git checkout -B drift-repro c1123cf2ad0c9f4e2e0d7b2c8bc6282e27eeb4f2 && git merge --no-ff 47599ccc66d6a19a3dfe39f1d57056ac6b1093c0
node scripts/docs-audit/affected-docs.mjs --json c1123cf2ad0c9f4e2e0d7b2c8bc6282e27eeb4f2
|
|
Seat adoption record — adopted VERBATIM. Tier verified from the transcript, ⛔ not self-report: 138 harness-stamped ⭐ The must-fix is a fourth non-coverage the PR names nowhere, and the reviewer found it by probing a mechanism claim instead of reading it. The round said it attached the check by identifier "so a Re-measured by this seat before adopting — ⛔ not taken on the reviewer's word: ⇒ After this lands, the client-side authoring door keeps accepting ⭐ And judgment 1 is the strongest verification of "correctly bounded" this seat has seen tonight. A differential corpus of 768 fixtures (24 types × 16 option shapes × 2 doors) run identically at base and head: base parses 546, head 386, 160 moved — and the 160 are exactly 20 non- ⭐ The refusal's advice was checked for truth, not just for presence. The order said a refusal that sends an author to a key that does not help them is worse than the silence it replaced. The reviewer followed Carrier handlingThe standing rule is that a returned verdict clears both carriers — 「FAIL 同 PASS 剥双载体」 — because the label means a review is pending and one has happened; the owed work rides the handover, ⛔ not a label. ⇒ Cleared on both, one stroke each seconds apart ( ⛔ This seat applied that rule inconsistently earlier tonight and says so rather than leaving two precedents standing: on PR #17567's first verdict (PASS WITH FINDINGS with two must-fixes) it kept the gate hung. The rule as written does not carve out that case, and the cycle is self-correcting anyway — the rework's push moves the head, ⛔ The PR stays draft, not enqueued, no auto-merge; card state and assignee untouched; ⛔ Contract review (
|
… and make the exported check equal the door
Contract review, must-fix. The PR claimed that attaching by identifier means
"a `.shape` mirror re-attaches the rule rather than a copy". Probed, and it is
false as a mechanism: `z.strictObject(DashboardWidgetSchema.shape)` ACCEPTS a
`horizontal-bar` carrying `stageOrder` and holds zero object-level checks, while
`.extend({})` keeps the refusal — a lit control (`type: 'ziggurat'`) is refused
by both, so the mirror carries the fields and it is precisely the check that is
dropped. Identifier attachment only makes re-attachment POSSIBLE.
That matters because objectui's authoring door IS that mirror
(`packages/types/src/zod/complex.zod.ts:627` at the pin) and re-attaches none of
the spec's exported checks, so its client-side door keeps accepting the key on a
`bar`. Named at all three sites that presented the list as complete: the check's
docblock, the changeset, and the migration entry's `acceptanceCriteria`, whose
"refused on its next authoring-path save" overstated the door coverage.
Two more things the rework surfaced rather than assumed:
- Adding the check to `object-refinement-check-exports.test.ts` (the declared
increment — the catalogue's population is "every mirrored spec object that
carries an object-level check", and this schema is measurably mirrored) turned
its parity leg RED: called directly with a raw fixture the export returned
early on an omitted `type`, while the door refuses, because the default fires
before object-level checks. Repaired at the producer — the check defaults
`type` itself — rather than by dropping the fixture. The accept set is
unmoved: `parse({...w})` and `parse({...w, type: undefined})` both yield
`type: 'metric'`, so the coalesce is unreachable through this door.
- zod 4 throws on `.omit()` / `.pick()` / `.partial()` of an object carrying a
refinement, so this change converts those three from working to throwing.
Latent, not live, and named as a fifth non-coverage.
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
Co-authored-by: Claude <noreply@anthropic.com>
…reads `check:objectui-pin-citations` refuses a sha in neither recognised spelling: these are historical measurements, so they take ``.objectui-sha` pin `<sha>``, not "the pinned `.objectui-sha` `<sha>`". Three sites, plus the regenerated registry. Gate now reports 12 asserting and 23 historical citations, exit 0. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>
|
Contract review verdict adopted verbatim — PASS. Second review, second rework. Seat readings before adopting (2026-09-11T07:00Z): tier from the review transcript Transport de-escaping applied by the seat and declared: ⭐ The reading the seat cared most about: the declared increment was taken, and taking it changed code — a Contract review — PR #17616 rework, head
|
| site | probes (lit) | stale | dark control |
|---|---|---|---|
packages/spec/src/ui/dashboard.zod.ts |
objectui's CLIENT-SIDE authoring door 1 · Five shapes, named so the gate 1 · .omit() 2 · ?? WIDGET_TYPE_DEFAULT 2 · checkDashboardWidgetStageOrder 5 |
Three shapes, named so the gate 0 · re-attaches the rule rather than a copy 0 · necessary but not sufficient 0 |
ziggurat-door 0 |
.changeset/dashboard-stageorder-gated-to-funnel.md |
client-side authoring door 1 · the **publish** door 1 · checkDashboardWidgetStageOrder 1 · .omit() 1 · BREAKING 1 |
Three shapes 0 · (uppercase PUBLISH door 0 — the round's self-catch, reproduced) |
0 |
…/semantic/18.dashboard-widget-stage-order-non-funnel-refused.ts |
WHICH DOOR 1 · Three more shapes this does NOT reach 1 · .omit() 1 · acceptanceCriteria 1 |
refused on its next authoring-path save 0 · Two shapes this does NOT reach 0 |
0 |
packages/spec/src/migrations/registry.ts (generated) |
WHICH DOOR 1 · Three more shapes… 1 · entry id 1 |
Two shapes… 0 · refused on its next authoring-path save 1 — chased: line 10473, a different entry ("whitespace is refused…"), not step18 |
0 |
Registry actually mirrors the entry: acceptanceCriteria blocks byte-identical after indent strip (36 lines each, diff empty); tsx scripts/build-migration-registry.ts --self-test --check → ✓ src/migrations/registry.ts is current (201 semantic, 165 retired-key, 178 retired-def), sentinel CHECK_EXIT_OK; regenerated with gen → blob 0fbb525fb3… identical to <ref>:packages/spec/src/migrations/registry.ts. Site 1's cited facts re-measured at objectui pin 53ded82b… (present as a commit in /home/user/objectui): mirror at packages/types/src/zod/complex.zod.ts:627 is specFieldsExcept(SpecDashboardWidgetSchema.shape, ['id','type']).extend({…}).strict(), type: DashboardWidgetTypeSchema.optional() with no default (.default( occurs once in that file, in a comment at line 541); all five exported check names 0 in packages/types/src and 0 whole-repo, against lit controls specFieldsExcept 17 and SpecDashboardWidgetSchema.shape 1. Must-fix delivered.
2. Is the corrected mechanism true in the new direction?
probe.ts (tsx, exit 0), section A: checks on the door 1; z.strictObject(door.shape) 0; z.object(door.shape) 0; door.extend({}) 1. horizontal-bar+stageOrder: door REFUSE [options.stageOrder]; both .shape mirrors ACCEPT []; .extend({}) REFUSE. Control type: 'ziggurat': REFUSE [type] on all four — the mirrors carry the fields, it is precisely the object-level check that is dropped. The strictObject helper is bare z.object(shape,{error}).strict() (no refinement of its own), so the base schema carried 0 checks — the "only makes re-attachment possible" sentence is a correct statement of zod's mechanism, and the sites it depends on (objectui:627, the helper, stack.zod.ts:371 + packages/cli/src/commands/{compile,validate}.ts for the "publish door" half) all say what the prose says. True, not merely different.
3. The increment and the coalesce — does it move any accept set?
- Spec door, old head vs new head, 176-row corpus (11
typevalues incl. absent/undefined/null/''/42/'list'/'ziggurat'× 8optionsshapes × valid/invalidid): 176/176 identical in verdict, issue paths and messages. Control: new head vs base differs on 30 rows, so the instrument could have read otherwise.parse({…w})andparse({…w, type: undefined})both yieldtype: 'metric'(string);type: nullis refused attype:invalid_valuealone and never reaches the check. The coalesce is unreachable at this door — the round's claim holds. - Raw call, old check vs new check (what a re-attaching mirror runs): 0 widening rows, 24 narrowing rows, all
type∈ {absent,undefined,null} with astageOrderkey present. The repair only ever refuses more, never less. ⛔ No widening hides here. - Simulated objectui mirror (shape spread minus
id/type,typeoptional with no default,.strict()) + new check: typeless+stageOrderREFUSE (old check: ACCEPT — the very drift the red leg caught); agrees with the spec door onstageOrderverdict 170/176, the 6 disagreements alltype: 'list'(objectui-only; the spec door refuses attype, the mirror correctly refusesstageOrder). Re-attaching is sufficient, as the prose now says. - Red control: old-head
dashboard.zod.tsswapped into my scratch copy, catalogue test →Tests 2 failed | 108 passed (110), both on the "declares NO type" fixture, exactly as reported; restored by hash008e7a65…. At the new head:vitest run --project local object-refinement-check-exports.test.ts dashboard.test.ts→ 193 passed (193).
4. The fifth non-coverage
Measured on zod 4.4.3: .omit(), .pick(), .partial() THROW at the new head and work at base (control) — verified claim, direction honest. Census with lit controls: .partial(/.omit(/.pick( occur 25/59/15 times in objectstack tracked sources, 0 of them on any Dashboard*/Widget* schema; objectui at the pin: 0 (its .partial() sites are SpecKanbanConfigSchema etc., not the widget). "Latent, not live" is honest for the two repos it names. ⚠ Outside those two: objectstack-ai/duly's two code-search hits are comment mentions, not derivations; cloud is not reachable from this session — unmeasured, stated as such, not a finding.
5. Outside scope / cards, NOT blockers
- C1 (docs precision, no code): the zod-4 set is wider than the three named —
.merge()always throws;.extend({<existing key>})throws (Cannot overwrite keys on object schemas containing refinements), so ".extend()is unaffected" is true only for disjoint extends (.safeExtend()is the safe spelling);.required()and.keyof()silently drop the refusal like.shape. All latent by the same census. - C2 (objectui#9111 carrier): at the pin a typeless widget renders as
''(DatasetWidget.tsx:422,DashboardWithConfig.tsx:109), notmetric, so the export's "resolves tometric" sentence is spec vocabulary there — the docblock already says so; and thetype: 'list'rows above are worth a pin on that side. - Inherited-only readings stated above; CI re-taken: 34 names, 30 success / 4 skipped / 0 red / 0 pending (
Check Changesetnow complete); combined status pending on Vercel only.
Verdict: PASS
Generated by Claude Code
…et, as a new key `check-widening-tells`' T1 tell fires on any added line shaped `identifier: z.Something` on the contract source surface. Two live pairs raised it against diffs that add no key at all: - PR #17616 — `ctx: z.RefinementCtx`, the second parameter of an exported object-level refinement. That signature is the repo's prescribed one, so every diff adding a cross-field refusal raised a widening tell for the refusal itself. - PR #17638 — `strategy: z.enum(['eager', 'lazy'], {`, an in-shape key the same block removed with one member MORE. Exit 4 against a correct `Clause-②: no`, holding a reviewed retirement PR out of the queue. The second was the net-delta budget being EARNED and refused at the spend: "a line that DECLARES a closed set is never spent" was written about an opener, which `memberTellKind` already answers `null` for, so the only lines it caught were keys whose value opens a closed set. It is replaced by the thing it protected — an inline set widened in place is visible on the T1 row alone — so such a key spends only on three facts the block carries: a removed line naming the same key, both member lists readable inline, and the added list a subset of the removed one. The parameter half reads which bracket is innermost over the line's own hunk; a shape body is brace-delimited by construction, and every state the reader cannot carry honestly answers "keep firing". Measured over the 233 commits touching these surfaces (`e9efc403`): of 20,193 rows the previous reading raises, 23 decline and 20,170 stand — all 23 T1, fifteen parameters and eight existing keys re-spelled around an identical enum. Claude-Session: https://claude.ai/code/session_01MCLBsUgfykL74aU716rzVK Co-authored-by: Claude <noreply@anthropic.com>
…et, as a new key (objectstack-ai#17760) Fixes objectstack-ai#17618 ## What was wrong `check-widening-tells`' **T1** tell reads an added line shaped `identifier: z.Something` on the contract source surface as *"a new key on a Zod object schema — the accept set gains a spelling an author may now write"*. Two live pairs raised it against diffs that spell nothing new. Both were reproduced from the PRs' own pushed bytes **before** anything was written. **Instance 1 — PR objectstack-ai#17616 (`packages/spec/src/ui/dashboard.zod.ts:470`).** The row fires on `ctx: z.RefinementCtx,`, the second **parameter** of an exported object-level refinement. `z.RefinementCtx` is a type; nothing constructs a shape there, and the diff it appears on *refuses* metadata that parses today. That signature is this repo's own prescribed one (the `objectstack-ai#16489` convention — `checkListViewPageMount`, `checkPageSourceCompleteness`, `checkGlobalFilterDateDefaultValue`), so **every** diff adding a cross-field refusal raised a widening tell for the refusal itself. **Instance 2 — PR objectstack-ai#17638 (`packages/spec/src/system/cache.zod.ts:197`).** The row fires on `strategy: z.enum(['eager', 'lazy'], {`, an in-shape key the same change block removed as `strategy: z.enum(['eager', 'lazy', 'scheduled']).default('lazy')`. Same key, one member **fewer**. That pair declares `Clause-②: no`, so it exited **4** and held a reviewed, green retirement PR out of the queue, where the only sanctioned clear is the false `yes` the file already refuses to ask an author for. ## The measurement that picked the fix Instance 2 is **not** the objectstack-ai#16943 net-delta budget being too thin. The budget was *earned* — the removed `strategy:` line is itself T1-shaped and bought one T1 unit — and then refused at the **spend**, by `!CLOSED_SET_OPENER.test(text)`: "a line that DECLARES a closed set is never spent against the budget". That clause was written about an **opener**, and an opener never reaches it: `memberTellKind` already answers `null` for an opener-only line. So the only lines it ever caught were **keys** whose value opens `z.enum(` / `z.union(` / `z.discriminatedUnion(` / `z.literal(`. Measured on this tree with the pre-change reader: ``` field: z.string() -> field: z.string().optional() 0 row(s) (declines) kind: z.enum(['a']) -> kind: z.enum(['a']).optional() 1 row (fires) ``` The asymmetry was accidental. ## What changed (`scripts/pm/check-widening-tells.mjs`, one file) **The spend.** What the blanket refusal was protecting is real and is kept: an **inline** set has no per-member line for T2 to read, so a set widened in place is visible on the T1 row and nowhere else. A closed-set-valued key may now spend the budget only on three facts the **block** carries — a removed line naming the **same key**, both member lists readable on their own line, and the added list a **subset** of the removed one. `z.enum(['a', 'b'])` to `z.enum(['a', 'b', 'c'])` still fires; a list that opens on a later line is unreadable and still fires; another key's removal pays nothing. **The parameter.** The claim is deliberately smaller than the depth-aware `z.object({ … })` reader T1's own comment refuses — the one whose cheap version fails GREEN by truncating. A Zod shape body is `{`-delimited **by construction**, so the question is never "which shape is this line in" but "which bracket is innermost", read over the line's **own hunk**, plus a declaration head left of that paren. Every state the reader cannot carry honestly answers `null` — *keep the tell firing*: a closer arriving on an empty stack (the hunk began inside something it was never shown), a string literal that does not close on its line, a declaration head it does not recognise (a method shorthand, for one). Nothing it returns ever means "no longer inside a shape", which is why it has no truncating failure mode. The same reading is applied on the **old** side too, the way objectstack-ai#16822's fragment rule is: a deleted parameter must not buy an added key the right to go unreported. ## Price of the change, measured Over the **233 commits** touching these surfaces in this tree's history, A/B'd row-for-row between the pre-change reader (`origin/main` `e9efc403`) and this one: | | rows | | --- | --- | | tell rows the previous reading raises | 20,193 | | the new reading raises | 20,170 | | **now decline** | **23** — all T1; no T2, T3 or T4 row moves | Of the 23: **15 are parameters** (twelve `ctx: z.RefinementCtx` / `z.core.$RefinementCtx`, three `input: z.input[typeof SomeSchema]`) and **8 are existing keys re-spelled** to carry `.meta({ title })` or a rewritten `.describe()` around an identical enum (e.g. `d2badf723`, `f502898a4`). Not one is a key or a member its diff added. On the tree itself, **16 of 8,974** T1-shaped lines under `packages/spec/src/**` sit inside a parameter list, 10 of them annotated `z.RefinementCtx`. The file's header prices this in the register it already uses, and states both quiet directions rather than leaving them to be discovered: 1. A parameter **added to an already-exported** function is a signature widening and now goes unreported here; nothing else in the file catches it (T3's listing records that an export EXISTS, and `api-surface-signatures.json` carries 27 `define*` helpers, none of them one of these checks). What is not lost is the function itself — a newly exported check adds its own T3 row, which is why objectstack-ai#17616 still reports one. 2. A widening carried by the **chained methods** rather than the member list (`.optional()` first among them) now declines on a closed-set-valued key. Not a new class: objectstack-ai#16943 already declines it for every key whose value is not a closed set; this removes the accidental exception rather than adding one. In the measured population all eight re-spellings are `.meta` / `.describe` rewrites and none adds `.optional()`. ## Acceptance (grading comment 5642818369) | item | before | after | | --- | --- | --- | | the parameter-list line reads no tell | T1 at `dashboard.zod.ts:470` | no row | | the in-shape re-spelling with a shrinking set reads no tell | T1 at `cache.zod.ts:197` | no row | | a genuinely new key on a shape STILL tells (control) | `manifest.zod.ts:44` | `manifest.zod.ts:44` | | the `objectstack-ai#16489` signature as a named fixture | — | `FILE_REFINEMENT_SIGNATURE` in `--self-test` | | `--pair 17638` with its `Clause-②: no` unchanged | **exit 4**, one T1 row | **exit 0**, no tell | ``` $ node scripts/pm/check-clause2-carriers.mjs --pair 17638 check-clause2-carriers: PR objectstack-ai#17638 / card objectstack-ai#17157 — the clause-② declaration is readable in the fixed spelling and both carriers agree, and its diff carries no widening tell. (exit 0) ```⚠️ **One acceptance item could not be run as written, and the reason is a fact about the board, not about the tree.** `--pair 17616` cannot form a pair: PR objectstack-ai#17616 **merged at 2026-09-11T07:41Z**, and the carriers script only judges an open PR — it exits **2** ("PR objectstack-ai#17616 is not open"), both before and after this change, which is not a T1 reading in either direction. Instance 1 is therefore reproduced and pinned where the reading actually lives: `tellsInFile` over that PR's real pushed patch (the bytes are the `--self-test` fixture), and the merged commit `1f0b5659e` is one of the 23 declines in the history A/B above. ## Verification - `node scripts/pm/check-widening-tells.mjs --self-test` — **269 cases pass** (245 before; +24 in the new battery, registered in the roster so the floor cannot be silenced by deleting it). - `node scripts/pm/check-clause2-carriers.mjs --self-test` — **465 cases pass** (it imports this file). - Gate families derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on the final commit and reconciled with `--ran`; every derived command run, exit codes in the report. - The decline is bracketed on every side by controls that still fire — an inline enum widened in place, a different key carrying a subset set, a list that opens on a later line, a real key added after the parameter list closes — because a reading that can only suppress is untestable in the direction that matters. ## Gate record — run on the final commit `923aed38a` `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives **32** families for the one changed path; all 32 were run and all 32 exited **0**, then reconciled with `--ran` (each line recorded as `command :: exit code`, captured before any pipe): ``` Run reconciliation — 32 derived, 32 run, 0 NOT-MEASURED, 0 UNRUN. ✓ dispatch-gates --ran: 32 derived famil(ies) accounted for — 32 run, 0 NOT-MEASURED (a DERIVED zero — all 32 recorded an exit code and none of them is 3). ``` Beyond the derived families, the two this diff owes by kind: `pnpm check:pm-widening-tells` (this file's own `--self-test`, which is what `lint.yml` runs) and `node scripts/pm/check-clause2-carriers.mjs --self-test` (the importing sibling) — both exit 0 and both are inside the 32. `git grep` finds no `*.test.ts` naming this script; its suite IS the self-test. Lint is delivered as a **proven narrowing**, not skipped: (i) the population is read from eslint's own config — the base entry's `files` glob is the whole tree (`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`), which covers this path; (ii) `npx eslint --no-inline-config --format json scripts/pm/check-widening-tells.mjs` reports **1 file, 0 errors, 0 warnings** (count read from the JSON, exit 0); (iii) invariance — this repo runs one `eslint.config.mjs` which **never enables type-aware linting for any file** (no `parserOptions.project`, no typed `@typescript-eslint` rules; the config says so at `eslint.config.mjs:326`), so a one-file diff cannot move the verdict on any file it does not touch. The whole-repo `eslint . --no-inline-config` remains CI's run. ## The one judgment call, on the four axes The card offered two shapes and this PR takes the smaller one. **实际业务需求**: the population is measured, not speculative — 233 commits, 23 rows, and one PR currently blocked by the instrument. **项目长远合理性**: the reading adds one bracket fact a hunk genuinely carries and refuses the depth-aware shape reader whose cheap version truncates; `null` means "keep firing" everywhere, so it cannot degrade silently (Route & surface ownership §3). **防 AI 写错**: the defect pushed a seat *away* from tightening a contract — it reported the narrowing direction as the widening one — which is the inverse of what clause ② exists to catch; correcting it makes the strict direction the cheap one. **创业阶段不扩散**: one file, no new flag, no new label, no new claim-line syntax, and no exit code moves. ## Acceptance notes - No out-of-scope edits: `check-clause2-carriers.mjs`, `packages/spec/**` and `.claude/**` are untouched; PR objectstack-ai#17638 is read-only evidence. - Changeset: `scripts/pm/**` publishes nothing from any package's `files[]` — this is the `skip-changeset` case the `Check Changeset` gate's own text calls textbook ("this PR edits a CI-internal script"). The label is applied on the PR. Authored by the skills-lane dev seat, session `session_01MCLBsUgfykL74aU716rzVK`, on claim comment 5642827410. --- _Generated by [Claude Code](https://claude.ai/code)_ Co-authored-by: os-sales <sales@objectstack.ai> Co-authored-by: Claude <noreply@anthropic.com>
…nd dashboard.globalFilters (objectstack-ai#18505) Fixes objectstack-ai#17505 Clause-②: yes — no authorable key moves, but every titled row property gains a `title` node in the emitted JSON Schema, a published artifact. Changeset is `minor` accordingly (`AGENTS.md` Post-Task Checklist step 3: `yes` takes at least `minor`). The `needs:contract-review` carrier is the seat's and is already on the card; this PR neither hangs nor strips it. ## What this does Studio renders a `type: 'repeater'` field as a table whose column headers read `items.properties[k].title ?? k` off the schema from `z.toJSONSchema(...)`. With no `title` the fallback arm runs in **every** locale, English included, so the maker saw `requiresService`, `filterBindings` and `optionsFrom` inside an otherwise translated panel. `resolveMetadataFormSchemaTitles` only ever REPLACES a title already present, so the English default has to live on the schema. This applies the mechanism ruled in objectstack-ai#16458 and already landed on `dashboard.header.actions` (PR objectstack-ai#17227) and on the `ai/skill`, `ui/report` and `ui/page` carriers (PR objectstack-ai#17500). No new contract decision. ## The row-property set, re-derived on today's tree The card's `17 + 10 = 27` is a 2026-09-10 reading, so it was re-derived through the platform's own predicate (`z.toJSONSchema` over `getMetadataTypeSchema`, `io: 'input'`) rather than by regexing source — using the derivation helpers copied byte-identical out of `repeater-item-titles.test.ts` itself. | carrier | authorable row properties | untitled before | untitled after | |---|---|---|---| | `dashboard:widgets` | 17 | 17 | 0 | | `dashboard:globalFilters` | 10 | 10 | 0 | **It is still 27, and the card is NOT stale.** `objectstack-ai#17616` removed `stageOrder` from widget types that do not read it, but `stageOrder` lives inside `options` — one level BELOW the repeater's row properties, where `options` itself is the single row property. A refusal at that depth cannot move this count. ## The emitted schema, before and after Read as Studio reads it — the property node first, never the `$ref` target, because a `.meta({ title })` on a schema zod hoists into `$defs` is emitted as a sibling of the `$ref`. - before: `widgets.items.properties.id.title` = `undefined` - after: `widgets.items.properties.id.title` = `"Widget ID"`, in **both** `io: 'input'` and `io: 'output'` - after: `globalFilters.items.properties.targetWidgets.title` = `"Target Widgets"` ## What must NOT get a title, and what was checked - **The five `retiredKey()` tombstones on this row** — `actionUrl`, `actionType`, `actionIcon`, `responsive`, `aria` — are untouched and still emit `title: undefined` in both io modes. A tombstone declares a key unwritable; an authoring label would advertise it as writable. They are objectstack-ai#17502's subject and are not addressed here. - **Every other repeater carrier is unchanged in the same run.** The other twenty carriers hold their exact counts — `field:options` 6, `object:fields.options` 6, `view:columns` 14, `view:sort` 2, `view:tabs` 9 still untitled — so no shared item schema leaked a title sideways. - **Non-repeater and composite siblings are unchanged**: dashboard top-level `columns` and `header.showTitle` still emit no `title`, which is what keeps the two absence controls in `packages/rest/src/meta-types-schema-titles.test.ts` valid (3/3 green). ## One control had to be re-pointed, not deleted `dashboard.test.ts` used `widgets[].id` as its "a sibling with no authored title has none" control — proving the emitter invents no titles. This change titles that property, so the control was re-pointed onto a **tombstone** (`widgets[].actionUrl`) and given a lit leg (`widgets[].id` is now `'Widget ID'`). It now pins the tombstone rule instead of borrowing a coincidence, which is strictly stronger than what it replaced. ## The ledger is paid, not weakened `repeater-item-titles.test.ts` is exact in both directions, so a paid debt left in the set is as red as an unpaid one. Both `dashboard:*` entries are **deleted**; five remain. Verified by name in the verbose run: `dashboard:widgets` and `dashboard:globalFilters` now execute without the `(ledger: still owed titles)` arm, while the five survivors keep it. ## Verification | check | result | |---|---| | `pnpm --filter @objectstack/spec build` | exit 0 (lock verdict `command-exit 0`) | | `pnpm --filter @objectstack/spec test` | **483 files / 13773 tests passed** | | `pnpm --filter @objectstack/spec typecheck` | exit 0 | | `pnpm --filter @objectstack/spec check:generated` | all **15** artifacts up to date, `check:authorable-surface` included | | `packages/rest` `meta-types-schema-titles.test.ts` | 3/3 passed | | `packages/platform-objects` translations | 8 files / 100 tests passed | `check:authorable-surface` green is the load-bearing one for the declared fence: **`packages/spec/authorable-surface.base.json` did not move and was not touched**, so the in-flight anchor-only PR that owns it is unaffected. ## Acceptance notes - `dashboard.form.ts` deliberately does **not** enumerate the `widgets` / `globalFilters` children. That enumeration is the i18n-catalog half (`os i18n extract` emits `REPEATER.PROPERTY` keys from the form's declared `label`s) and belongs to objectstack-ai#17508, a decision card. The spec-side pin that holds a form `label` equal to a schema `title` is scoped to `header.actions` only, so nothing here reds without it. - noted, not filed: the card and its triage both say "six `retiredKey()` tombstones ... (5 on `dashboard.widgets`)". On today's tree the two carriers in this card hold exactly **5**, all on `widgets`, and `globalFilters` holds **0** — so the sixth is not on either carrier of this card. 承接者: objectstack-ai#17502, whose subject is the tombstone set. --- _Generated by [Claude Code](https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6)_ Co-authored-by: Claude <noreply@anthropic.com>
Part of #17344 — finding 1 only (the ADR-0049 gate). Finding 2 landed in #17474; finding 3 (the locale drop) is objectui's and stays open on the card.
Clause-②: yes
This narrows a published accept set:
dashboard.widgets[].options.stageOrderparses today on every widgettypeand is refused here on every type exceptfunnel.needs:contract-reviewhangs on both carriers; ⛔ nothing lands until an at-tier review returns.What was wrong
optionsis the open renderer-extras bag, so nothing closed overstageOrder. Ahorizontal-barwidget carrying an authored seven-stage contract lifecycle parsed, booted, and forwarded the array to the renderer — which never looked at it, and rendered alphabetically by display label instead. Nothing warned, nothing refused, and the chart looked deliberate.Re-measured at this repo's
.objectui-shapin53ded82bf7a494f54e344e19099dbf00854b8694, not inherited from the reporter's published-tarball reading:categoryOrderreads inpackages/plugin-charts/src/AdvancedChartImpl.tsxgrep -o): the prop declaration (247), the destructure (850), and one read —buildCategoryRank(categoryOrder)at 1514if (chartType === 'funnel'), opened at 1473packages/plugin-dashboard/src/DatasetWidget.tsxstageOrderinAdvancedChartImpl.tsx⇒ funnel-only reproduces on this pin.
What it does now
DashboardWidgetSchemacarries an object-level check,checkDashboardWidgetStageOrder, that refusesstageOrderunless the widget'stypeisfunnel.Why object-level, and why not a field refinement.
stageOrderis declared atdashboard.zod.tsinsideDashboardWidgetOptionsSchema; thetypethat decides whether it means anything is that object's sibling one level up onDashboardWidgetSchema. A refinement attached tostageOrdersees the array and nothing else. The idiom is not invented for this: the same file already attachescheckGlobalFilterDateDefaultValuetoGlobalFilterSchemawith.superRefine(…)by identifier, and this follows it — a named function, chained on its own line, exported so it is the rule the door runs rather than a copy that can drift..shapemirror re-attaches the rule rather than a copy". That is false as a mechanism, and the review probed it rather than reading it. Reproduced here:z.strictObject(DashboardWidgetSchema.shape)ACCEPTS thehorizontal-bar+stageOrderwidget and holds zero object-level checks, while.extend({})keeps the refusal; a lit control (type: 'ziggurat') is refused by both, so the mirror does carry the fields and it is precisely the check that is dropped. Attaching by identifier only makes re-attachment possible. The consequence is non-coverage 1 below.The refusal, because the defect was silence. A bare "unrecognized key" would answer silence with a shrug, so the message names the key, the
typethis widget carries, and the onetypethat honours it — plus where ordering lives for every other type:The authored type is interpolated, not hard-coded, and a pin proves it: two different authored types produce two different messages.
Behaviour, both directions, measured
Every leg is
safeParseon an authored widget — ⛔ never a reading of the schema source or of its.describe()prose.type: 'horizontal-bar'+stageOrdercustomissue atoptions.stageOrdertype: 'funnel'+stageOrdertype: 'horizontal-bar', nostageOrder(otheroptionsmembers)type: 'horizontal-bar', nooptionsat allThe "before" row is not a claim about the past: the pin that asserted it —
CONTROL — the key is still UNGATED: a non-funnel widget carrying it parses too, added by #17474 precisely so a future gate would have a red test to flip — is the test this PR flips, and it is in the diff.What the gate does NOT cover
Stated so the change is not read as complete. The first and last arrived from the contract review.
@object-ui/typesbuilds its ownDashboardWidgetSchemafromspecFieldsExcept(SpecDashboardWidgetSchema.shape, …).extend({…}).strict()(packages/types/src/zod/complex.zod.ts:627at the pin), and a.shapespread drops every object-level check. Re-measured here rather than taken on the review's word: 0 occurrences of any of the five exported check names inpackages/types/src, against a lit control of 17specFieldsExceptcall sites and the mirror line itself present. So until objectui imports and chainscheckDashboardWidgetStageOrder, its dashboard editor keeps acceptingstageOrderon abarand the author meets this refusal later, at publish. Carrier: objectui#9111.typeoutsideChartTypeSchema. zod treats thatinvalid_valueas aborting and skips object-level checks for the input, sotype: 'ziggurat'+stageOrderreports the type refusal alone. The author fixes the type, re-parses, and meets this refusal then — the two are never seen together. Pinned, so a zod upgrade cannot change it silently.type.typecarries.default('metric')and zod applies defaults before object-level checks, so an omittedtypeis indistinguishable here from an authoredmetric. The verdict is right either way —metricreads the key no more thanhorizontal-bardoes — so that one case carries an extra sentence pointing at the missingtyperather than a wrong one, instead of claiming the author wrotemetric.string | number | boolean, unmatched against the dimension's picklist. Afunnelwith a misspelled stage parses and renders it in the sentinel position..omit()/.pick()/.partial(). zod 4 throws on all three once an object carries a refinement, so this change converts those three from working to throwing. Latent rather than live — no consumer in either repo derives the widget schema that way today — and.extend()is unaffected and keeps the refusal, which is the spelling the mirrors actually use.Sibling sweep —
stageOrderwas the only oneAsked of the same pin: is any other member of that generic bag read by a single branch? No.
dateGranularityDatasetWidget.tsx:443sortBy:444(→orderat 450)sortOrder:450limit:452stageOrder:1468→ forwarded →AdvancedChartImpl.tsx:1514chartType === 'funnel'Lines 443–455 sit outside every type branch (the only
widgetType ===reads in that span areisTable/isMatrix, which do not enclose them), so the other four act on every widget type. ⛔ Nothing was changed about them.Changeset level
minor, notpatch. An accept-set narrowing is a breaking change; the launch-window convention in theCheck Changesetstep's WHICH LEVEL prose ships breaking changes asminorand carries breaking-ness in the BREAKING banner plus the ADR-0087 disposition instead of in the bump. Both are present: the banner, and anadr-0087: registered dashboard-widget-stage-order-non-funnel-refuseddisposition marker (written as the HTML-comment form the gate reads, in the changeset file) against a new protocol-18 semantic entry. The disposition isregisteredrather thannot-required (no-migration-prescription)because there genuinely is a prescription and the changeset carries its FROM → TO table.Repo census before landing: zero authored widgets carry the key anywhere in the monorepo — 59 occurrences outside changelogs, all schema, tests, generated reference pages, the sdui-parser census and the gate that derives it (LIT control
sortBy= 180; DARK controlstageOrdre= 0).Not in this PR
options.stageOrderis declared for every chart type, documented for a chart type that does not exist, and silently dropped in every non-enlocale #17344.objectstack-ai/objectui. The asymmetry favours gating anyway: a narrowing later relaxed costs an author nothing, while an accepted-and-inert key costs them a chart that silently says something they did not author.Contract review rework
PASS WITH FINDINGS, one must-fix (prose, no behaviour change). The must-fix is non-coverage 1 above, now named at all three sites that presented the list as complete — the check's docblock, the changeset, and the migration entry'sacceptanceCriteria, whose "refused on its next authoring-path save" overstated the door coverage. Registry regenerated;check:migration-registryre-run and green.Two things the rework surfaced rather than inherited:
object-refinement-check-exports.test.tspins "every mirrored spec object that carries an object-level check", andDashboardWidgetSchemais measurably mirrored (non-coverage 1). Adding it turned the parity leg RED — called directly with a raw fixture the export returned early on an omittedtype, while the door refuses, because zod appliestype's default before object-level checks. ⛔ Repaired at the producer rather than by dropping the fixture: the check defaultstypeitself, so the export is the rule the door runs for every input, which is the whole premise of exporting it.parse({ …widget })andparse({ …widget, type: undefined })both yieldtype: 'metric', so the coalesce is unreachable through this door. The review's 768-fixture differential corpus was not re-run and did not need to be; the four behavioural rows re-measure identical at the new head.The review's other two cards are not taken here: objectui#9111 (above), and the present-tense wording inside the refusal message — left alone deliberately, because changing it would move a published message string on a change the review graded prose-only, and I am not otherwise editing that line.
验收备注
skills/objectstack-ui/rules/dashboards.md:345andcontent/docs/ui/dashboards.mdx:121are hand-written sites that still documentstageOrderfor apyramidtype, and are now also stale about the gate. They are already carried by Three hand-written sites still documentoptions.stageOrderfor apyramidchart type the schema refuses #17471 (sub-issue of Dashboard widgets:options.stageOrderis declared for every chart type, documented for a chart type that does not exist, and silently dropped in every non-enlocale #17344, with its own draft PR) — not widened into this PR, whose face is the schema.packages/sdui-parser/src/dashboard-widget-options.ts:51describes the key as "funnel/pyramid stage order" in a comment. Same carrier.This branch was produced in session https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH.
Generated by Claude Code