Skip to content

fix(spec)!: refuse dashboard.widgets[].options.stageOrder on every widget type that does not read it - #17616

Draft
os-bill wants to merge 5 commits into
mainfrom
claude/issue-17344-stageorder-adr-0049-gate
Draft

fix(spec)!: refuse dashboard.widgets[].options.stageOrder on every widget type that does not read it#17616
os-bill wants to merge 5 commits into
mainfrom
claude/issue-17344-stageorder-adr-0049-gate

Conversation

@os-bill

@os-bill os-bill commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

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.stageOrder parses today on every widget type and is refused here on every type except funnel. needs:contract-review hangs on both carriers; ⛔ nothing lands until an at-tier review returns.

What was wrong

options is the open renderer-extras bag, so nothing closed over stageOrder. A horizontal-bar widget 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-sha pin 53ded82bf7a494f54e344e19099dbf00854b8694, not inherited from the reporter's published-tarball reading:

probe reading
categoryOrder reads in packages/plugin-charts/src/AdvancedChartImpl.tsx 3 occurrences (grep -o): the prop declaration (247), the destructure (850), and one read — buildCategoryRank(categoryOrder) at 1514
the guard enclosing 1514 if (chartType === 'funnel'), opened at 1473
producer side, packages/plugin-dashboard/src/DatasetWidget.tsx 1468 builds the explicit order for any widget; 1529 forwards it whenever non-empty — no type gate
DARK control — stageOrder in AdvancedChartImpl.tsx 1 (a comment; the spec key never reaches that file by name)

⇒ funnel-only reproduces on this pin.

What it does now

DashboardWidgetSchema carries an object-level check, checkDashboardWidgetStageOrder, that refuses stageOrder unless the widget's type is funnel.

Why object-level, and why not a field refinement. stageOrder is declared at dashboard.zod.ts inside DashboardWidgetOptionsSchema; the type that decides whether it means anything is that object's sibling one level up on DashboardWidgetSchema. A refinement attached to stageOrder sees the array and nothing else. The idiom is not invented for this: the same file already attaches checkGlobalFilterDateDefaultValue to GlobalFilterSchema with .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.

⚠️ Corrected at review — what the export does NOT buy. An earlier revision of this body said attaching by identifier means "a .shape mirror 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 the horizontal-bar + stageOrder widget 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 type this widget carries, and the one type that honours it — plus where ordering lives for every other type:

options.stageOrder is authored on a widget of type: 'horizontal-bar', and type: 'funnel' is the only widget type that reads it — on every other type the key parses, is forwarded to the renderer, and no branch consults it, so the order you wrote is silently absent from what renders. Either write type: 'funnel', or delete stageOrder and order this widget with options.sortBy + options.sortOrder, which lower into the dataset query itself instead of re-sorting what it returned.

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 safeParse on an authored widget — ⛔ never a reading of the schema source or of its .describe() prose.

fixture before after
type: 'horizontal-bar' + stageOrder parses, array round-trips refused, one custom issue at options.stageOrder
type: 'funnel' + stageOrder parses parses, value intact
type: 'horizontal-bar', no stageOrder (other options members) parses parses
type: 'horizontal-bar', no options at all parses parses

The "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.

  • ⚠️ objectui's client-side authoring door — this refusal is the PUBLISH door's, not the editor's. @object-ui/types builds its own DashboardWidgetSchema from specFieldsExcept(SpecDashboardWidgetSchema.shape, …).extend({…}).strict() (packages/types/src/zod/complex.zod.ts:627 at the pin), and a .shape spread 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 in packages/types/src, against a lit control of 17 specFieldsExcept call sites and the mirror line itself present. So until objectui imports and chains checkDashboardWidgetStageOrder, its dashboard editor keeps accepting stageOrder on a bar and the author meets this refusal later, at publish. Carrier: objectui#9111.
  • A type outside ChartTypeSchema. zod treats that invalid_value as aborting and skips object-level checks for the input, so type: 'ziggurat' + stageOrder reports 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.
  • A widget that declares no type. type carries .default('metric') and zod applies defaults before object-level checks, so an omitted type is indistinguishable here from an authored metric. The verdict is right either way — metric reads the key no more than horizontal-bar does — so that one case carries an extra sentence pointing at the missing type rather than a wrong one, instead of claiming the author wrote metric.
  • The array's contents. Still unconstrained string | number | boolean, unmatched against the dimension's picklist. A funnel with a misspelled stage parses and renders it in the sentinel position.
  • Consumers that derive this schema with .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 — stageOrder was the only one

Asked of the same pin: is any other member of that generic bag read by a single branch? No.

key where it is read at the pin branch-guarded?
dateGranularity DatasetWidget.tsx:443 no — top of the component, lowered into the query at 572
sortBy :444 (→ order at 450) no
sortOrder :450 no
limit :452 no — lowered at 574
stageOrder :1468 → forwarded → AdvancedChartImpl.tsx:1514 yeschartType === 'funnel'

Lines 443–455 sit outside every type branch (the only widgetType === reads in that span are isTable / isMatrix, which do not enclose them), so the other four act on every widget type. ⛔ Nothing was changed about them.

Changeset level

minor, not patch. An accept-set narrowing is a breaking change; the launch-window convention in the Check Changeset step's WHICH LEVEL prose ships breaking changes as minor and carries breaking-ness in the BREAKING banner plus the ADR-0087 disposition instead of in the bump. Both are present: the banner, and an adr-0087: registered dashboard-widget-stage-order-non-funnel-refused disposition marker (written as the HTML-comment form the gate reads, in the changeset file) against a new protocol-18 semantic entry. The disposition is registered rather than not-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 control stageOrdre = 0).

Not in this PR

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's acceptanceCriteria, whose "refused on its next authoring-path save" overstated the door coverage. Registry regenerated; check:migration-registry re-run and green.

Two things the rework surfaced rather than inherited:

  • The declared increment was taken, because the catalogue's population turned out to include this schema: object-refinement-check-exports.test.ts pins "every mirrored spec object that carries an object-level check", and DashboardWidgetSchema is measurably mirrored (non-coverage 1). Adding it turned the parity leg RED — called directly with a raw fixture the export returned early on an omitted type, while the door refuses, because zod applies type's default before object-level checks. ⛔ Repaired at the producer rather than by dropping the fixture: the check defaults type itself, so the export is the rule the door runs for every input, which is the whole premise of exporting it.
  • That repair moves no accept set, measured rather than argued: parse({ …widget }) and parse({ …widget, type: undefined }) both yield type: '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.

验收备注

This branch was produced in session https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH.


Generated by Claude Code

os-bill and others added 3 commits September 11, 2026 03:27
…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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation protocol:ui tests tooling labels Sep 11, 2026
@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 7 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/spec/api-surface/ui.json, packages/spec/export-origins/ui.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/analytics.mdx (via DashboardWidgetSchema (symbol, a top-level const))
  • content/docs/ui/dashboards.mdx (via stageOrder (literal, a string literal in checkDashboardWidgetStageOrder))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v15.mdx (via DashboardWidgetSchema (symbol, a top-level const))
  • content/docs/releases/v16.mdx (via DashboardWidgetSchema (symbol, a top-level const))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/api-surface/ui.json, packages/spec/export-origins/ui.json) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 135 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c1123cf2ad0c9f4e2e0d7b2c8bc6282e27eeb4f2packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9e0b3a3c7513df46f55c90b24cdc71b8ee54f621 — the merge of head 47599ccc66d6a19a3dfe39f1d57056ac6b1093c0 into base c1123cf2ad0c9f4e2e0d7b2c8bc6282e27eeb4f2, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs c1123cf2ad0c9f4e2e0d7b2c8bc6282e27eeb4f2 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

os-bill commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator Author

Seat adoption record — adopted VERBATIM. PASS WITH FINDINGS, one must-fix (prose). domain:spec execution seat, session_01MkQhmuuJAVDjmeWNixwDDH, 2026-09-11T05:00Z.

Tier verified from the transcript, ⛔ not self-report: 138 harness-stamped model fields, all claude-fable-5-1 = CONTRACT_REVIEW_TIER; lit control 101 assistant messages. ⚠️ In-seat at-tier, ⛔ not cross-seat. ⚠️ 0 transport entities to restore.

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 .shape mirror re-attaches the rule rather than a copy." The reviewer probed it: z.strictObject(DashboardWidgetSchema.shape) ACCEPTS the horizontal-bar + stageOrder widget; .extend({}) keeps the refusal. ⇒ Attachment by identifier does not survive a .shape mirror — it only makes re-attachment possible. And objectui, at its pin, builds its authoring door from exactly that mirror and re-attaches none of spec's exported checks.

Re-measured by this seat before adopting — ⛔ not taken on the reviewer's word:

the pin resolves here      53ded82bf  fix(data-objectstack): lower an array analytics filter … (#7754)
the mirror site            objectui packages/types/src/zod/complex.zod.ts:627
                           export const DashboardWidgetSchema = specFieldsExcept(SpecDashboardWidgetSchema.shape, [
re-attachments in that package                                     : 0
LIT CONTROL  the mirror line itself is present                     ⇒ the 0 is a reading

⇒ After this lands, the client-side authoring door keeps accepting stageOrder on a bar. ⚠️ And the migration entry's acceptanceCriteria says the key is "refused on its next authoring-path save" — which overstates the door coverage. That sentence is the must-fix: ⛔ a gate that names three non-coverages and presents the list as complete must not omit the fourth.

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-funnel types × 4 stageOrder-carrying option sets × 2 doors. Zero funnel rows moved. Zero non-stageOrder option sets moved. ⇒ The narrowing refuses precisely what it claims and nothing else — measured, ⛔ not argued.

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 sortBy/sortOrder all the way down — read outside every type branch at DatasetWidget.tsx:441-455, lowered into the selection at :572-574, compiled into ORDER BY by both server strategies — and then found the caveat it inherits: a sortBy naming nothing in dimensions/values is silently dropped, caught at author time by the lint rule from #14148, ⛔ not by parse. ⇒ The advice is true; where it is weak is now on the record instead of being discovered by the next author.

Carrier handling

The 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 (⚠️ a lone stroke is what H35 fires on).

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, --pair then answers C3 ("the review that cleared this gate judged a different tree"), and the seat re-hangs. That is exactly what happened on #15117 tonight. ⇒ Clearing now is the rule; the C3 re-hang is the safety net.

⛔ The PR stays draft, not enqueued, no auto-merge; card state and assignee untouched; ⛔ Part of, not Fixes — finding 3 stays open on #17344.


Contract review (CONTRACT_REVIEW_TIER, isolated seat) — PR #17616 @ 2d171492

  • Implemented-by: branch claude/issue-17344-stageorder-adr-0049-gate
  • Reviewed-by: isolated subagent at CONTRACT_REVIEW_TIER, adopted by session_01MkQhmuuJAVDjmeWNixwDDH

Method: fresh worktrees at the PR head (2d171492ef) and its merge base (3ef96b4712), offline install, safeParse corpora and probes run in both; objectui read by git show at the pinned sha 53ded82b (the commit resolves in /home/user/objectui although that checkout's HEAD is elsewhere); GitHub read by REST. Nothing was posted, committed, or edited in any tracked file.

① Derived judgments

1. The narrowing is correct and correctly bounded (⭐). Differential corpus, identical script at base and head: 24 widget types (20 enum members + omitted + ziggurat/pyramid/42) × 16 options shapes × 2 doors (DashboardWidgetSchema, DashboardSchema.widgets[]) = 768 fixtures. Base parses 546, head 386, 160 moved — and the 160 are exactly 20 non-funnel types (19 enum members + omitted-type) × 4 stageOrder-carrying option sets (['a','b'], [], mixed members, stageOrder+siblings) × 2 doors, every one OK → custom@options.stageOrder. Zero funnel rows moved; zero non-stageOrder option sets moved (each sibling alone, all four together, passthrough extras, empty options, no options); stageOrder: null / 'a' still yield a single invalid_type on both sides (no doubled issue); stageOrder: undefined still parses. The PR's four behavioural rows all reproduce; the PR's own dashboard.test.ts + object-refinement-check-exports.test.ts run 178/178 green in the fresh worktree. One shape worth naming that the PR does not: stageOrder: [] on a non-funnel is now refused — consistent with "the key is refused", and inert either way at the pin (DatasetWidget.tsx:1474 falls back on empty), so not a legal shape lost.

2. The idiom was found, not invented — but the "survives a .shape mirror" claim is false as a mechanism. At the head, checkGlobalFilterDateDefaultValue is declared at dashboard.zod.ts:888, attached by identifier at :1037, exported, and catalogued by object-refinement-check-exports.test.ts:60/271/388-390; the new check follows it exactly (:432 declaration, :810 attachment, one custom check on the schema — probed _zod.def.checks.length === 1). Probed on the head: z.strictObject(DashboardWidgetSchema.shape) ACCEPTS the horizontal-bar+stageOrder widget; .extend({}) keeps the refusal. So attachment by identifier does not survive a .shape mirror — it only makes re-attachment possible. That matters here: objectui at the pin builds its authoring door from the mirror — packages/types/src/zod/complex.zod.ts:627 specFieldsExcept(SpecDashboardWidgetSchema.shape, …).extend({…}).strict() — and re-attaches none of the spec's four exported checks (grep of packages/types/src at the pin: 0 hits for any of the four names; its GlobalFilterSchema carries a hand-copied inline superRefine at :736, adopted per objectui#4165). After this lands, objectui's client-side door keeps accepting stageOrder on a bar — the objectui#7715 class, which is the incident the exported-check discipline exists for. This is a fourth non-coverage, and the PR does not name it anywhere. Also probed: zod 4.4.3 throws on .omit()/.pick()/.partial() of an object carrying refinements; no consumer in either repo derives the widget schema that way today (0 hits; lit control: 6 .shape sites in objectui), so this is latent, not live.

3. The refusal's advice is true, with one caveat it inherits (⭐). At the pin, sortBy/sortOrder/dateGranularity/limit are read at the top of DatasetWidget.tsx:441-455, outside every type branch (the only nearby widgetType === reads are isMatrix at :428, which do not enclose them), and lowered into the selection at :572-574; the server compiles query.order into ORDER BY in both strategies (service-analytics/src/strategies/objectql-strategy.ts:594-597, native-sql-strategy.ts:610-612). So the redirect lands on a key that genuinely orders every dataset-bound chart type. The caveat: DatasetWidget.tsx:448 silently drops a sortBy that names nothing in dimensions/values, and the zod schema does not cross-check it — that is #14148 (closed), which landed as the lint rule widget-sortby-unselected at packages/lint/src/validate-widget-bindings.ts:441, so an author following the advice is caught at author time by lint, not by parse. Acceptable. Two nits: the message says "on every other type the key parses, is forwarded…" (dashboard.zod.ts:467) in the present tense while the author is reading a refusal of exactly that; and the same redirect is issued for the single-value family (metric/gauge/solid-gauge/kpi/bullet, widgetDispatch.ts:63) where ordering means nothing — harmless.

4. The three declared non-coverages hold, and the second's extra sentence is honest. Corpus: ziggurat/pyramid/42 + stageOrderinvalid_value@type alone, both doors, base and head identical. Omitted type + stageOrdercustom@options.stageOrder (base: OK); the extra sentence claims only that metric is what an omitted type resolves to and does not assert the author wrote it — true. funnel + ['drafft', 42, true] parses on both; a {} member is invalid_union on both. But the list is presented as complete at dashboard.zod.ts:408-410 ("Three shapes, named so the gate is not read as complete"), .changeset/…:39-45, and the migration entry's acceptanceCriteria (18.dashboard-widget-stage-order-non-funnel-refused.ts:49-59, "refused on its next authoring-path save … Two shapes this does NOT reach") — and judgment 2 shows a fourth: the objectui .shape mirror runs no such check.

5. Sibling sweep — verified at the pin, holds. categoryOrder in AdvancedChartImpl.tsx: 3 occurrences by grep -o (:247 prop, :850 destructure, :1514 the read), :1514 inside if (chartType === 'funnel') opened at :1473, with the other guards at :1400/1555/1590/1719/1750/1847/1923 all silent on it; buildCategoryRank has one non-test call site in the whole pin; dark control stageOrder in that file = 1 (comment :1504). Producer DatasetWidget.tsx:1468-1474 builds and :1529 forwards for any widget. The only other stageOrder readers at the pin are a comment (ChartRenderer.tsx:91) and the sdui-parser census. What I could not verify from here: nothing material — the pin was fully readable.

6. Semver — correct against the written convention; the ADR-0087 entry is real and correctly dispositioned. .github/workflows/pr-automation.yml:712-720 and scripts/check-changeset-no-major.mjs header: during the launch window breaking changes ship as minor, breaking-ness is carried by the BREAKING banner plus the ADR-0087 disposition; the new export also widens the ui index, which alone takes at least minor. Both carriers present. The entry exists at entries/semantic/18.…ts, the registry is regenerated (check:migration-registry: "current, 201 semantic"), check-adr-0087-registration is green and reports the id as "new here"; step 18 is the pending major (package 17.4.0, protocolVersion 17.0.0). registered is the honest disposition — there is a FROM→TO prescription. ADR-0049's text is security-scoped, but the repo cites it as the general enforce-or-remove policy (docs/adr/0049…md:162-163), so the framing is the repo's own.

7. Fence — verified by state. Diff is 8 files, none under packages/console/** or any locale path; PR body opens Part of #17344, no closing keyword; card #17344 open, labels needs:contract-review + pm:dispatched; PR labels carry needs:contract-review; check-clause2-carriers --pair 17616 exits 0, "both carriers agree". CI on the head: 34 distinct names, 31 success, 3 skipped, 0 failed; combined status success. Finding 3 untouched; #17471/#17538 (the hand-written pyramid sites) are open and carry content/docs/ui/dashboards.mdx:120, which still teaches funnel / pyramid.

Minor non-reproducible figure: the PR's "59 occurrences outside changelogs" reads 29 at base and 91 at head over tracked files by grep -o — probably counted over built dist/. The conclusion it supports ("zero authored widgets carry the key") is independently a reading: 6 authored dashboard files exist under examples/ (lit control), none carries the key.

② Semver grading

@objectstack/spec: minor — correct. Accept-set narrowing (breaking) + one new public export; BREAKING banner present; ADR-0087 registered dashboard-widget-stage-order-non-funnel-refused present and gate-verified; major correctly not used per the launch-window convention.

③ Boundary-flag disposition

Clause-②: yescorrect and required. needs:contract-review on both carriers, read back. Nothing else in the accept set moved (judgment 1).

Must-fix before landing (prose, no behaviour change):

  • Name the fourth non-coverage — objectui's @object-ui/types DashboardWidgetSchema is a .shape mirror (complex.zod.ts:627 at the pin) that drops this check until objectui re-attaches checkDashboardWidgetStageOrder, so the client-side authoring door stays silent — at packages/spec/src/ui/dashboard.zod.ts:408-431, .changeset/dashboard-stageorder-gated-to-funnel.md:39-45, and the migration entry's acceptanceCriteria at packages/spec/src/migrations/entries/semantic/18.dashboard-widget-stage-order-non-funnel-refused.ts:49-59 (then gen:migration-registry), whose "refused on its next authoring-path save" currently overstates the door coverage.

Cards, not blockers:

  • objectui: re-attach checkDashboardWidgetStageOrder to the mirror (objectui#7715 / objectui#4165 class); needs an objectui card and a named carrier in the PR body.
  • Extend object-refinement-check-exports.test.ts with the new export (legs 1-2, parity/bijection); the PR's own test covers legs 3-4 and my probe confirms one check.
  • Wording: dashboard.zod.ts:467 present-tense "the key parses" inside a refusal of it.

Verdict: PASS WITH FINDINGS


Generated by Claude Code

os-bill and others added 2 commits September 11, 2026 05:04
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:ui size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant