Repository navigation
Dashboard widgets: options.stageOrder is declared for every chart type, documented for a chart type that does not exist, and silently dropped in every non-en locale #17344
Description
Activity
zhuangjianguo commented
on Sep 10, 2026 CollaboratorAuthorMore actionsA second consumer of finding 3's root cause: category colours fall back too
Measured on the same widget while narrowing its filter (
objectstack-ai/hotclmissue #59 / PR #62), before and after that change so it is pre-existing and unrelated to it:In a
zh-CNconsole the per-category colours fall back to a positional palette instead of the dimension's declared option colours.Activerenders green#2F7D5Binenand blue inzh-CN— the same category, the same authoredclm_contract.statusoption colour, two different colours depending on the viewer's locale.Same mechanism as finding 3, one prop over. The dashboard plugin builds both from the same label-resolved map:
let U = D(P.object, P.dimensionFields, r, { enabled: !s }), { categoryColors: G, dimensionLabels: K, categoryOrder: J } = useMemo(() => { … let i = n ? V(r?.options, oe(r, O)) : void 0; return { categoryColors: ie(i), dimensionLabels: R(e, t, O), categoryOrder: L(i) }; }, [U, O, u]);
categoryColorsandcategoryOrdercome out of the sameuseMemoover the same resolved option metadata, and the chart looks both up by the row's rendered category value. When the query rows carry unlocalized labels and the map carries localized ones, both lookups miss: ordering degenerates to the sentinel (finding 3) and colouring degenerates topalette[index].Two consequences worth having on the record:
- A chart's colour encoding is not locale-stable. Anywhere a colour carries meaning — a status that is authored red because it is bad — the meaning is present in
enand absent everywhere else, with nothing to indicate it. Screenshots, docs and support answers written against one locale describe a different chart than the one another locale renders. - Removing or adding a category shifts every colour after it. Because the fallback is positional, narrowing that widget's filter by one status re-coloured the remaining bars. An authored option colour cannot drift like that; a positional palette does.
This does not change the remedies already proposed — it raises the value of remedy 3. Keying the category map on stored values rather than resolved labels fixes ordering and colour together, in one place, without waiting on the analytics label-localization question. Remedies 1 and 2 (the doc string naming a nonexistent chart type, and author-time enforcement) remain independent.
Environment:
@objectstack/console@17.4.0, applicationobjectstack-ai/hotclm@2d63324,pnpm demoon a clean database, Chromium at 1440x1000,enandzh-CNcontexts, four passes.
Generated by Claude Code
- A chart's colour encoding is not locale-stable. Anywhere a colour carries meaning — a status that is authored red because it is bad — the meaning is present in
- addedpriority:p2Medium: important, M3Medium: important, M3
on Sep 10, 2026 Triage: lands in
DashboardWidgetOptionsSchemaunderpackages/spec(plus the renderer and its docs);domain:spec;priority:p2; class (c) three times over.options.stageOrderis an ungated member of the generic widgetoptionsobject, and three things are wrong with it — ⭐ all silent: nothing warns, nothing refuses, the widget renders and the authored order is simply absent.- Only the
funnelbranch reads it. On every other chart type it is accepted, forwarded, and never consulted. - Its own documentation names
pyramidas a primary case — and there is nopyramidchart type. ⇒ the documentation's headline example cannot be built. - Silently dropped in every non-
enlocale.
⇒ p2: a declared, documented, accepted key that does nothing on most chart types, nothing in most locales, and whose own docs point at a type that does not exist.
⇒
⚠️ These are three defects with three different repairs — name each in the PR, ⛔ do not fix one and close. Gating the key to the branch that reads it is a narrowing (declare Clause-②); thepyramidreference is a docs correction; the locale drop is a bug in the read path and is likely the most user-visible of the three.⚠️ Take the locale finding first — an option that works only inenis the kind of defect that reads as "our product is broken in your language".Size/model suggestion:M.分诊席位 ·
session_017VGfRocA8VjczSe84fgjY3· R+166 · 2026-09-10T14:36Z · 本评论来自分诊座位
Generated by Claude Code
- Only the
Claim: session_01MkQhmuuJAVDjmeWNixwDDH · branch claude/issue-17344-stageorder-declared-vs-honoured
Clause-②: no — declared at dispatch for the slice this round may land, ⛔ not for the whole card. Correcting a doc string that names a nonexistent chart type moves no accept set.⚠️ GatingstageOrderto the chart types that read it is a NARROWING and is fenced out of this round — if you conclude the gate must ship, that isClause-②: yeswithneeds:contract-review, and the answer is to stop and report, not to write it.File face declared —
packages/spec/src/ui/dashboard.zod.ts, its tests, and a changeset. ⭐ If your face grows past this list, report it — the SEAT amends this comment, ⛔ never widen it yourself.⚠️ First, a routing correction the seat owes you — triage's ordering cannot be executed in this repoTriage's grading (
5620431933) says: "⚠️ Take the locale finding first — an option that works only inenis the kind of defect that reads as 'our product is broken in your language'."⛔ That finding does not live here. Finding 3 (and the category-colour sibling in comment
5615601832) is measured in@objectstack/console'sdist/assets/plugin-dashboard-*.js— thecategoryOrder/categoryColorspair built in oneuseMemoover label-resolved metadata. That is the objectui renderer;packages/console'sdistis script-generated and ⛔ never hand-edited, and a UI defect routes torepo:objectui. ⇒ ⛔ Do not attempt finding 3, and ⛔ do not edit anything underpackages/console. The seat is handling the cross-repo half; you are not.So triage's "⛔ do not fix one and close" stands, and this is how it is satisfied: your PR names all three findings and lands only the ones that live in
packages/spec, saying plainly which half is elsewhere and where it went. The card stays open.What this round may do
① Finding 2 — the doc string, and it is the cheapest real fix.
dashboard.zod.ts's JSDoc and.describe()both say "funnel / pyramid stages". ⭐ There is nopyramidchart type — the reporter measured 0 occurrences in@objectstack/spec@17.4.0's emitted types and 0 in the console charts bundle. So the one sentence an author reads before using this option names two chart types, one of which cannot be built.⚠️ Re-measure that on the current tree rather than inheriting the 17.4.0 reading, with a lit control (a chart type that IS in the enum, e.g.funnel) and a dark control (a fabricated type). Per the failure-fix order this is route 2: make the correct form the only spelling.⭐ And the plural framing is load-bearing, not cosmetic: "ordered-sequence charts", "stages above all" is precisely what makes an author conclude the option applies to ordered marks generally — which is finding 1. So the corrected prose must say
funnelonly and say plainly that no other chart type reads it.② Finding 1 — MEASURE it, do not fix it. Establish, on the current tree and the current console pin, exactly which chart types read
stageOrder. The reporter's reading is funnel-only, from the published 17.4.0 bundle.⚠️ That is a published-tarball reading, not a reading of this repo's pin — re-derive it against.objectui-sha, and if you cannot reach the renderer at that pin, say NOT MEASURED rather than inheriting. Report the measurement; ⛔ do not write the gate.⭐ Why the gate is fenced, so this does not read as timidity: it is ADR-0049 enforce-or-remove on an accepted key, so it refuses metadata that validates today ⇒ published-surface narrowing ⇒
Clause-②: yes,needs:contract-review, and an ADR-0087 disposition.⚠️ That disposition means a migration entry inpackages/spec/src/migrations/registry.ts— a hot file with two open writers right now (#17439 and #17334, both enqueued). ⇒ Even if the gate were ruled, this is not the round that writes it.⛔ Fenced-out paths, each held by another open PR at claim time
packages/spec/src/ui/component.zod.tsandcomponent.test.ts(#17439) ·packages/spec/src/ui/view.zod.tsandview.test.ts(#17447) ·packages/spec/src/migrations/registry.tsandsrc/migrations/entries/**(#17439 and #17334) ·packages/console/**(generated) · anything underpackages/spec/scripts/**(a serial chain another PM seat drives).⭐ If you find yourself needing a migration entry, the accept set moved and you have left this round's scope — stop and report.
Falsify the premises FIRST
Every numbered finding is a premise for you to reproduce, not to inherit — and all of them were measured against published 17.4.0 tarballs plus a browser, which is a different tree from this one. A measured "already fixed" or "does not reproduce on this pin" is a good outcome and ⛔ is not a failed round; ⛔ closing the card is the seat's act, never yours.
⭐ A count or a zero is not a reading until you look at what it matched. Lit control (> 0) AND dark control (0) on every absence claim — and the
pyramid= 0 claim is exactly the shape that needs both. Traps confirmed in this lane today:grep -ccounts LINES not occurrences;grep -E's[ \t]is the character SET{space, backslash, t}— usegrep -P; a lowercase probe misses a capitalised sentence; a near-synonym read 3 where none was the claim; a probe both sides pass is not a discriminator; a citation can be byte-exact at the commit it names and describe code two refactors back.⛔ Never capture an exit code through a pipe —
cmd > log 2>&1; EXIT=$?.
⛔ Run a tool's own predicate; never re-implement it.
⚠️ Exit 3 = a gate's own PREREQUISITE NOT MET ⇒ NOT MEASURED, not red.
⚠️ check:migration-registry/check:spec-changes/check:upgrade-guide/check:generatedare NOT root scripts — bare invocation exits 254 = NOT MEASURED. Usepnpm --filter @objectstack/spec check:….
⚠️ check:doc-authoringrefuses a bare issue id inside a.describe()string — it projects intocontent/docs/references/**. PR #17439 hit exactly this today. Cite something a reader can act on.
⚠️ Editing a.describe()regeneratescontent/docs/references/ui/dashboard.mdx—gen:schemathengen:docs, in that order. That is a forced path, not scope growth; declare it.
⚠️ check:react-declaration-parityCAN run locally (sdui.manifest.jsonis tracked at the repo root; the baseline_commentnames the invocation). AGENTS.md's claim otherwise is stale (#17405).
⚠️ Verify-lock: ⛔ re-readbash scripts/pm/os-verify-lock.sh --statusbefore your first heavy run; exit 99 is NOT MEASURED, not red.Deliverable: a changeset, a pin, and a draft PR — and
⚠️ Part of #17344, ⛔ NOT a closing keyword. Findings 1 and 3 stay open after this lands; a closing keyword would shut the card and delete two live items.
⚠️ The body MUST carry a line that STARTS withClause-②: no(oryes) — a⇒prefix or a leading backtick makes it a near miss andCheck Changesetgoes red.
⚠️ Attribution: session id in body prose, ⛔ not a hand-written footer.
⛔ Do NOT flip ready, enqueue, or arm auto-merge.Commit trailers — ⛔ model-free, exactly:
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>Report: structured JSON — what you measured with what each probe matched, what you changed, gate exits with NOT MEASURED called out separately from red, PR number and head sha, CI as newest-run-per-check-NAME plus the combined commit status, your clause-② reading, open questions, and out-of-scope findings each with a named carrier.
Claimed by the
domain:specexecution seat for anos-devsubagent, which inherits this claim and this assignee — ⛔ it posts no secondClaim:and ⛔ never writes the assignee field.Batch independence: face (
packages/spec/src/ui/dashboard.zod.ts) measured disjoint from the two in-flight rounds (#16903data/driver/**; #17070kernel/plugin.zod.ts+packages/core/src/plugin-loader.ts) and from all open PRs at claim time. Read at 2026-09-10T15:20Z.domain:spec执行席 ·session_01MkQhmuuJAVDjmeWNixwDDH· 2026-09-10T15:20Z
Generated by Claude Code
os-dev-report
{ "issue": 17344, "status": "done", "branch": "claude/issue-17344-stageorder-declared-vs-honoured", "pr": "https://github.com/objectstack-ai/objectstack/pull/17474", "pr_number": 17474, "head_sha": "3d1635d7ca267f6b7a90863c4718060b2dbfe234", "premise_still_valid": true, "summary": "Landed only the packages/spec slice of the three-finding card, and the PR names all three. Finding 2 (the doc string naming a nonexistent `pyramid` chart type) reproduces on the CURRENT tree and is fixed: the JSDoc and `.describe()` on DashboardWidgetOptionsSchema.stageOrder now name `funnel` only AND state outright that no other widget type reads the key, sending the rest to sortBy/sortOrder; the generated reference page was regenerated through the forced gen:schema -> gen:docs path. Finding 1 was MEASURED and deliberately not fixed: funnel-only reproduces at this repo's .objectui-sha pin, and two of the four new pins record the ungated behaviour as it stands so a future gate has a red test to flip rather than a silence. Finding 3 and its category-colour sibling were not attempted: they live in the objectui renderer, and nothing under packages/console was touched. No accept set moved; `stageOrder` still parses on every widget type exactly as before. The card stays open, and the PR carries `Part of #17344`, not a closing keyword.", "tests": "SUITE — `pnpm --filter @objectstack/spec test` under scripts/pm/os-verify-lock.sh: VERDICT command-exit 0, 'Test Files 469 passed (469) / Tests 13229 passed (13229)'. `pnpm --filter @objectstack/spec typecheck` under the same lock: VERDICT command-exit 0 (tsc --noEmit + check:scripts-typecheck + check:test-typecheck, the latter printing 'OK -- 54 file(s) / 261 error(s) / 145 pinned signature(s) held'). Targeted first: `pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/ui/dashboard.test.ts` -> exit 0, 70 passed. ABLATION — two legs, both run from the COMMITTED state, both proving the mutation reached disk before any verdict was read and both restored byte-exact. Leg M1 (revert the .describe() to the old 'Explicit category order for funnel/pyramid stages (stored values)' wording): injected anchor grep -c = 1, removed anchor 'only widget type that reads it' grep -c = 0, git hash-object 4a971f4a60733368e094bea62345c8ff352c79fb != HEAD blob d7ba6af241fbff1c5339e0f557a0a97880a15ec2, run exit 1, 'Tests 2 failed | 68 passed (70)' -- the pyramid pin AND the funnel-only pin. Leg M2 (keep the new wording, drop only the clause 'is the only widget type that reads it'): injected anchor grep -c = 1, hash a0cfc9de8394968a70a4b1d9b2dc9abe9d042c49 != HEAD blob, run exit 1, 'Tests 1 failed | 69 passed (70)' -- ONLY the funnel-only pin, so the two assertions are discriminators rather than one probe both sides pass. Each leg restored with `git checkout HEAD -- packages/spec/src/ui/dashboard.zod.ts` (never the bare form) inside a trap on absolute paths; restoration proven by `git diff HEAD --stat` empty and by git hash-object returning to d7ba6af241fbff1c5339e0f557a0a97880a15ec2, not by an exit code. NO REBUILD/DIST PREFLIGHT was needed or claimed: dashboard.test.ts imports './dashboard.zod' as a relative source path inside the same package, so no dist participates in the resolution and packages/spec has no vitest alias redirecting it. LINT — no narrowing: `pnpm exec eslint . --no-inline-config --format json` was run over the WHOLE repository at git rev-parse --short HEAD = 3d1635d7, exit 0, 6559 files linted (count read from eslint's own JSON output), 0 errors, 0 warnings.", "measurements": { "finding_2_pyramid_absence_on_current_tree": "Tool's own predicate, not a grep: ChartTypeSchema.safeParse against the freshly built packages/spec/dist/ui/index.mjs. LIT 'funnel' ACCEPT; LIT 'bar' ACCEPT; CLAIM 'pyramid' REFUSE; DARK 'ziggurat' (fabricated) REFUSE; DARK 'bi-polar-bar' (a sibling variant removed in the same batch) REFUSE. Enum has 20 members and matches the reporter's 17.4.0 list exactly. Source-text probes with grep -o (occurrences, not lines): 'pyramid' 4 in packages/spec/src, LIT 'funnel' 18, DARK 'ziggurat' 0. What the pyramid hits MATCHED: chart.zod.ts:142 is the taxonomy NOTE recording its removal as a funnel-rendering variant; chart.test.ts:97 is the pin asserting ChartTypeSchema.parse('pyramid') throws; dashboard.zod.ts:258/266 were the two stale doc sites this PR fixes. So the claim reproduces AND the current tree explains why, which is what let the corrected prose cite chart.zod.ts / chart.test.ts instead of a bare issue id.", "finding_1_which_chart_types_honour_it": "MEASURED at the pin, NOT inherited from the published bundle. .objectui-sha = 53ded82bf7a494f54e344e19099dbf00854b8694; the /home/user/objectui checkout is shallow but `git cat-file -t` resolves that commit and `git grep PINSHA` reads its trees, so the renderer IS reachable. buildCategoryRank (the function that turns the forwarded order into a rank map, defined in packages/core/src/utils/chart-series.ts:907) has exactly ONE non-test call site in the whole pin: packages/plugin-charts/src/AdvancedChartImpl.tsx:1514, imported at line 52. Line 1514 sits inside `if (chartType === 'funnel')` at line 1473. DARK controls, all in the same file and all silent on categoryOrder: pie/donut (1400), treemap (1555), sankey (1590), radar (1719), scatter (1750), combo (1847), the cartesian fall-through drawing bar/horizontal-bar/line/area (1923+), and the single-value / tabular families short-circuited at 1360/1376; `column` is aliased to `bar` at 2093 and 2175 so it is covered by the cartesian reading. categoryOrder's only other occurrences in that file are the prop's JSDoc (247) and the destructure (850). Producer side has no gate either: packages/plugin-dashboard/src/DatasetWidget.tsx builds explicitOrder from options.stageOrder for ANY widget (1468-1474) and forwards it whenever non-empty (1529); packages/plugin-report/src/DatasetReportRenderer.tsx forwards the picklist-derived order the same way. => funnel-only REPRODUCES at the pin. Gate deliberately NOT written.", "finding_3_locale_drop": "NOT ATTEMPTED and NOT MEASURED, by dispatch: it lives in the objectui renderer (the categoryOrder/categoryColors pair built in one useMemo over label-resolved metadata), and packages/console's dist here is script-generated. No file under packages/console was touched." }, "gates": { "derivation": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, run with NO path arguments so the script derived the change set itself from the merge base (4 paths). Reconciled afterwards with --ran on a record carrying the form COMMAND :: exit CODE for all 106.", "reconcile_verdict": "'106 derived, 103 run, 3 NOT-MEASURED, 0 UNRUN' — exit 0. Every derived family accounted for.", "green": 103, "not_measured_not_red": [ "pnpm check:dual-build-cjs-loads :: exit 3 — PREREQUISITE NOT MET, reads built output of hono/account/setup/studio/client-react/client, none in this diff's closure", "pnpm check:lean-entry-closure :: exit 3 — PREREQUISITE NOT MET, loads @objectstack/objectql's built entry points", "pnpm check:type-check-debt :: exit 3 — PREREQUISITE NOT MET, --re-measure refuses with 31 workspace dependencies unbuilt; the gate's own header says a number measured from here would be a different world" ], "red": [], "first_reds_that_were_stale_dist_not_drift": "check:api-surface, check:browser-reachable-entries, check:dual-source-exports, check:entry-nameability, check:exported-any, check:generated and check:skill-examples all exited 1 on the first pass with their own 'the package is not built / build first, then re-run' text. Rebuilt packages/spec (and, for the last one plus the two @objectstack/lint doc gates and check:docs-transcript-drift, the @objectstack/client-react and @objectstack/lint closures) under the verify lock and re-ran: ALL GREEN, including check:generated reporting 'All 15 generated artifacts are up to date' — so the api-surface staleness it first reported was the stale dist, not drift from this diff.", "notable_named_gates": "check:doc-authoring exit 0 (the new .describe() carries no bare issue id — the shape it refuses, which PR #17439 hit today). check:widget-option-census exit 0. check:authorable-surface exit 0 (authorable-surface.base.json untouched; no ordinary build wrote it). check:objectui-pin-citations exit 0. check:nul-bytes exit 0, plus a manual grep -naP over every touched file for control bytes: no matches.", "stale_tree_note": "dispatch-gates warns HEAD is a few commits behind origin/main and that 3 files it derives from moved in that range: .github/workflows/board-snapshot.yml, scripts/measure-test-shard-timings.mjs, scripts/pm/board-snapshot.mjs — all PM board-snapshot tooling, none of them a family this diff touches." }, "ci": { "read_at": "once, immediately after opening the draft PR — no polling, no waiting", "head_sha": "3d1635d7ca267f6b7a90863c4718060b2dbfe234", "newest_run_per_check_NAME": { "distinct_names": 31, "completed_success": ["Check Documentation Links", "Check PR Size", "No other open PR may claim the same issue", "Part-of PR must not also close its card", "filter"], "completed_skipped": ["Console Pin Gate", "Packed-tarball smoke (opt-in)"], "in_progress": ["Auto Label", "Build Core", "Build Docs", "Check Changeset", "Dogfood Regression Gate (1/3)", "Dogfood Regression Gate (2/3)", "Dogfood Regression Gate (3/3)", "Dogfood Verify CLI", "Flag docs affected by code changes", "Governed Surface Queue Guard", "Lint & Repo Gates", "No other open PR may claim the same single-writer path", "Spec property liveness", "Temporal Conformance (live PG + MySQL)", "Test Core (1/6)", "Test Core (2/6)", "Test Core (3/6)", "Test Core (4/6)", "Test Core (5/6)", "Test Core (6/6)", "Type Check · consumer gates", "Type Check · debt ledger", "Type Check · source gates", "Type Check · workspace"], "failed": [] }, "combined_commit_status": "success — 1 context: Vercel (success). This is the commit-status API, separate from the check-runs above.", "note": "'Part-of PR must not also close its card' is already green, which is the check that would catch a closing keyword. CI convergence is the seat's to read, not mine." }, "clause_2_reading": "Clause-②: no. Nothing in the diff narrows an accept set. `stageOrder` is still declared, still optional, still ungated, and still parses on every widget type — two of the new pins assert exactly that, using a horizontal-bar widget carrying stageOrder. The diff is prose (JSDoc + .describe()), the generated reference page that projects from the .describe(), four pins and a patch changeset. The narrowing the card also asks for — gating the key to the type that honours it — is ADR-0049 enforce-or-remove on an accepted key, would be Clause-② yes plus needs:contract-review plus an ADR-0087 disposition in packages/spec/src/migrations/registry.ts (a fenced-out file with two open writers), and was NOT written. No needs:contract-review label was applied, and none is owed.", "mcp_calls": "2 — mcp__github__search_issues (one targeted dedup search; /search/* is blocked at the egress proxy so search has no REST equivalent, declared as a channel switch) and mcp__github__sub_issue_write (attaching #17471 to #17344). Everything else — the card body, all three comments, the sub-issue creation, the draft PR, the body read-back, check-runs and the combined status — went through repo-scoped REST, probed green at the start of the run (GET /repos/objectstack-ai/objectstack -> 200).", "open_questions": [ { "question": "Attribution shape on the PR body: the dispatch said 'session id in body PROSE, NOT a hand-written footer', while the standing clause I carry says the body ends with the session-URL footer block. I followed the dispatch (session URL in a closing prose sentence, no hand-written footer) because AGENTS.md's own attribution paragraph says 'Durable attribution lives in body prose or a comment'. Measured outcome: creating the PR through raw REST, the PLATFORM appended the session-URL footer itself, so the stored body carries exactly one footer and the sent body is a byte-exact prefix of it. Flagging the conflict rather than picking a side silently.", "options": ["A. Keep prose-only on this surface and let the platform append (measured: one footer, body byte-exact)", "B. Hand-write the footer as the standing clause says (risks two footers on this channel/action pair)"], "recommendation": "A — it is what was measured on this exact channel (raw REST) and action (create), and the platform-readings discipline forbids generalising one cell to another anyway." }, { "question": "Three hand-written sites still teach `pyramid` (skills/objectstack-ui/rules/dashboards.md:345, content/docs/ui/dashboards.mdx:121, packages/sdui-parser/src/dashboard-widget-options.ts:51). I filed them as sub-issue #17471 rather than fixing them, because they are outside the declared file face and the skills one sits under a published-skill line ratchet whose budget is the PM seat's to grant. Does the seat want that folded into the next round on this card, or dispatched separately?", "options": ["A. Dispatch #17471 as its own round (skills budget decided up front)", "B. Amend this card's file face and let the next slice take it with the gate"], "recommendation": "A — the skills line budget is a separate decision from the ADR-0049 gate, and mixing them would put a governed-surface path in the same PR as a narrowing." } ], "out_of_scope_findings": [ "filed as #17471 (sub-issue of #17344): three hand-written sites still document `options.stageOrder` for a `pyramid` chart type the schema refuses — skills/objectstack-ui/rules/dashboards.md:345, content/docs/ui/dashboards.mdx:121, packages/sdui-parser/src/dashboard-widget-options.ts:51. Class (a): copy the documented example and it fails parse. Carrier: the `domain:spec` seat, via the sub-issue, which is already in the dispatch pool.", "noted, not filed: the same stale two-type prose lives on the objectui side of the pin — the categoryOrder prop's own JSDoc at packages/plugin-charts/src/AdvancedChartImpl.tsx:235 ('Declared category order for ordered-sequence charts (funnel / pyramid)') and content/docs/plugins/plugin-dashboard.mdx:300. Carrier: the `domain:spec` seat's cross-repo half, which is already handling findings 3 and its colour sibling in objectui — this is the same file family it will open. Not filed from here because filing into another repo is that half's routing decision, not mine.", "noted, not filed: `check:react-declaration-parity` cannot run in this repo at all — check:generated names it as the one artifact whose input this repo cannot produce (it needs objectui's sdui.manifest.json, produced only by building objectui at the pin and enumerating it in a real browser). The claim comment states the opposite ('CAN run locally, sdui.manifest.json is tracked at the repo root; AGENTS.md's claim otherwise is stale, #17405'). Measured here: check:generated's own output says 'Cannot run here'. Carrier: whoever owns #17405 — this is a reading about that card's own subject, so it belongs in its thread rather than as a new card. Reporting the contradiction rather than choosing a side." ] }
Generated by Claude Code
Landing record — PR #17474 (finding 2 only). This card stays OPEN.
Verified by content on
origin/main0ee32edef5, read 2026-09-10T17:38Z:probe 'only widget type' (>0 = landed) : dashboard.zod.ts 1, dashboard.test.ts 1 stale 'funnel/pyramid stages' (expect 0) : 0 lit 'stageOrder' : dashboard.zod.ts 1, dashboard.test.ts 10The stale-side control reads 0 and the lit control reads non-zero, so the probe discriminates: the pre-#17474 wording is gone and the new wording is present.
Why this card does not close. #17474 declared
Part of #17344, notFixes, and it carried finding 2 alone. Two findings remain live:- Finding 1 — the ADR-0049 gate. Untouched by docs(spec): stop documenting
options.stageOrderfor a chart type that does not exist #17474. - Finding 3 — the locale drop. Fenced out at review as objectui's, not this repo's; it needs a card in
objectstack-ai/objectuibefore it can leave this one.
Next dispatch off this card should name finding 1 as its subject and carry finding 3's fence forward verbatim, so a dev does not re-derive the fence.
Generated by Claude Code
- Finding 1 — the ADR-0049 gate. Untouched by docs(spec): stop documenting
3 remaining items
Claim: session_01MkQhmuuJAVDjmeWNixwDDH — branch
claude/issue-17344-stageorder-adr-0049-gate
Branch:claude/issue-17344-stageorder-adr-0049-gateClause-②: yes
Gating
stageOrderto the widget type that reads it narrows a published accept set — a widget that validates today would be refused. Triage named that directly: 「Gating the key to the branch that reads it is a narrowing (declare Clause-②)」. ⇒needs:contract-reviewhangs on both carriers as soon as the PR exists, and ⛔ nothing lands until an at-tier review returns.⚠️ TheClause-②:line must BEGIN a line in the PR body —-,>,**are read; a##heading is NOT. ⭐ Run the body's line throughreadClause2Line(the exact functioncheck-changeset-no-majorimports) before posting;--pair's green is ⛔ not evidence the changeset gate can read it.Changeset owed; its LEVEL is yours to measure, ⛔ not asserted here.
⚠️ An accept-set narrowing is not apatch; read the repo's written convention rather than reasoning from a gate's behaviour.Dispatched by the
domain:specexecution seat at 2026-09-11T03:18Z.mode:subagent, oneos-dev, one worktree. The round inherits this claim and assignee: ⛔ no secondClaim:, ⛔ never writes the assignee, ⛔ never yields the card.⛔ Subject: FINDING 1 ONLY — the ADR-0049 gate
This card carries three defects with three different repairs. Finding 2 landed in PR #17474 (the
pyramiddocumentation correction). Finding 3 is NOT yours — see the fence below. ⇒ Your subject is finding 1 and nothing else.Finding 1:
options.stageOrderis an ungated member of the generic widgetoptionsobject, and only thefunnelbranch of the renderer reads it. On any other chart type it is accepted, forwarded and never consulted — silently. ADR-0049's shape: enforce or remove.Direction, from triage: gate the key to the branch that reads it. ⛔ Do not take the other arm of the card's either/or ("or ordered marks honour it") — that is a renderer change in
objectstack-ai/objectui, ⛔ not this repo and ⛔ not this lane.⚠️ The fence, carried forward VERBATIM so you do not re-derive itFinding 3 — the locale drop. Fenced out at review as objectui's, not this repo's; it needs a card in
objectstack-ai/objectuibefore it can leave this one.⚠️ Triage's comment says 「Take the locale finding first」. That instruction predates the review that measured finding 3 to be objectui's, and this seat's landing record for PR #17474 records the fence. ⇒ ⛔ Do not take finding 3. If you believe the fence is wrong, stop and report with the measurement — ⛔ do not act on the older instruction.Premise pre-measured by the seat BEFORE claiming —
origin/main, read 2026-09-11T03:17Zthe ungated key packages/spec/src/ui/dashboard.zod.ts:277 stageOrder: z.array(z.union([z.string(), z.number(), z.boolean()])).optional() finding 2 landed :280 '`funnel` is the only widget type that reads it: on any other type the key …' :272 'There is no `pyramid` widget type. It was removed from `ChartTypeSchema` …' the type lives elsewhere :379 type: ChartTypeSchema.default('metric') LIT CONTROL 'stageOrder' in that file : 1 ⇒ the readings above are readings⭐ The shape of the problem is in those two line numbers: the key is at
:277insideoptions, and the widget'stypeis at:379— different schema levels. A gate therefore cannot be a per-field refinement onstageOrderalone; it needs to see the siblingtype.⚠️ Measure what the surrounding schema actually permits (asuperRefineat the widget level, or whatever this file already uses for cross-field rules — ⛔ look for an existing idiom before inventing one), and say what you chose and why.⚠️ Falsify every line above on your own merge base. ⭐ The seat has been measurably wrong more than once tonight and was corrected by its own rounds each time; ⛔ do not defer to it.What the round owes
- The gate, with a refusal an author can act on. The message must name the key, the widget type that was authored, and the one type that honours it. ⛔ A bare "unrecognized" is not a repair for a key whose whole defect was silence.
- Behaviour, both directions, with lit controls. Before: a
horizontal-barwidget carryingstageOrderparses. After: it is refused with that message, and afunnelwidget carryingstageOrderstill parses, and ahorizontal-barwidget withoutstageOrderstill parses. ⛔ Prove by behaviour, never by reading the schema source. - Ablation. Remove the gate and show the refusal disappears; restore proven by state (
git hash-objectagainst the HEAD blob,git diff HEADempty).⚠️ grep -ccounts LINES — usegrep -o | wc -l. - Say what the gate does NOT cover. If some authored shape still slips through (a widget whose
typeis defaulted rather than written, say), name it rather than letting the PR imply completeness. - Sweep for siblings before you finish: is
stageOrderthe only member of that genericoptionsobject that a single branch reads? If there are others, ⛔ do not fix them — report them, with the measurement.
Fences
- ⛔ Finding 3 / anything locale-related — objectui's, per the fence above.
- ⛔
packages/spec/src/ui/action-params.zod.tsandexamples/app-todo/**belong to ActionEngineFacade.delete declares id: string while the runtime facade accepts string | string[] and examples/app-todo relies on the array form through a hand-rolled context type #15117, in flight beside you. - ⛔
packages/spec/src/conversions/registry.ts,docs/protocol-upgrade-guide.md,packages/spec/spec-changes.jsonbelong to spec: thetranslation-validation-messages-removedmigration text still stops at the authored literal — it never names the object-scoped bundle key that shipped in 17.3.0 #15437 / PR docs(spec): name the object-scoped bundle key in the validationMessages migration text #17604, awaiting its last gate. - ⛔ Never touch
content/docs/releases/; ⛔ never rewrite aCHANGELOG.md. ⚠️ If you mergemain: re-derive themerge=os-regenrouted set from.gitattributesat your merge time — ⛔ a quoted list is recall, not a reading (the register moved 9 → 10 tonight) — and if a routed path changed on both sides, probe its survival by content with a lit control: ⛔ a zero exit from that driver is not evidence the merge happened (incident [incident] Themerge=os-regendriver exited 0 on a live merge while DISCARDING main's side ofcontent/docs/references/ui/view.mdx— caught and repaired in-round, but a zero exit from that driver is not evidence the merge happened #17602).- Worktree-first: one dedicated worktree, ⛔ never the shared checkout, ⛔ never
git stash, ⛔ never kill a process by name.
⚠️ A prerequisite refusal does not always arrive as exit 3 — several arrived as exit 1 tonight purely because a package'sdistwas missing in a fresh worktree. Judge by substance, build, re-run; ⛔ never report a refusal as a pass and ⛔ never as a finding.⚠️ Edited 2026-09-11T04:40Z — one machine-read line added, nothing else.check-clause2-carriers --pair 17616exited 4 on C5, reporting this card as declaringClause-②: no. Measured cause:claimedBranchesreads only a line that BEGINSBranch:/Branches:, and this claim wrote the branch inline on theClaim:line — so it parsed zero branches,governingClaimreturned null, and the card declaration fell back to thread order, returning the spentClause-②: nofrom the finding-2 round. ⇒ TheBranch:line above is the repair; ⛔ no word of the order changed. ⭐ The defect was in this seat's own comment format, and the round measured it and reported it rather than writing the label itself — which was right: a dev that can re-hang its own gate can clear it.
Generated by Claude Code
os-dev-report
{ "issue": 17344, "status": "done", "scope": "finding 1 only — the ADR-0049 gate. Finding 2 landed in PR #17474; finding 3 stays fenced as objectui's.", "branch": "claude/issue-17344-stageorder-adr-0049-gate", "pr": "https://github.com/objectstack-ai/objectstack/pull/17616", "pr_number": 17616, "head_sha": "2d171492efcfb7d33f59475fede039aa525f2c4f", "premise_still_valid": true, "summary": "Finding 1 is fixed: `options.stageOrder` is now refused at parse on every widget `type` except `funnel`. Every line of the seat's pre-measurement was re-derived on my own merge base (3ef96b471) and every one held, including the shape claim — the key is inside `DashboardWidgetOptionsSchema` and the deciding `type` is its sibling one level up on `DashboardWidgetSchema`, so a per-field refinement cannot see it. I did NOT invent an idiom: `dashboard.zod.ts` already attaches a named, exported `(value, ctx)` check to `GlobalFilterSchema` with `.superRefine(…)` by identifier, and `checkDashboardWidgetStageOrder` follows it exactly — object-level check on the widget, refusing at path `options.stageOrder`. The refusal names the key, the authored type (interpolated, pinned to prove it is not a fixed string) and the one type that honours it, plus `sortBy`/`sortOrder` as where ordering lives for the rest. Clause-② yes; `needs:contract-review` hangs on both carriers, read back on both. Changeset is `minor` with a BREAKING banner and an ADR-0087 `registered` disposition against a new protocol-18 semantic entry — level measured from the repo's written convention (the `Check Changeset` step's WHICH LEVEL prose plus `check-changeset-no-major`'s launch-window header), not reasoned from the gate's behaviour. ⛔ The card's other arm (ordered marks honour it) was NOT taken: it is an objectui renderer change. Finding 3 was NOT touched.", "the_gate": { "where": "packages/spec/src/ui/dashboard.zod.ts — `checkDashboardWidgetStageOrder` declared once, attached once as `.superRefine(checkDashboardWidgetStageOrder)` on `DashboardWidgetSchema`, exported on the `@objectstack/spec/ui` barrel.", "why_object_level": "MEASURED, not assumed. On my merge base the key sits at dashboard.zod.ts:277 inside `DashboardWidgetOptionsSchema` (which is `.passthrough()` — an open bag that cannot refuse anything by being strict) and `type: ChartTypeSchema.default('metric')` at :379 is a sibling of `options` one level up on the widget. A refinement attached to `stageOrder` sees the array and nothing else. The existing idiom in the same file is `.superRefine(checkGlobalFilterDateDefaultValue)` on `GlobalFilterSchema` (declared at ~758, attached at ~907) — one exported function per refinement, attached by identifier so a `.shape` mirror re-attaches the rule rather than a copy. I looked for that before writing anything and used it.", "message": "`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." }, "behaviour_both_directions": { "method": "`safeParse` on authored widgets — ⛔ never a reading of the schema source or its `.describe()` prose.", "before": "`pnpm --filter @objectstack/spec exec vitest run src/ui/dashboard.test.ts` on the unmodified merge base: exit 0, 70 passed — INCLUDING #17474's pin `CONTROL — the key is still UNGATED: a non-funnel widget carrying it parses too`, which parses a `horizontal-bar` widget carrying `stageOrder` and asserts the array round-trips. That pin is the before-reading and it is the pin this PR flips.", "after": "exit 0, 83 passed. horizontal-bar + stageOrder REFUSED with one `custom` issue at `options.stageOrder`; funnel + stageOrder PARSES with the array intact; horizontal-bar with the other four `options` members and no `stageOrder` PARSES; horizontal-bar with no `options` at all PARSES. Also pinned: `pie` produces a message naming `pie` and not `horizontal-bar` (so the assertion cannot be met by a hard-coded string), and the refusal travels through `DashboardSchema.widgets[]` at path `widgets.0.options.stageOrder`." }, "gate_does_not_cover": [ "A `type` outside `ChartTypeSchema`: MEASURED — zod treats `invalid_value` as aborting and skips object-level checks for that input, so `type: 'ziggurat'` + `stageOrder` reports the type refusal ALONE. The two refusals are never seen together; the author meets this one on the next parse. 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 from an authored `metric`. The verdict is right either way, but the message cannot claim the author wrote `metric` — that one case carries an extra sentence pointing at the missing `type`. Both spellings pinned.", "The array's CONTENTS: still unconstrained string, number or boolean members, unmatched against the dimension's picklist. A `funnel` with a misspelled stage parses and renders it in the sentinel position. Pinned as a fact rather than left implied." ], "sibling_sweep": "ASKED AND ANSWERED: `stageOrder` is the only member of that bag whose effect is confined to one branch, so nothing else is in this class and ⛔ nothing else was touched. Read at the pin `53ded82bf7a494f54e344e19099dbf00854b8694`: `dateGranularity` (DatasetWidget.tsx:443), `sortBy` (:444, into `order` at :450), `sortOrder` (:450) and `limit` (:452) are all read at the TOP of the component, outside every type branch — the only `widgetType ===` reads in that span are `isTable`/`isMatrix` at 427-428, which do not enclose them — and they lower into the DatasetSelection at 572-574. `stageOrder` (:1468) is forwarded as `categoryOrder` for any widget (:1529) and consumed at AdvancedChartImpl.tsx:1514, inside `if (chartType === 'funnel')` opened at 1473. Counts by `grep -o | wc -l`, not `grep -c`: `categoryOrder` in AdvancedChartImpl = 3 (declaration 247, destructure 850, the one read 1514); DARK control `stageOrder` in that same file = 1, a comment.", "tests": "TARGETED — `pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/ui/dashboard.test.ts` under scripts/pm/os-verify-lock.sh: VERDICT command-exit 0, 83 passed (70 before the change). SUITE — `pnpm --filter @objectstack/spec test` under the lock: VERDICT command-exit 0, 'Test Files 473 passed (473) / Tests 13439 passed (13439)'. TYPECHECK — `pnpm --filter @objectstack/spec typecheck` under the lock: VERDICT command-exit 0, check:test-typecheck printing 'OK -- 54 file(s) / 259 error(s) / 144 pinned signature(s) held'. ABLATION — run from the COMMITTED state (HEAD 2d171492ef), one leg, trap on absolute paths. Removed the `.superRefine(checkDashboardWidgetStageOrder)` attachment and proved the mutation reached DISK before reading any verdict: attachment occurrences by `grep -o | wc -l` 1 BEFORE and 0 AFTER, injected marker 1, and `git hash-object` moved 4d06e536943f68585297a07a614061a67eb0c58a (the HEAD blob) -> f20de6c957bb482f0ed5171059ae4f230d290ae6. Ablated run: exit 1, 'Tests 8 failed | 75 passed (83)' — and the 8 are exactly the gate pins (the GATED pin, the path pin, the message pin, the not-a-fixed-string pin, both defaulted-type pins, the attached-by-identifier pin, and the DashboardSchema.widgets[] pin) while every CONTROL pin stayed green, so the controls are discriminators rather than legs that pass either way. RESTORED with `git checkout HEAD -- packages/spec/src/ui/dashboard.zod.ts` (⛔ never the bare form) and proven BY STATE, not by an exit code: `git hash-object` back to 4d06e536943f68585297a07a614061a67eb0c58a, `git diff HEAD` EMPTY, marker occurrences 0, attachment occurrences back to 1. NO dist preflight was needed or claimed and here is the reading behind that: dashboard.test.ts imports './dashboard.zod' — a RELATIVE specifier inside the same package — so no `dist/` is on the resolution path and the alias machinery (which keys on bare `@objectstack/*` specifiers) never sees it. LINT — no narrowing, the full union: `pnpm exec eslint . --no-inline-config --format json` over the WHOLE repository at `git rev-parse --short HEAD` = 2d171492ef with a clean tree, exit 0, 6608 files linted (count read from eslint's own JSON output), 0 errors, 0 warnings.", "gates": { "derivation": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, run with NO path arguments so the script derived the change set itself from the merge base. Header confirmed the repo assertion holds against this checkout's origin. Derived twice — 6 paths on the first pass and 8 on the final tree — and the COMMAND SET was byte-identical both times (109).", "reconcile_verdict": "'109 derived, 108 run, 1 NOT-MEASURED, 0 UNRUN' — exit 0, via --ran on a record carrying 'COMMAND :: exit CODE' for all 109.", "green": 108, "not_measured_not_red": [ "pnpm check:dual-build-cjs-loads :: exit 3 — the gate's own 'PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/' naming 33 unbuilt packages (account, setup, studio, cloud-connection, the four connectors and 25 more), none in this diff's closure. Its own last line: '⛔ This is NOT a pass: nothing was measured.'" ], "red": [], "first_pass_reds_that_were_NOT_drift": "Five gates exited 1 on the first sweep and every one was a real, actionable fact rather than a refusal misread as a pass — judged by substance, as instructed. (1) check:api-surface + check:export-origins + check:generated: my one new EXPORT, `checkDashboardWidgetStageOrder`, widened the public surface. Regenerated both snapshots; the diff is 2 added lines and '0 breaking (removed/narrowed)'. ⚠️ gen:api-surface REFUSED the first regeneration attempt, exit 1, because packages/spec/dist described a src that had moved under it ('recorded 652d65d5… · src now hashes to 3777cac0…') — that is the build-before-you-judge rule firing, so I rebuilt packages/spec and re-ran. (2) check:objectui-pin-citations: the pinned objectui sha in my migration entry was not in backticks, so it was a citation the gate could not find. Fixed at the entry and regenerated the registry. (3) check:skill-examples: a PREREQUISITE refusal arriving as exit 1, exactly the shape the dispatch warned about — its own text says 'packages/client-react/dist holds no .d.ts declarations — the package is not built'. Built @objectstack/client-react and re-ran: green. ⛔ Not reported as a red and ⛔ not reported as a finding. On the final sweep all five are green; `check:lean-entry-closure` and `check:type-check-debt` also converted from exit 3 to exit 0 once more packages were built.", "notable_named_gates": "check:widget-option-census exit 0 (no declared key added or removed, so the sdui-parser census is unmoved). check:authorable-surface exit 0 with authorable-surface.base.json untouched — no ordinary build wrote it. check:doc-authoring exit 0 (the refusal message carries no bare issue id, the shape it refuses). check:migration-registry exit 0. check:spec-changes and check:upgrade-guide exit 0 with BOTH artifacts untouched — see the fences note. check:nul-bytes exit 0, plus a manual `grep -naP` for control bytes over all 8 touched files: exit 123, no matches.", "forced_regeneration_path": "Editing the `.describe()` forced gen:schema then gen:docs, in that order; it moved exactly 2 lines of content/docs/references/ui/dashboard.mdx and nothing else. ⛔ Never run in a MERGE state: no merge was performed on this branch at all (no .git/MERGE_HEAD, and origin/main was never merged in), so the `merge=os-regen` re-derivation clause did not arise." }, "fences_honoured": "git status over every fenced path is EMPTY: packages/spec/src/conversions/registry.ts, docs/protocol-upgrade-guide.md, packages/spec/spec-changes.json (#15437 / PR #17604), packages/spec/src/ui/action-params.zod.ts, examples/app-todo/** (#15117), content/docs/releases/**, and every CHANGELOG.md. ⭐ The two ADR-0087 projections are the interesting ones, and they are untouched by MEASUREMENT rather than by luck: spec-changes.json carries protocolVersion 17.0.0 and aggregates majors 10..17, so a protocol-18 semantic entry projects into neither artifact. LIT CONTROL — a protocol-17 semantic id, `aggregation-node-distinct-retired`, appears 2 times in spec-changes.json and 1 time in docs/protocol-upgrade-guide.md; DARK — my new id and a known-18 sibling id both appear 0 times in both. The precedent commit f8e5790593 (the grouping-field narrowing that landed last night) touched the same 6 file kinds and likewise neither projection.", "clause_2_reading": "Clause-②: yes, and it is the right reading: metadata that parses today is refused after this. `needs:contract-review` hung on BOTH carriers by additive REST POST (⛔ not a whole-group replace) and READ BACK on both — #17344 now reads 'domain:spec, needs:contract-review, pm:dispatched, priority:p2' and PR #17616 reads 'needs:contract-review, size/l'. Nothing was stripped: `size/l` was applied concurrently by the size-labeler and survived, which an integral PUT would have erased. The PR body's declaration line was run through `readClause2Line` (the exact function check-changeset-no-major imports) BEFORE posting and again after the read-back: {kind: 'declared', value: 'yes', line: 'Clause-②: yes'}. CI's `Check Changeset` is green on the head, which is the gate-side confirmation the line is legible. ⚠️ `node scripts/pm/check-clause2-carriers.mjs --pair 17616` exits 4 — see open_questions, it is a stale-declaration READ about the card, not about the PR body, and C5 is report-only (it writes no label).", "ci": { "read_at": "once, after opening the draft PR — ⛔ no polling, no sleeping, no waiting", "head_sha": "2d171492efcfb7d33f59475fede039aa525f2c4f", "newest_run_per_check_NAME": { "distinct_names": 34, "failed": [], "in_progress": [], "skipped": ["Check PR Size", "Console Pin Gate", "Packed-tarball smoke (opt-in)"], "success": ["Auto Label", "Build Core", "Build Docs", "Check Changeset", "Check Documentation Links", "Dogfood Regression Gate", "Dogfood Regression Gate (1/3)", "Dogfood Regression Gate (2/3)", "Dogfood Regression Gate (3/3)", "Dogfood Verify CLI", "Flag docs affected by code changes", "Governed Surface Queue Guard", "Lint & Repo Gates", "No other open PR may claim the same issue", "No other open PR may claim the same single-writer path", "Part-of PR must not also close its card", "Spec property liveness", "Temporal Conformance (live PG + MySQL)", "Test Core", "Test Core (1/6)", "Test Core (2/6)", "Test Core (3/6)", "Test Core (4/6)", "Test Core (5/6)", "Test Core (6/6)", "Type Check · consumer gates", "Type Check · debt ledger", "Type Check · source gates", "Type Check · workspace", "TypeScript Type Check", "filter"] }, "combined_commit_status": "success — 1 context, Vercel=success. This is the commit-status API, separate from the check-runs above.", "note": "'Part-of PR must not also close its card' is green, which is the check that would catch a closing keyword. CI convergence is the seat's to read, not mine. ⛔ The PR is a draft; I did not flip it ready, enqueue it, or arm auto-merge." }, "premise_falsification": "Every line of the seat's 03:17Z pre-measurement re-derived on merge base 3ef96b471 and every one HELD — so nothing here rests on deference. dashboard.zod.ts:277 is the ungated key with exactly the quoted declaration; :280 and :272 carry #17474's landed prose; :379 is `type: ChartTypeSchema.default('metric')`; LIT CONTROL 'stageOrder' in that file = 1 at the time of the reading. ⭐ One thing the pre-measurement did not say and that turned out to decide the design: `DashboardWidgetOptionsSchema` is `.passthrough()`, so strictness was never available as a route — an open bag cannot refuse a key by being closed, which is exactly why the gate has to be a cross-field check rather than a shape change. ⭐ And one correction to my own first draft, caught by behaviour rather than by reading: I had assumed a bad `type` would produce BOTH refusals; the probe showed zod aborts on it and reports only the type. The docblock and the pin now state the measured behaviour rather than the assumed one.", "mcp_calls": "2 — two targeted mcp__github__search_issues calls, both dedup searches before deciding whether to file. Declared channel switch: /search/* is refused by the egress proxy by design and has no REST equivalent, and a label-guessed REST listing would have been an unverified dark read. Everything else went through repo-scoped REST, probed green at the start of the run (GET /repos/objectstack-ai/objectstack -> 200): the card body and all 7 comments, the draft PR creation, the PR body read-back, the label writes and their read-backs, the new card, its read-back, check-runs and the combined status.", "open_questions": [ { "question": "`check-clause2-carriers --pair 17616` exits 4 on C5, reporting that card #17344 'declares `Clause-②: no`'. It does not — the governing claim (comment 5628909337, 2026-09-11T03:15Z) declares `yes`. MEASURED cause: `governingClaim` selects the most recent claim comment THAT NAMES A BRANCH, and `claimedBranches` only reads a line beginning `Branch:` / `Branches:`. Both claim comments on this card write the branch INLINE on the `Claim:` line, so `claimedBranches` returns [] for both, `governingClaim` returns null, and `cardDeclaration` falls back to scanning every claim comment in THREAD ORDER — returning the FIRST declaration it finds, which is the spent 15:21Z `Clause-②: no` from the finding-2 round. That is precisely the stale-premise failure `governingClaim`'s own docblock says it exists to prevent. ⛔ I did not touch it: it is PM tooling, outside this card's face, and this is a fourth live instance of an already-open card rather than a new defect. Who amends the claim comment so the card reads `yes`? The seat owns it and I am forbidden to write it.", "options": [ "A. The seat amends its claim comment to carry a `Branch: claude/issue-17344-stageorder-adr-0049-gate` line on its own line — one edit, makes `governingClaim` select the newest claim, and C5 clears on the next --pair", "B. Leave it and let the reviewer read the exit-4 text against this report — C5 is report-only and writes no label, and CI's `Check Changeset` (which reads the PR BODY, where the declaration is unambiguous) is already green", "C. Fix `claimedBranches` to also read the inline spelling — the durable repair, but it is #17149's subject and not mine" ], "recommendation": "A now, C eventually. A is one edit by the seat that owns the comment and it makes the board's own reading true; B leaves a red exit that a later reader has to re-derive. C is the real fix but it belongs to #17149, which is open and already names this class." }, { "question": "Attribution shape on the PR body — the same conflict the previous round on this card flagged, and I resolved it the same way and re-measured rather than inheriting. I put the session URL in closing body PROSE and hand-wrote no footer. MEASURED on this exact channel (raw REST) and action (create): the PLATFORM appended `_Generated by [Claude Code](https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH)_` itself, so the stored body carries EXACTLY ONE footer and my sent body is a byte-exact prefix of it (8159 sent, 8249 stored, startsWith true). Should a dev hand-write the footer on this channel, or keep relying on the platform?", "options": [ "A. Keep prose-only on raw-REST create and let the platform append — measured twice now on this pair, one footer both times", "B. Hand-write it per the standing clause — risks two footers on this channel/action pair" ], "recommendation": "A, on the same grounds as last round: it is what was measured on this exact channel and action, and the platform-readings discipline forbids generalising one cell of that matrix to another anyway." } ], "out_of_scope_findings": [ "filed as #17618: `check-clause2-carriers`' T1 widening tell reads a TypeScript function PARAMETER annotated with a zod type as 'a new key on a Zod object schema'. Measured on this very PR: it reports `packages/spec/src/ui/dashboard.zod.ts:434`, the line `ctx: z.RefinementCtx,` in the signature of the refinement function, as an authorable key the accept set GAINS — on a diff whose entire content is a narrowing. Systematic rather than incidental: `(value, ctx: z.RefinementCtx)` is this repo's own prescribed signature for an exported object-level check, so every PR that adds a cross-field REFUSAL will raise a widening tell for the parameter of the function doing the refusing. Deduped first against a targeted search: #16943 is the same class on T2 and is CLOSED; #17098 and #17346 are other reading defects in the same script and neither covers this. Carrier: the card itself, which is unassigned and in the dispatch pool.", "noted, not filed: the C5 stale-declaration read described in open_questions — `claimedBranches` returning [] for a claim that writes its branch inline, so `governingClaim` falls back to thread order and reads a spent declaration. Carrier: #17149, OPEN, whose title is exactly this class ('A `Claim:` comment that yields ZERO parsed branches silently stops governing instead of saying so — #16170's class recurring in a third spelling, now measured on three live cards'). This is a fourth live card for it, and reporting it into that thread is worth more than a duplicate.", "noted, not filed: three hand-written sites document `options.stageOrder` for a `pyramid` type and are now ALSO stale about the gate — skills/objectstack-ui/rules/dashboards.md:345, content/docs/ui/dashboards.mdx:121, packages/sdui-parser/src/dashboard-widget-options.ts:51. Carrier: #17471, the sub-issue filed off this card by the previous round, which already has draft PR #17538. ⛔ Not widened into this PR: the skills path sits under a published-skill line ratchet whose budget is the seat's to grant, and mixing a governed-surface path into a PR carrying a narrowing is the thing the previous round declined to do for the same reason.", "noted, not filed: the `Dogfood Regression Gate` and `Test Core` check names appear BOTH bare and sharded on this head (`Test Core` and `Test Core (1/6)`..`(6/6)`; `Dogfood Regression Gate` and `(1/3)`..`(3/3)`), 38 check_runs across 34 distinct names. All green here so nothing is hidden, but a required-context set matched by NAME cares about that duplication. Carrier: none identified — I could not name a PR or a person who will touch this, so I am recording it as an observation rather than claiming it has a home." ] }
Generated by Claude Code
os-dev-report
{ "issue": 17344, "status": "done", "round": "contract-review rework — PASS WITH FINDINGS, one must-fix (prose)", "scope": "finding 1 only. Finding 2 landed in PR #17474; finding 3 stays fenced as objectui's.", "branch": "claude/issue-17344-stageorder-adr-0049-gate", "pr": "https://github.com/objectstack-ai/objectstack/pull/17616", "pr_number": 17616, "head_sha": "47599ccc66d6a19a3dfe39f1d57056ac6b1093c0", "previous_head_sha": "2d171492efcfb7d33f59475fede039aa525f2c4f", "premise_still_valid": true, "summary": "The must-fix is addressed, and I reproduced the falsification myself rather than taking it on the review's word: `z.strictObject(DashboardWidgetSchema.shape)` ACCEPTS the horizontal-bar + stageOrder widget and holds ZERO object-level checks, while `.extend({})` keeps the refusal — with a lit control (`type: 'ziggurat'`) refused by BOTH, so the mirror carries the fields and it is precisely the check that is dropped. My claim that identifier attachment means a `.shape` mirror re-attaches the rule was false as a mechanism; it only makes re-attachment possible. The fourth non-coverage is now named at all three sites that presented the list as complete, plus a fifth I found while probing. The declared increment was TAKEN — and taking it turned the catalogue's parity leg RED, which I repaired at the producer rather than by dropping the fixture. Carrier for the objectui half named in the PR body: objectui#9111.", "must_fix": { "what": "Name objectui's `.shape` mirror as a fourth non-coverage at the three sites that presented the list as complete, and stop the migration entry's `acceptanceCriteria` overstating the door coverage.", "reproduced_myself": "`z.strictObject(DashboardWidgetSchema.shape)` => ACCEPTS, 0 object-level checks. `z.object(.shape)` => ACCEPTS. `.extend({})` => REFUSES. The door itself => REFUSES. LIT CONTROL `type: 'ziggurat'` => REFUSED by the door AND by the `.shape` mirror, so the mirror does carry the fields and the ACCEPTS is specifically the missing check, not a broken probe.", "objectui_consequence_re_measured": "At the `.objectui-sha` pin 53ded82bf7a494f54e344e19099dbf00854b8694: `packages/types/src/zod/complex.zod.ts:627` builds its `DashboardWidgetSchema` from `specFieldsExcept(SpecDashboardWidgetSchema.shape, ['id','type']).extend({…}).strict()`. Re-attachments of the five exported spec checks in `packages/types/src`: 0, 0, 0, 0, 0. LIT CONTROLS: the mirror line itself present (1), and `specFieldsExcept` used 17 times in that package — so the zeroes are readings. One thing I measured that the review did not name: that mirror DROPS `type` from the spread and redeclares it `DashboardWidgetTypeSchema.optional()` with NO default (the file uses `.default(` exactly once, and not there).", "sites_proven_by_state_on_the_PUSHED_ref": "Read back with `git show origin/claude/issue-17344-stageorder-adr-0049-gate:PATH` at 47599ccc66, counted with `grep -o | wc -l` (⛔ never `grep -c`, which counts LINES). SITE 1 packages/spec/src/ui/dashboard.zod.ts — PROBE \"objectui's CLIENT-SIDE authoring door\" 1, PROBE 'Five shapes, named so the gate' 1, STALE 'Three shapes, named so the gate' 0, STALE 're-attaches the rule rather than a copy' 0, LIT 'checkDashboardWidgetStageOrder' 5. SITE 2 .changeset/dashboard-stageorder-gated-to-funnel.md — PROBE 'client-side authoring door' 1, PROBE 'the **publish** door' 1, PROBE 'checkDashboardWidgetStageOrder' 1, PROBE '.omit()' 1, STALE 'Three shapes' 0, DARK 'ziggurat-door' (fabricated) 0, LIT 'BREAKING' 1. SITE 3 the migration entry — PROBE 'WHICH DOOR' 1, STALE 'refused on its next authoring-path save' 0, STALE 'Two shapes this does NOT reach' 0, PROBE 'Three more shapes this does NOT reach' 1, LIT 'acceptanceCriteria' 1. SITE 3b the GENERATED registry — PROBE 'WHICH DOOR' 1, LIT the entry id 1, so `gen:migration-registry` really carried the edit through. ⚠️ Honest note on my own method: my first pass at SITE 2 read 0 because I probed the uppercase 'PUBLISH door' while the changeset spells it '**publish**' — the lowercase-probe-misses-a-capitalised-sentence trap, in reverse. I re-probed with the spelling actually used rather than recording the 0.", "gen_and_check": "`pnpm --filter @objectstack/spec gen:migration-registry` -> '✓ wrote src/migrations/registry.ts (201 semantic, 165 retired-key, 178 retired-def)'; `check:migration-registry` -> exit 0, '✓ src/migrations/registry.ts is current'." }, "fifth_non_coverage_found_while_probing": "zod 4 THROWS on `.omit()` / `.pick()` / `.partial()` of an object carrying a refinement — probed all three on this schema, all three throw ('.omit() cannot be used on object schemas containing refinements'). So this change converts those three from working to throwing for any consumer that derives the widget schema that way. Latent rather than live (the review measured 0 such consumers in either repo, lit control 6 `.shape` sites in objectui), and `.extend()` is unaffected. Named alongside the fourth at all three sites rather than left for the next reader, on the same principle the must-fix states.", "declared_increment": { "taken": true, "why_it_qualified": "The catalogue's population predicate is 'every mirrored spec object that carries an object-level check'. Before this PR `DashboardWidgetSchema` was mirrored but carried no check, so it was correctly absent; it now carries one, and objectui's mirror of it is exactly what non-coverage 4 measures. ⇒ leaving it out would have made the file's own count-and-bijection claim false over its stated population. It IS a per-export catalogue, so the escape did not apply.", "what_it_caught": "⭐ Adding it turned leg 1 RED — 2 failures, both on the omitted-`type` fixture: 'the direct call refuses at exactly the declared paths' and 'the schema parse and the direct call agree issue for issue'. Cause, measured: the catalogue calls the export DIRECTLY with the RAW fixture, so `widget.type` is `undefined`, while the door applies `type`'s `.default('metric')` BEFORE object-level checks. The export was refusing strictly LESS than the door it is exported from — which is the one thing an exported check may not do, and precisely the drift this catalogue exists to catch.", "repair": "At the producer, ⛔ not by dropping the fixture: the check now reads `widget.type ?? WIDGET_TYPE_DEFAULT`. Both suites then run 193/193 green.", "accept_set_unmoved_measured": "`parse({ …widget })` -> `type: 'metric'` (typeof string) and `parse({ …widget, type: undefined })` -> `type: 'metric'` (typeof string). `type` is therefore never `undefined` when object-level checks run at this door, so the coalesce is unreachable here and no verdict can change. The four behavioural rows re-measured identical at the new head: horizontal-bar+stageOrder REFUSED, funnel+stageOrder PARSES, horizontal-bar without stageOrder PARSES, horizontal-bar with no options PARSES. ⛔ The review's 768-fixture differential corpus was NOT re-run, as instructed, and needed no re-run. Where the coalesce DOES act is the objectui mirror, whose `type` has no default — so re-attaching there is now sufficient, and the docblock/changeset say so instead of the earlier 'necessary but not sufficient'.", "legs_3_and_4": "Left where they are: the catalogue's leg-4 `it.each` keeps its four rows and now carries a comment saying it is not an enumeration of every exported refinement, because this export's barrel-identity and attached-by-identifier legs live beside the schema they guard in dashboard.test.ts." }, "tests": "SUITES — `pnpm --filter @objectstack/spec build && test && typecheck` chained under scripts/pm/os-verify-lock.sh: VERDICT command-exit 0, 'Test Files 473 passed (473) / Tests 13454 passed (13454)' (13439 before the rework), typecheck clean with check:test-typecheck 'OK -- 54 file(s) / 259 error(s) / 144 pinned signature(s) held'. TARGETED at the final head, under the lock: dashboard.test.ts + object-refinement-check-exports.test.ts -> exit 0, 193 passed (193). ABLATION re-run at the reworked head, from the COMMITTED state, trap on absolute paths: attachment occurrences by `grep -o | wc -l` 1 BEFORE / 0 AFTER, injected marker 1, `git hash-object` moved e85e7f6f83c0816506f15cefa16fc5b1156688bd (the HEAD blob) -> 8914e39ece5be084516efaaf38d16c1da5054115; ablated run exit 1, 'Tests 8 failed | 75 passed (83)', the 8 being exactly the gate pins while every CONTROL pin stayed green; RESTORED with `git checkout HEAD -- PATH` and proven BY STATE — hash back to e85e7f6f83, `git diff HEAD` EMPTY, marker 0, attachment 1. LINT — full union, no narrowing: `pnpm exec eslint . --no-inline-config --format json` over the whole repo at `git rev-parse --short HEAD` = 47599ccc66 with a clean tree, exit 0, 6608 files linted (count from eslint's own JSON), 0 errors, 0 warnings.", "gates": { "derivation": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, no path arguments. 109 families, the same set as both earlier sweeps.", "reconcile_verdict": "'109 derived, 108 run, 1 NOT-MEASURED, 0 UNRUN' — exit 0, via --ran, on a full sweep of the FINAL tree.", "green": 108, "not_measured_not_red": [ "pnpm check:dual-build-cjs-loads :: exit 3 — 'PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/', naming 33 unbuilt packages, none in this diff's closure. Building those is a whole-farm run CI owns. Its own last line: 'This is NOT a pass: nothing was measured.'" ], "red": [], "a_red_I_introduced_and_fixed": "⚠️ Reporting this against myself. My first rework push spelled the new objectui pin citations as 'the pinned `.objectui-sha` BACKTICKED-SHA', and `check:objectui-pin-citations` refused all three (exit 1, 'names a sha in neither recognised citation spelling'). The gate admits exactly two forms and they mean different things: ``.objectui-sha` = BACKTICKED-SHA` asserts THIS is the pin we build against (checked), ``.objectui-sha` pin BACKTICKED-SHA` records a measurement taken AT that pin (historical, not checked). Mine are historical, so all three moved to the `pin` form and the registry was regenerated. Gate now: exit 0, '12 asserting objectui pin citation(s) match .objectui-sha (53ded82bf), 23 historical citation(s) recorded and not checked, across 1385 spec source(s)'.", "prerequisite_refusals_resolved_by_building": "Four families first read exit 3 / exit 1 in a freshly re-created worktree purely because their inputs were unbuilt — check:doc-formula-expressions, check:doc-security-posture (both @objectstack/lint), check:skill-examples (@objectstack/client-react `dist` .d.ts), check:docs-transcript-drift. Built those closures under the lock and re-ran: all four exit 0. ⛔ None was reported as a red and ⛔ none as a finding. check:api-surface and check:generated likewise refused twice with their own 'the package is not built' / 'dist describes DIFFERENT sources' text after src moved under dist; rebuilt and re-ran, and the final reading is '@objectstack/spec public API surface + factory signatures unchanged ✓' and 'All 15 generated artifacts are up to date' — so the comment-only rework moved no public surface, and the one added export recorded earlier still stands alone." }, "carrier_state": { "labels": "OFF both carriers, as the rule requires of a returned verdict — #17344 now reads 'domain:spec, pm:dispatched, priority:p2' and PR #17616 reads 'documentation, protocol:ui, size/l, tests, tooling'. ⛔ I did not re-hang anything.", "pair_predicate": "`node scripts/pm/check-clause2-carriers.mjs --pair 17616` exits 4 on C3, exactly as forecast: 'the gate WAS bound and cleared (last removal 2026-09-11T04:53:03Z), but the PR's head has MOVED since… The review that cleared this gate judged a different tree'. It states in its own text that the re-hang is a seat's act, not this script's, and ⛔ not mine.", "pr_state": "draft: true, state: open, auto_merge: null, head 47599ccc66. ⛔ Not flipped ready, ⛔ not enqueued, ⛔ no auto-merge. Body opens `Part of #17344`, no closing keyword (check-closing-keyword-parity exit 0; CI's 'Part-of PR must not also close its card' green).", "claim_and_assignee": "⛔ No second `Claim:`, ⛔ assignee never written." }, "ci": { "read_at": "once, after the final push — ⛔ no polling, no waiting", "head_sha": "47599ccc66d6a19a3dfe39f1d57056ac6b1093c0", "newest_run_per_check_NAME": { "distinct_names": 34, "failed": [], "in_progress": [], "skipped": ["Console Pin Gate", "Packed-tarball smoke (opt-in)"], "success_count": 32 }, "combined_commit_status": "pending — 1 context, Vercel=pending. Recorded as it read; ⛔ not waited on. The check-runs API above is the separate and complete reading: 0 failures across 34 names.", "note": "'Check Changeset' green, so the `Clause-②: yes` line is still legible after the body edit." }, "open_questions": [ { "question": "RESOLVED from last round, recorded so the thread closes: the seat amended its claim comment, and the stale-declaration read is gone. Measured now — `claimedBranches(5628909337)` returns ['claude/issue-17344-stageorder-adr-0049-gate'] where it returned [] before, `governingClaim` resolves to that comment, and `cardDeclaration` reads {state: 'declared', value: 'yes'}. The C5 row that fired last round is gone; the exit 4 now is C3, which is the head-moved state and is the seat's to clear. ⛔ Nothing owed from me.", "options": [], "recommendation": "No action. The durable half is still #17149's (a claim whose branch is written inline parses to zero branches and silently stops governing)." }, { "question": "Attribution footer form varies by ACTION on the same channel, which is a new cell of the matrix rather than a repeat of last round's. Measured on raw REST: CREATE appended the SESSION-URL form `_Generated by [Claude Code](https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH)_`; the PATCH of the same body appended the BARE form `_Generated by [Claude Code](https://claude.ai/code)_`. Exactly one footer both times, no doubling, and I hand-wrote none. Does the seat want the durable session attribution preserved across body edits?", "options": [ "A. Leave it — the session id is also in the body PROSE (a closing sentence), which is where AGENTS.md says durable attribution lives, so a PATCH that swaps the footer form loses nothing", "B. Hand-write the session-URL footer on PATCH — but that is untested on this cell and risks two footers" ], "recommendation": "A. The prose sentence survives every edit, and platform-readings forbids generalising one cell of the channel-by-action matrix to another anyway — which this measurement just demonstrated in the direction of caution." } ], "out_of_scope_findings": [ "carried in the PR body, filed by the seat as objectui#9111: objectui's client-side `DashboardWidgetSchema` is a `.shape` mirror and will not carry this check until it imports and chains `checkDashboardWidgetStageOrder`. ⛔ Not mine to fix. One measurement to hand whoever takes it, which the card may not have: that mirror drops `type` from the spread and redeclares it `DashboardWidgetTypeSchema.optional()` with NO default, so a typeless widget arrives at a re-attached check as `undefined` rather than `metric` — this round made the exported check default `type` itself for exactly that caller, so re-attaching is now sufficient and no objectui-side default is needed.", "filed as #17618 last round, still open and unchanged by this rework: `check-clause2-carriers` T1 reads a function parameter annotated `ctx: z.RefinementCtx` as a new authorable key, so every PR adding a cross-field refusal raises a widening tell for the refusal itself.", "noted, not filed: the review's own nit at the refusal message — 'on every other type the key parses' in the present tense while the author is reading a refusal of exactly that. ⛔ Deliberately not folded in: it would move a published message string on a change the review graded prose-only, the seat scoped it as fold-in-only-if-already-editing-that-line, and I was not otherwise editing it. Carrier: the seat, which raised it.", "noted, not filed: the review recorded that a `sortBy` naming nothing in `dimensions`/`values` is silently dropped at `DatasetWidget.tsx:448` and is caught at author time by the lint rule `widget-sortby-unselected` rather than by parse — so the redirect this refusal issues is true but lands on a key with its own author-time caveat one layer over. Carrier: #14148, closed, which is where that rule landed; recording it here rather than re-filing.", "noted, not filed: the review measured my 'zero authored widgets carry the key' census figure as non-reproducible (it reads 29 at base and 91 at head over tracked files, against my 59) — probably because my `git grep` ran over a tree carrying built `dist/`. The CONCLUSION is independently a reading and the review re-derived it: 6 authored dashboard files under `examples/` (lit control), none carrying the key. The figure is corrected here rather than left standing in the changeset, which does not quote it. Carrier: none needed — the claim it supported holds." ] }
Generated by Claude Code
Closing — all three findings discharged, finding 1 verified by CONTENT on
maindomain:specexecution seat,session_01MkQhmuuJAVDjmeWNixwDDH, 2026-09-11T07:50Z. PR #17616 usedPart of, ⛔ notFixes, so GitHub did not close this card — the seat decides it, and here is the reading it decided on.finding disposition verified 1 — stageOrderdeclared for every chart typePR #17616, merged 2026-09-11T07:41:09Z by content on main, below2 PR #17474, merged 2026-09-10T17:21:53Z merge state read this turn 3 objectui's, fenced — carrier objectui#9111 ⛔ not this repo's to close Finding 1 verified by state on
origin/main, not by the merge flag⭐ Whitespace flattened before matching, because a phrase that wraps a line break reads 0 on a contiguous
grep— a mistyped anchor, ⛔ not an absence. That exact trap bit me on a landed docs page earlier today.1 "objectui's CLIENT-SIDE authoring door" ← must-fix prose, present 1 "Five shapes" ← must-fix prose, present 2 "?? WIDGET_TYPE_DEFAULT" ← the reviewed coalesce, present 5 "checkDashboardWidgetStageOrder" ← LIT CONTROL, certainly present 0 "Three shapes, named so the gate" ← stale text, gone 0 "re-attaches the rule rather than a copy" ← the FALSE mechanism, goneBoth zeros sit beside a control that fires at 5, so they are readings rather than a grep that failed to run.
What the review actually bought
This card took two reviews and two reworks, and the thing that justified the second was not prose. The declared increment was taken, and taking it turned the catalogue's parity leg red — the exported check refused strictly less than the door it was exported from, because the catalogue calls it with a raw fixture while the door applies
.default('metric')first. The round repaired that at the producer with a?? WIDGET_TYPE_DEFAULTcoalesce.⚠️ A repair that turns a red test green by widening what an export accepts would have been a real defect, so the seat pointed the re-review at exactly that. Measured: 176/176 corpus rows identical at the spec door old-head vs new-head — with a control showing 30 rows differing against the merge base, so the instrument could have read otherwise — and 0 widening rows / 24 narrowing rows on the raw call a re-attaching mirror would run. It only ever refuses more.Verdict adopted verbatim at #17616 (comment), tier-verified before adoption (85/85
claude-fable-5-1, lit control 70 assistant turns). ⛔ Self-report is not a reading.Cards left open by the review, ⛔ not blockers and ⛔ not closed here
- The zod-4 derivation set is wider than the three the prose names:
.merge()always throws;.extend({<existing key>})throws (.safeExtend()is the safe spelling);.required()and.keyof()silently drop the refusal like.shape. All latent by census. - objectui renders a typeless widget as
'', notmetric(DatasetWidget.tsx:422,DashboardWithConfig.tsx:109), so the export's "resolves tometric" sentence is spec vocabulary there. Carrier: objectui#9111. - [finding]
check-clause2-carriersT1 reads a function PARAMETER annotatedctx: z.RefinementCtxas a new authorable key — so every PR that adds an object-level refusal raises a widening tell for the refusal itself #17618 — the clause-② T1 widening tell — is unchanged by this work and now carries a second, worse instance from feat(spec)!: retire thescheduledcache-warmup strategy — the cron it selected left in this same major (ADR-0049) #17638.
⛔
pm:*and the assignee are stripped in the same write as the close.domain:specandpriority:p2stay:归属 is not state.
Generated by Claude Code
- The zod-4 derivation set is wider than the three the prose names:
Summary
DashboardWidgetOptionsSchema.stageOrderis an ungated member of the generic widgetoptionsobject. Three things are wrong with it, all silent — nothing warns, nothing refuses, the widget renders and the authored order is simply not there.funnelbranch of the renderer reads it. On any other chart type the option is accepted, forwarded, and never consulted.pyramidas a primary case. There is nopyramidchart type — it is absent from the spec's chart-type enum and from the console bundle.funnel, the order is matched on rendered category values, and analytics rows carry unlocalized dimension labels — so an authoredstageOrderis silently discarded in every locale that is noten. The dashboard plugin already contains a hedge for exactly this mismatch, and the hedge does not save it.Found while fixing an application-side widget in
objectstack-ai/hotclm(its issue #48, PR #57). Everything below is read out of the published 17.4.0 packages —@objectstack/spec@17.4.0and@objectstack/console@17.4.0— plus browser measurements from that app.1. Honoured by the funnel branch only
@objectstack/console@17.4.0,dist/assets/plugin-charts-*.js. The chart component destructures the prop ascategoryOrder: c, and the only placecis read is inside the funnel guard:his the resolved chart type. Every other branch (bar/column/horizontal-bar,line,area,pie,donut,treemap,sankey,radar, …) ignores the prop; the other textual matches forcin those branches are unrelated locals (let c = {...n}in the pie branch,c = o.map(…)in sankey).Meanwhile
@objectstack/spec@17.4.0declares it with no chart-type gate at all, in the same generic object assortBy/sortOrder/limit:Authoring a
horizontal-barwidget carryingstageOrderpassesvalidateand boots. Measured in the browser: the widget rendered alphabetically by display label (Active · Approved · Draft · In Approval · In Review · Signing · Submitted) with the lifecycle order authored and ignored.This is ADR-0049's shape — enforce or remove. Either the authoring path refuses
stageOrderon a chart type that cannot honour it, or ordered marks honour it.2. The documentation names a chart type that does not exist
packages/spec/src/ui/dashboard.zod.ts, the JSDoc and the.describe()string that reaches the authoring UI and the generated reference:pyramidappears zero times in@objectstack/spec@17.4.0's emitted types and zero times in the console charts bundle. The widgettypeenum at 17.4.0 is:column · metric · kpi · line · bar · horizontal-bar · area · pie · donut · funnel · scatter · treemap · sankey · combo · gauge · solid-gauge · bullet · radar · table · pivotSo the one sentence an author reads before using this option names two chart types, of which one is the only one that works and the other does not exist. The plural framing ("ordered-sequence charts", "stages above all") is what makes an author reasonably conclude it applies to ordered marks generally — which is finding 1.
3. On a funnel, the order is matched on labels, and analytics labels are not localized
dist/assets/plugin-dashboard-*.jsbuilds the prop like this:The plugin emits both the authored stored value and its resolved display label into
categoryOrder, because the chart sorts onString(row[xAxisKey])and it cannot know which spelling the row will carry. That hedge is itself the evidence that the contract documented as "stored values" is matched label-shaped.The hedge fails as soon as the two label sources disagree. Measured in the hotclm app, same widget, same build, two consoles:
stageOrderenDraft · Submitted · In Review · In Approval · Approved · Signing · Activezh-CNActive · Approved · Draft · In Approval · In Review · Signing · SubmittedThe mechanism:
dimensionLabelsresolves through the app's i18n bundle, so onzh-CNthe order array carries[stored_value, 中文标签]pairs, while the rows arriving fromPOST /api/v1/analytics/dataset/querycarry the English labels (the unlocalized-analytics-label behaviour of #5076, closednot_planned). Neither spelling in the order map matches the row, every row falls to the2**53-1sentinel, and the sort degenerates to the query's incoming row order.The consequence matters beyond cosmetics, and it is why I am reporting it rather than leaving it under #5076: a funnel is a mark whose meaning is its order. A funnel rendered in a different order in
zh-CNthan inen, from one authored widget, is not an untranslated string — it is a chart that says something different to a Chinese-reading user than to an English-reading one, with nothing anywhere to indicate it. #5076 was closed as not planned on the reading that analytics labels showing English is a display shortfall; this is the same root cause silently discarding authored metadata. Related open surface: #17307 (two other dashboard i18n surfaces declared translatable and not resolved).What the app did in the meantime
Nothing that patches the platform, per its own policy. The widget stopped being a funnel for unrelated reasons (its distribution does not decline, so the mark was making a false claim), and
stageOrderwas removed rather than left inert — the app now orders by the measure,sortBy: 'contract_count', which lowers toorder: { contract_count: 'desc' }on the dataset query and cannot be reordered by any label in any locale. That is a workaround for one widget, not a fix, and it is only available to widgets for which a measure ordering is meaningful; a funnel by definition needs lifecycle order.Suggested remedies, in the order I would take them
funnelonly, and say plainly that no other chart type reads it.stageOrderon a widget whosetypecannot honour it should be a validate refusal or at minimum a lint warning, not silence. Same treatmentoptions.sortBygot in A dashboard widget's OWNfilterkeys andoptions.sortByare not resolved at author time — validate/build exit 0, widget renders empty #14148.categoryIdoff the payload for click handling —se(t?.payload)), so the order map has a locale-independent key available to it. Matching on that would make finding 3 disappear without waiting on the analytics label-localization question.categoryOrderon the ordered marks where an author would reasonably expect it (bar/column/horizontal-bar/line/area), or keep it funnel-only and let step 2 enforce that.Steps 1–3 are independent of each other and each removes a silent failure on its own.
Prior art / related
dateGranularity,sortBy/sortOrder, and funnel stage order", closed by feat(analytics): honour widget dateGranularity, sortBy/sortOrder, and limit in dataset queries (#3588) #3652.sortBy/sortOrder/dateGranularityare genuinely fixed (verified:sortByreaches the query asorder: { … }). Its third item — funnel stage order — is whatstageOrderanswers, and this issue is the part of that answer that did not land: the option arrived generically declared, funnel-only implemented, and label-matched.filterkeys andoptions.sortByare not resolved at author time — validate/build exit 0, widget renders empty #14148 — a widget's ownfilterkeys andoptions.sortBynot resolved at author time. Same enforcement gap, one option over.not_planned) — analytics surfaces never apply the i18n bundle. Root cause of finding 3; this issue is the authored-metadata consequence of it.GlobalFilterSchema.objectis never read, andChartAxisSchema.titlebypasses a locale resolver in its own bundle — two dashboard i18n surfaces declared translatable and not resolved #17307 — two other dashboard i18n surfaces declared translatable and not resolved.Environment
@objectstack/spec@17.4.0,@objectstack/console@17.4.0(published tarballs, read directly)objectstack-ai/hotclm@2807b3b,pnpm demo, Chromium,enandzh-CNconsoles, four passes, screenshottedlegal_dashboard→stage_funnel, datasetcontract_metrics, dimensionclm_contract.status(a picklist with lifecycle option order declared on the object)