Skip to content

fix(spec): a joined report refuses a top-level dataset / rows / columns / values, pointing each onto blocks[] - #20160

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-19856-joined-report-selection-keys
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-19856-joined-report-selection-keys

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #19856

Clause-②: no (narrowing)

A type: 'joined' report selects nothing itself: each block binds its own dataset and picks its own rows / columns / values. ReportSchema's refinement already refused a container-level order for that reason, but the four selection keys beside it parsed green, and the renderer then dropped them without a word. This PR refuses each of them at its own path, with a pointer onto blocks[], the way order has always been refused.

Measured first

Parse probe at origin/main 8d1f7ab7 (re-run, per the card's request, against the spec dist built at that commit). Base is a valid joined report with one dataset-bound block; each row adds one key.

added key at 8d1f7ab7 at this PR's head
dataset: 'sales' accepted refused, custom at ['dataset']
rows: ['stage'] accepted refused, custom at ['rows']
columns: ['region'] accepted refused, custom at ['columns']
values: ['revenue'] accepted refused, custom at ['values']
all four together accepted four issues, one per key
order: [{ by: 'revenue' }] refused at ['order'] unchanged, byte-identical message
runtimeFilter: {…} accepted accepted (the joined branch reads it)
rows: [], columns: [], values: [] accepted accepted (see decision 2)
chart: {…} accepted accepted (see decision 3 and the findings)
measures / fields / dimensions / dataSet / objectName unrecognized_keys naming the rename target unchanged; the rename target is now refused on a joined report

The renderer, at this repo's .objectui-sha pin f8a9d0fb0596f4521076628e2bbfe27e6ce67d52 (the card's reading was at objectui's working HEAD 0cf2d66, not the pin; this is the re-read). In packages/plugin-report/src/DatasetReportRenderer.tsx, the component resolves outerFilter from report.runtimeFilter (:1455) and drillSink from report.drilldown (:1457). The joined branch opens at :1462 and returns a stack of per-block tables. It reads only report.blocks and report.name, plus those two. The top-level reads of columns / order / dataset / rows / values / chart start at :1529, after that return. The card's reading holds at the pin.

Producer census (card Zone 2, item 2). No real producer writes any of the four keys on a joined container:

  • This repo at 8d1f7ab7: every joined-report site outside generated references. These are the showcase showcase_task_overview report, the content/docs/ui/reports.mdx example, 3 fixtures in packages/lint/src/validate-chart-bindings.test.ts, 2 in packages/platform-objects/.../report-form-echo-decisions.test.ts, the spec's own report.test.ts, and scripts/analytics-reconcile/reconcile.ts, which reads blocks only. None carries a container dataset / rows / columns / values.
  • objectui at the pin: 13 joined-report occurrences in fixtures and docs. Plugin-report tests, the app-shell preview/inspector tests, @object-ui/types' spec-report test, the plugin-report README and docs. None carries them. The objectui docs already state "there is no container-level dataset to inherit".
  • cloud at 48d7066: git grep for a joined report type exits 1 (zero matches). As a control, the same grep for type: 'summary' does hit.
  • One producer path does exist, found by reading code at the pin: Studio's ReportDefaultInspector. Its type picker commits onPatch({ type: v }) and nothing else. It then hides the top-level binding for joined reports (datasetBound = reportType !== 'joined', :328), and SchemaForm's hiddenFields hides all five keys. So a report that was bound first and then switched to joined keeps its dataset / values / rows invisibly. After this PR, saving that draft is refused with a located issue at dataset. The Properties tab cannot show that key, but the JSON source tab (ResourceEditPage.tsx:1152) can delete it. This is reported as a finding, not fixed: objectui is read-only on this card.

What changed

packages/spec/src/ui/report.zod.ts:

  • The joined arm of ReportSchema's superRefine now loops over a module-private JOINED_CONTAINER_SELECTION_KEYS = ['dataset', 'rows', 'columns', 'values']. It is not exported, so the public surface is unchanged and check:api-surface is green. It adds one custom issue per offending key at path: [key], with this exact text:

    a `joined` report selects per block — move `KEY` onto `blocks[]`, or delete it; on the container it selects nothing.
    

    (KEY stands for the key's own name.) The existing order refusal follows the loop, unchanged byte for byte: a `joined` report orders per block — move `order` onto `blocks[]`.

  • TSDoc on the four keys was changed. No .describe() string changed, so the generated JSON Schema and reference pages are byte-identical, and check:generated reports all 15 artifacts current.

    • dataset: "For a joined report, the data lives on blocks: a top-level dataset on one is refused — each block binds its own."
    • rows: "A non-empty list on a joined report is refused — see blocks[].rows."
    • columns: "Ignored for the other non-joined types; a non-empty list on a joined report is refused — see blocks[].columns."
    • values: "A non-empty list on a joined report is refused — see blocks[].values."

ADR-0087: a new semantic entry, packages/spec/src/migrations/entries/semantic/18.ui-report-joined-container-selection-refused.ts. registry.ts was regenerated with gen:migration-registry. spec-changes.json and the upgrade guide were regenerated too and came out byte-identical, because major-18 entries do not project until the protocol reaches 18 (the saved-report-stack-retired entry, also major 18, is absent from both for the same reason).

Changeset: .changeset/19856-joined-report-container-selection-refused.md. It is @objectstack/spec: minor, carries a **BREAKING** banner with the FROM → TO mapping and the fix, and declares its ADR-0087 disposition as registered ui-report-joined-container-selection-refused.

Decisions I settled, on the four axes

1. Bare refusal, not an ADR-0087 D2 conversion that strips the keys.

  • Business need (measured): no producer in any of the three repos writes these keys. The one producer path (Studio's type switch) hits the save door, and a conversion does not run there: saveMetaItem validates against the current schema, and conversions run only on authored source and on stored rows at rehydration. A conversion would buy nothing for the one real path.
  • Long-term: one strict contract, with no permanent conversion entry for a key nothing reads.
  • Anti-AI-error: a loud refusal with a prescription. Stripping the keys in normalizeStackInput would hide exactly the mistake the card is about.
  • Startup: the transition stays tight; there is no named external-user evidence that would justify a staged path.

2. The threshold is order's own: a present dataset, and a non-empty list. An empty rows / columns / values selects nothing, which is exactly what a joined container selects, and it carries no dropped intent. Refusing it would widen the narrowing past the silent-drop defect, and it would diverge from the r.order?.length threshold one line below in the same arm. This choice is pinned by a test, so it reads as deliberate.

3. chart is not refused here, although it is inert on a joined container too. The obvious prescription, "move chart onto blocks[]", would send the author to blocks[].chart. At the pin, the joined branch never reads that key either: git grep -F "block.chart" over objectui packages/ at the pin exits 1, while the control block.runtimeFilter exits 0. So the fix shape for chart is not settled, and it does not meet the bar for an in-place fix. It is reported below as one family covering the container and block chart.

Alias routing (card Zone 2, item 3): the alias tables stay as they are. measures → values and dataSet → dataset still rename first, and the renamed key on a joined container then meets this refusal. That is the same two-step answer sort → order already gives. Both spellings are pinned end to end: alias, then rename, then refusal, then the key moved onto a block parses.

Tests: every refusal asserts code + path + prescription text

New describe('Joined reports refuse top-level selection keys') in packages/spec/src/ui/report.test.ts, 13 tests:

  • For each of the 4 keys: exactly one issue, { code: 'custom', path: [key], message: PRESCRIPTION(key) }.
  • All four at once: four issues in declaration order, followed by the order issue with its message asserted byte-identical.
  • defineReport throws the prescription (the authoring door).
  • getMetadataTypeSchema('report') refuses with the same envelope (the metadata save door's schema).
  • Taking the advice parses: all four keys on a (matrix) block.
  • The empty-list threshold: rows: [], columns: [], values: [], order: [] on a joined container parses.
  • Preservation: a container runtimeFilter + drilldown: false parses and round-trips, and a matrix report with the same four keys parses.
  • Alias legs, for measures → values and dataSet → dataset: the alias gets unrecognized_keys naming the target; the target on the container is refused with the envelope; the target on a block parses.

Ablation (one-off, no permanent file). Anchor-verified mutation via scripts/ablation-replace.mjs, with a trap restore. The anchor was if (value === undefined || … ) continue; and it was replaced with an unconditional continue;. The anchor went from 1 hit to 0; the blob went f13efbaf → 743ff83c. Predicted direction: red. Observed: report.test.ts 9 failed | 54 passed. The 9 failures are exactly the refusal tests: 4 per-key, all-four, defineReport, save door, and 2 alias legs. The 4 preservation pins stayed green, as they should. The subject resolves through the relative import ./report.zod to src/, so no dist was involved. Restore: blob equals HEAD f13efbaf, and git diff HEAD is empty.

Verification (all at head 87c19461)

  • packages/spec full suite (vitest run --project local --maxWorkers=2): 540 files, 15810 passed | 2 todo.
  • pnpm --filter @objectstack/spec run typecheck (tsc --noEmit, scripts, and the test layer): exit 0.
  • pnpm --filter @objectstack/spec build && … check:generated: all 15 generated artifacts current.
  • Consumer suites, the direction that tightening affects (tests that parse a joined ReportSchema, after rebuilding every packages/* dist, 71 packages):
    • packages/lint src/validate-chart-bindings.test.ts: 45 passed.
    • packages/platform-objects src/apps/translations/report-form-echo-decisions.test.ts: 35 passed.
    • @objectstack/example-showcase, whole suite: 29 files, 384 passed (vitest run --maxWorkers=2).
  • Pin sweep: git grep over the repo for the joined-arm message texts and for joined fixtures carrying container selection keys. No pin asserted the old acceptance, so there was nothing to flip. The only same-semantic pin, a joined report orders per block (report.test.ts), stays green and is now asserted byte-exact in the all-keys test.
  • Derived gates: node scripts/pm/dispatch-gates.mjs --commands on the actual 5 changed paths gave 88 commands. Every one was run with its exit code recorded before any pipe, and --ran reconciled 88 run, 0 NOT-MEASURED, 0 UNRUN. Five first exited 3 (prerequisite not met). They were re-run green after turbo run build --filter='./packages/*' --filter='./packages/*/*', and after a --depth=1 fetch of the teardown self-test's pinned fixture commit.
  • Declared to CI: the path-scheduled jobs (Test Core shards, Dogfood, Temporal Conformance, Build Core) and the whole-workspace type-check lanes.

Findings reported for the seat (not filed here)

  1. Joined-report charts never render, at the container or per block. Class (b), from the declared JoinedReportBlockSchema.chart ("Optional inline chart configuration") and the liveness/report.json blocks note. Reach: ReportSchema parse accepts both (measured above). At the pin, the joined branch renders tables only, and block.chart has zero reads in objectui packages/ at the pin and at HEAD 5c61e52. packages/lint still validates block charts against the block dataset. Seam: spec:JoinedReportBlockSchema.chart and spec:ReportSchema.chart (joined) → renderer:DatasetReportRenderer (joined branch).
  2. Studio's report type switch leaves the hidden top-level binding on a joined draft. Class (c), named producer: objectui ReportDefaultInspector.tsx at the pin, where the type picker patches only type. After this PR, that draft is refused at save at dataset. The refusal is located and can be fixed through the JSON source tab, but not from the Properties tab. The objectui-side fix is to clear or show the container selection keys when the type becomes joined.

Acceptance notes (observations, not filed)

  • ReportSchema.blocks' TSDoc still describes the pre-cutover shape: "Each block declares its own object, columns, groupings and filter. The container-level filter is ANDed…". The key is runtimeFilter, and object/groupings are gone. This PR does not touch it. Carrier: the next PR to edit report.zod.ts.
  • content/docs/ui/reports.mdx names only order among a joined container's refused keys. It is still true, just not complete. Carrier: none (outside this claim's file surface).
  • A non-joined report carrying blocks is accepted and ignored. That is declared ("Renderers must ignore blocks when type !== 'joined'"), so this is an observation only.

Generated by Claude Code

… / values

A joined container selects nothing itself; the renderer's joined branch
reads blocks and returns before any top-level selection key, so these were
accepted and silently dropped. Each is now refused at its own path with a
pointer onto blocks[], beside the unchanged order refusal.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
…R-0087 ledger, plus its changeset

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation protocol:ui tests tooling labels Sep 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 3 documentable anchor(s).

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

  • content/docs/data-modeling/analytics.mdx (via ReportSchema (symbol, a top-level const))
What this run could not see
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 207 client-bound route-ledger rows — the other 153 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 153: 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; 98 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 136 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 49144fccc873c48babf3c45e56982b6c18169487 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 234d4db624fd235a1501281f7e12006a47ca7bca — the merge of head 87c194618ca5a40807e3b1a0b8400bda0f1f037f into base 49144fccc873c48babf3c45e56982b6c18169487, 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 234d4db624fd235a1501281f7e12006a47ca7bca && git checkout 234d4db624fd235a1501281f7e12006a47ca7bca
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 49144fccc873c48babf3c45e56982b6c18169487 87c194618ca5a40807e3b1a0b8400bda0f1f037f && git checkout -B drift-repro 49144fccc873c48babf3c45e56982b6c18169487 && git merge --no-ff 87c194618ca5a40807e3b1a0b8400bda0f1f037f

node scripts/docs-audit/affected-docs.mjs --json 49144fccc873c48babf3c45e56982b6c18169487

⚠️ 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 49144fccc873c48babf3c45e56982b6c18169487 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 87c194618ca5a40807e3b1a0b8400bda0f1f037f

① Derived judgments

Method: card #19856 body + 4 comments read; PR body + diff (5 files vs merge-base 8d1f7ab7, +306/−5) read in full; report.zod.ts, strict-object.ts, metadata-type-schemas.ts, metadata-diagnostics.ts, stack.zod.ts, report.form.ts, liveness/report.json read at the head; objectui DatasetReportRenderer.tsx and ReportDefaultInspector.tsx read at the .objectui-sha pin f8a9d0fb; a 22-case safeParse probe and the single test file run on a git archive of the head under my scratch dir (removed after; probe script + output kept at review/probe-19856.ts / review/probe-output.txt).

  • Refuses a present top-level dataset on a type: 'joined' report — RIGHT. report.zod.ts:451-460 loops JOINED_CONTAINER_SELECTION_KEYS inside the existing joined arm and adds one custom issue at path: [key]. Probe P2: {...JOINED, dataset: 'tasks'} → success=false, exactly one issue, code custom, path dataset. The renderer at the pin never reads it: joined branch opens at DatasetReportRenderer.tsx:1462 and returns at :1526; the first top-level report.dataset read is :1537. Matches the file's own contract (:344-345 docblock, :443-450 refinement comment) and the card's landing site.
  • Refuses a NON-EMPTY top-level rows / columns / values on a joined report — RIGHT. Probe P3: three issues, one per key, each at its own path with the prescription. Probe P4 (all four + order): five issues in declaration order; the order message is byte-identical to main (a \joined` report orders per block — move `order` onto `blocks[]`., :464`). Ablation claim in the PR body is plausible and the test file pins each refusal by code + path + message; I re-ran the file: 63 passed (13 new).
  • Empty rows: [] / columns: [] / values: [] still parse on a joined container — RIGHT, and deliberate. Probe P5 → success=true. The threshold is the sibling order refusal's own (r.order?.length, :461); an empty list carries no dropped intent, and refusing it would have broken any GUI draft that pre-seeds empty arrays. The card asked for the four keys to be refused and did not rule on emptiness; the choice is pinned by a test so it reads as a decision, not an omission.
  • runtimeFilter and drilldown on a joined container still parse — RIGHT. Probe P6 → success=true, values round-trip. These are exactly the two container keys the joined branch reads (DatasetReportRenderer.tsx:1455 mergeFilters(report.runtimeFilter, …), :1457 report.drilldown).
  • Non-joined reports untouched — RIGHT. Probe P12 (no type, defaults to tabular, with dataset/values/blocks) → success=true; P13 (matrix with all four keys) → success=true. The loop sits inside if (r.type === 'joined') and the non-joined arm still runs checkReportOrder (:468-470).
  • Alias routes measures→values, dataSet→dataset, groupings→rows reach the refusal — RIGHT, as a two-step. Probe P8/P9/P10: the alias key is refused as unrecognized_keys naming the rename target (Zod 4 aborts the payload on unrecognized_keys, so the superRefine does not run in that same parse — P8 shows dataset: 'tasks' beside measures is NOT reported until measures is fixed). After the rename the target meets the joined refusal (P2/P3). This is the same two-step sort→order already gives on a joined report (:308-311 comment), and the alias tables are unchanged. Not a defect; noted as a UX limit in ③.
  • chart on a joined container is still accepted — acceptable, out of the card's four keys. Probe P7 → success=true. The PR declines to refuse it because the natural prescription blocks[].chart is itself never rendered at the pin (git grep -F block.chart over objectui packages/ at f8a9d0fb → 0 hits; I re-ran it). Reported by the PR as finding 1 for a separate card; correct handling.
  • Every door is the same schema — RIGHT. getMetadataTypeSchema('report') === ReportSchema (probe P16 → true; metadata-type-schemas.ts:115), so the save door (protocol.ts:6625) and the read-side _diagnostics (metadata-diagnostics.ts:60,100) carry the same issue; stack.zod.ts:381 reports: z.array(ReportSchema) covers defineStack / os validate / os build; defineReport and Report.create throw the prescription (P17/P18 → true). The migration entry's WHICH DOOR paragraph is accurate.
  • Refusal text is a correct prescription. a \joined` report selects per block — move `KEY` onto `blocks[]`, or delete it; on the container it selects nothing.— both remedies are true (blocks declare all four keys,report.zod.ts:231-237; deleting changes nothing rendered), and "taking the advice parses" is pinned (the matrix-block test). Probe P11 (a plain report flipped to joinedwith no blocks) yieldsblocksneeded +dataset+values` refusals together — informative, not contradictory.
  • Public surface unchanged — RIGHT. JOINED_CONTAINER_SELECTION_KEYS is module-private (:252, not exported; ui/index.ts untouched). Only TSDoc on the four keys changed; no .describe() or .meta() string changed, so the JSON Schema / reference artefacts are byte-identical. Check-runs: Lint & Repo Gates success, Type Check · source gates success (lint.yml carries check:api-surface at :6510 and check:generated --reconcile-only at :5453), Spec property liveness success. liveness/report.json needs no edit: the four props stay live (they are every non-joined report's selection) and their notes already say "for non-joined reports".
  • Producer census — verified, zero container keys anywhere. My own scan (same-indent sibling keys around every type: 'joined'): objectstack head 15 sites / 0 flagged (showcase 1, docs 1, packages/lint 3 fixtures, packages/platform-objects 2 fixtures, spec tests), objectui at the pin 18 lines / 0 flagged (the PR's "13 occurrences" counts fixtures + docs; CHANGELOG hits make up the difference), cloud 48d7066 → git grep exit 1 with the summary control exit 0.
  • Nothing refused that should parse; nothing the card names still accepted. Coverage of the card's four keys is complete; the order message is unchanged; no non-joined path is touched.

CI at review time (head check-runs, fetched twice): 25 success, 6 skipped, 0 failure; 6 still in_progress — Test Core (1,3,4,5,6/6) and Type Check · workspace. Test Core (2/6), Check Changeset, Lint & Repo Gates, Type Check · source gates, Type Check · consumer gates, Spec property liveness concluded success. The seat should confirm the remaining shards before enqueue; nothing in the diff can redden them that the single-file run and the source-gate lane did not already exercise.

② Semver level

  • Level: @objectstack/spec: minor — RIGHT under the launch-window convention. The change narrows ReportSchema's accept set (breaking). scripts/check-changeset-no-major.mjs refuses major before GA and its header names the BREAKING banner + ADR-0087 disposition as the carriers; .github/workflows/pr-automation.yml:753-760 (WHICH LEVEL) confirms a breaking change ships as minor, and fix( may raise a bump but not lower it. Spec is at 17.4.0; Check Changeset (job changeset-check, which runs both check-adr-0087-registration.mjs --base MERGE_BASE at :940 and check-changeset-no-major.mjs --base MERGE_BASE at :1075) concluded success.
  • Declaration consistent and truthful. Changeset body carries (a) Clause-②: no (narrowing), (b) a **BREAKING** banner, (c) a FROM → TO block with the exact old acceptance and the new issue envelope, (d) the one-line fix, (e) exactly one marker adr-0087: registered ui-report-joined-container-selection-refused. readClause2Line (the single reader, scripts/pm/clause2-line.mjs) parses both the changeset and the PR body as {kind: declared, value: no, arm: narrowing} — I ran it. The claim comment carried the bare no; the PR body's no (narrowing) is the AGENTS.md Post-Task step 3 spelling for a breaking narrowing and is the arm check-adr-0087-registration.mjs signal (4) reads; the dev declared this deviation in the os-dev report. Consistent.
  • ADR-0087 registration — consistent. New entry packages/spec/src/migrations/entries/semantic/18.ui-report-joined-container-selection-refused.ts exports entry: SemanticMigration with the id the marker names; registry.ts step18 gains the identical object (48 lines) at its sorted position (ui-reference-rail… → ui-report-joined… → view-filter-rule…). I checked the whole step18 list: 170 ids, sorted order equals file order, and the entries/semantic/18.* directory holds 170 files — the generator's derived order is respected. Major 18 does not project into spec-changes.json / the upgrade guide yet (spec is 17.x), so those artefacts are correctly untouched.
  • Entry content matches the change. surface names the four keys on a joined container with the NON-EMPTY threshold; replacement gives both remedies and says order's refusal is unchanged; reason cites the pin sha and the renderer lines (:1462, :1529) — I verified both at f8a9d0fb — plus the alias tables (all listed aliases exist in report.zod.ts:289-301) and the Studio path (ReportDefaultInspector.tsx:328 datasetBound = reportType !== 'joined', :391 onPatch({ type: v }), :530 hiddenFields — all verified at the pin); acceptanceCriteria names the doors correctly and the census numbers match mine. One nit, not blocking: surface is prose rather than the dotted-path style types.ts:39 suggests, which matches the neighbouring step18 entries' practice.

③ Boundary flags

  • PR flag 1 — joined-report charts never render (container or blocks[].chart). Answered: out of the card's scope; verified at the pin (0 block.chart reads; container chart read at :1557 sits after the joined return). Needs its own card (class b, fix shape undecided: render block charts vs retire the key). Not blocking.
  • PR flag 2 — Studio's report type picker leaves a hidden container binding on a draft switched to joined; after this PR that save is refused at dataset. Answered: real, verified at the pin (ReportDefaultInspector.tsx:391 patches only type; :328/:530 hide the five keys). The refusal is located and recoverable through the JSON source tab (ResourceEditPage.tsx:1152 at the pin). objectui is read-only on this card, so the fix (clear or surface the container keys when the type becomes joined) needs an objectui card; the seat should file or route it before or with landing. Not blocking — the alternative (keep silently dropping) is the defect the card names.
  • Same hazard exists on this repo's own form, unflagged by the PR: packages/spec/src/ui/report.form.ts:37 hides the whole "Dataset binding" section with visibleWhen: "data.type != 'joined'" and :67 shows blocks only for joined — so any SchemaForm consumer of the spec form (not just objectui's inspector) hides stale dataset / rows / columns / values on a type switch. Nothing to change in this PR (a visibility rule produces no values), but the objectui card above should name this form as the shared cause so the fix clears the keys rather than only un-hiding them in one inspector.
  • Stored sys_metadata rows: the entry says a stored joined report carrying the keys is not rewritten, shows the issue in _diagnostics, and is refused on its next save. Verified: computeMetadataDiagnostics re-parses the served document with the same schema (metadata-diagnostics.ts:60,100); census finds zero such rows in any seed / example / fixture in the three repos, so no deployment shipped by this repo starts failing. Answered.
  • Alias UX limit (from ① P8): on a joined report the unknown-key hint sends an author to a key that is then refused, so the full answer is two parses. Same as the existing sort→order route; a per-key guidance entry cannot condition on type. Acceptable; a future improvement would be a joined-aware hint in the refinement itself. No escalation.
  • Docs drift: content/docs/ui/reports.mdx:105-108 still lists only order as the container-only refusal on a joined report — true but now incomplete. Outside the claim's file surface; the PR reports it with no carrier. Suggest the seat attach it to the next docs pass. Not blocking.
  • ReportSchema.blocks TSDoc (report.zod.ts:403-411) still describes the pre-cutover block shape (object, groupings, container filter). Pre-existing, noted by the PR, no carrier; not blocking.
  • Nothing found that the author should have flagged and did not, beyond the report.form.ts visibility note above. No producer in objectstack, objectui-at-pin or cloud writes the four keys on a joined container; analytics-reconcile reads blocks only; packages/lint validate-chart-bindings.ts:539-556 would resolve a container dataset on a joined report if one were present, but only to lint it, and none exists.

Implemented-by: claude/issue-19856-joined-report-selection-keys
Reviewed-by: session_01Rjy9MeetSfq34PKn81CRiN

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 27, 2026 03:44
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 27, 2026
Merged via the queue into main with commit 2bbebf5 Sep 27, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-19856-joined-report-selection-keys branch September 27, 2026 04:03
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Sep 28, 2026
…ding it hides (objectui#10746) (objectstack-ai#10762)

Fixes objectstack-ai#10746
Clause-②: no — a Studio inspector's patch on a type switch; no declared
surface moves

## What changed


`packages/app-shell/src/views/metadata-admin/inspectors/ReportDefaultInspector.tsx`:
the Report type picker's commit is now `commitType`. When the picked
type is `joined`, the patch that commits `type: 'joined'` also carries
`dataset`, `values`, `rows`, `columns`, `chart` and `order` as
`undefined`-valued keys — only those the draft actually holds
(`draft[key] !== undefined`). Any other type commits `{ type }` alone,
as before. The list lives in `JOINED_CONTAINER_CLEARED_KEYS` with the
reasoning beside it; `blocks`, `runtimeFilter` and `drilldown` are never
named.

`order` is a sixth key, beyond the card's five, under the role file's
bounded in-place exemption (same defect class, mechanical, same file,
same gate family, no other claim on the file): the spec's own
`reportForm` hides `order` in the same "Dataset binding" section
(`visibleWhen: "data.type != 'joined'"`), and the INSTALLED spec already
refuses a container `order` on a joined report ("a `joined` report
orders per block — move `order` onto `blocks[]`"), so the same
invisible-key save refusal exists today for `order`. Evidence in H3
below.

New pin file `ReportDefaultInspector.joinedClearsBinding-10746.test.tsx`
(8 tests); changeset `.changeset/10746-joined-report-clears-binding.md`
(`'@object-ui/app-shell': patch`).

## H1 — reproduction on `origin/main` (`1422a920ed`)

The pin file run against the untouched component: `Tests 4 failed | 4
passed (8)`. The defect pin quotes the only `onPatch` argument the type
picker commits today: `AssertionError: expected { type: 'joined' } to
strictly equal { type: 'joined', …(6) }` — the commit is `{ type:
'joined' }` and nothing else. The 4 pins green on base are the
boundary/control pins, which pin what must NOT change.

## H2 — the clear mechanism

`onPatch` is a shallow patch in all three hosts: `ResourceEditPage`
applies it as `handleDraftChange((d) => ({ ...d, ...patch }))`,
`ReportConfigPanel.handlePatch` as `{ ...draftRef.current, ...patch }`,
and `StudioDesignSurface.onPatch` as `setDraft((d) => ({ ...d, ...patch
}))`; each saves through `client.save`, which is `JSON.stringify`. None
offers a delete sentinel, so the one way to drop a key is an
`undefined`-valued key — the spelling this inspector's own `commitChart`
(`chart: next.type ? next : undefined`) and the siblings
(`ActionDefaultInspector`, `ObjectDefaultInspector`,
`DatasetDefaultInspector`'s "Clear all", pinned by its objectui#9372
suite) already use. Measured in the second pin: after the spread the key
is an OWN property holding `undefined` (`Object.hasOwn` true, value
`undefined` — not `null`, not an empty string), and
`JSON.parse(JSON.stringify(committed))` — the shape `client.save` puts
on the wire and the spec parses — has no such property. The spec's
refinement itself skips `undefined` (`if (value === undefined || …)
continue;` in the joined arm), so even the in-memory draft would not be
refused.

- Reach: The clear holds for the patch and for the save that follows it.
The metadata-admin editor rebuilds its draft as the served draft spread
over `layered.effective` (on load, after each save and after publish),
and `effective` is the published layer, so a report whose PUBLISHED
version was bound gets those keys back in the draft after the first
draft save until it is published, and a report already saved `joined`
with stale keys is not repaired on load. Both are the host's
draft-over-baseline merge, objectui#10765.

## H3 — the spec check

Installed `@objectstack/spec` is 17.4.0
(`packages/app-shell/node_modules/@objectstack/spec`). It PREDATES
objectstack-ai/objectstack#20160: `grep 'selects per block'` over its
`dist/` gives 0 hits; the control `grep 'orders per block'` gives 3.
One-off probe with the installed `ReportSchema.safeParse` (not
committed):
- joined + one block: success
- joined + stale `dataset` / `values` / `rows` / `columns` / `chart`:
**success** — 17.4.0 accepts them
- joined + stale `order`: refused, `custom` at `['order']`, "a `joined`
report orders per block — move `order` onto `blocks[]`."
- joined + `blocks: []`: refused at `['blocks']`
- joined + `runtimeFilter` + `drilldown: false`: success
- joined + `dataset: undefined` as an own key: success

So the pins' parse leg measures the `order` half with the installed spec
(before the fix: refused at `['order']`; after: parses), and for the
four selection keys the pins assert ABSENCE and cite the rule read at
objectstack `origin/main` `packages/spec/src/ui/report.zod.ts`:
`JOINED_CONTAINER_SELECTION_KEYS = ['dataset', 'rows', 'columns',
'values']`, one `custom` issue per present key at `path: [key]`, message
"a `joined` report selects per block — move `KEY` onto `blocks[]`, or
delete it; on the container it selects nothing." (`KEY` stands for the
key's own name.) No checkout on this box holds a built spec `dist`
carrying that refusal, and building one was outside this card.

## H4 — edges

- `joined` → non-joined: nothing is restored; the patch is `{ type }`
alone and the author re-binds (pinned).
- non-joined → non-joined: the binding is kept; the patch is `{ type }`
alone (control, pinned).
- `joined` with `blocks[]` present: untouched, same array reference, in
both directions (pinned).
- Undo: there is no undo stack behind the metadata draft.
`ResourceEditPage`'s `Undo2` button is `doDiscardDraft` (ADR-0034:
discard the whole pending draft), not a per-edit undo, and
`handleDraftChange` keeps no history. One undo cannot restore the keys;
switching the type back does not either.
- `runtimeFilter` and `drilldown` survive the switch (pinned): the
joined branch reads both.
- Partially bound report: the patch names only the keys present
(pinned), so `ReportConfigPanel.onFieldChange` sees no phantom clears,
and an unbound report's switch stays `{ type: 'joined' }` (pinned).

## Pins and ablations

Head `3f53c15776`, pin file plus the two existing
`ReportDefaultInspector` suites: `Test Files 3 passed (3) · Tests 37
passed (37)`.

Red on base (`1422a920ed`, component untouched, pin file present): `4
failed | 4 passed` — the defect pin, the own-key/serialised pin, the
parse leg, the partially-bound pin. The other 4 are controls and
boundaries, green on base by construction.

Per-hunk ablations on the committed head, each through objectstack's
`scripts/ablation-replace.mjs` (anchor must hit exactly once; the blob
change is verified on disk; restore proven `blob == HEAD` `a9cb7e9c` and
`git diff HEAD` empty), the prediction written before each run, observed
direction = predicted:
- M1, drop the `joined` guard (`if (nextType === 'joined')` becomes `if
(typeof nextType === 'string')`): predicted CONTROL F red; observed `1
failed | 7 passed` (CONTROL: non-joined → non-joined keeps the binding).
- M2, drop the presence filter (`if (draft[key] !== undefined)
patch[key] = undefined;` becomes unconditional): predicted D and E red;
observed `2 failed | 6 passed` (names only present keys; unbound one-key
patch). The first attempt was a NO-OP the tool refused — the replacement
text was a substring of the anchor, so its on-disk count could not rise
(`1 to 1, a rise of 0`) — and it restored; the leg was re-run with a
distinct replacement.
- M3, add `blocks` to the cleared list: predicted C and G red; observed
`2 failed | 6 passed` (parse leg: the joined report loses its blocks;
CONTROL: blocks untouched).
- M4, drop `order` from the list: predicted A, B and C red; observed `3
failed | 5 passed`.

Assertion spelling: every patch is read with `toStrictEqual`, because
`toHaveBeenCalledWith` / `toEqual` treat an `undefined`-valued key as
absent and would read the defect and the fix alike.

Round 2 (`d4945789f6`, the contract-review wording round: the
changeset's reach sentence and the pin file's three-host comment; no
source line changed — the diff's only non-changeset lines are the
comment lines shown by `git diff -U0`, and `ReportDefaultInspector.tsx`
is untouched): pin file re-run `Tests 8 passed (8)`.

## Gates (local, derived by hand from `package.json` and the workflows —
objectui has no `dispatch-gates.mjs`)

- ① closure build `turbo run build --filter='@object-ui/app-shell^...'
--concurrency=2`: `Tasks: 28 successful, 28 total`, 2m12s, under the
verify lock.
- ② `pnpm --filter @object-ui/app-shell type-check` (`tsc --noEmit &&
tsc -p tsconfig.test.json`): exit 0; `tsc -p tsconfig.test.json
--listFiles` lists the pin file (1 hit among 4673 files).
- ② vitest, the three files above: 37 passed; round 2 pin file: 8
passed.
- `node scripts/check-changeset-presence.mjs`: ✅ (round 2: 2 source
files of 1 released package changed, 1 changeset declared). `node
scripts/check-changeset-no-major.mjs`: ✅. `pnpm check:changeset-claims`:
✅. `pnpm check:pending-changeset-literals`: ✅.
- `pnpm check:control-bytes`: ✅ OK (round 2: 8942 tracked text files).
The role file's control-byte grep over the changed files: no match.
- `pnpm check:new-line-citations`: `0 new citation(s)` (both rounds).
- `pnpm check:test-path-roots`: ✅ OK. `pnpm check:vi-mock-specifiers`: ✅
OK.
- eslint with the package config on the two touched `.tsx` files: exit
0.
- NOT MEASURED locally, CI-owned: `pnpm lint` (repo-wide), the 8 test
shards, `Build & E2E`, `check:i18n-*` (no `t()` key was added or
changed). Round 1 CI on `3f53c157`: 40 success, 3 skipped, 0 failed per
the contract review.

## Serial

Round 2: `origin/main` at `25c7d584e4` (+5 commits over BASE
`1422a920ed`: objectstack-ai#10752, objectstack-ai#10753, objectstack-ai#10708, objectstack-ai#10751, objectstack-ai#10761); none touches the
three files; `git merge-tree --write-tree` exits 0 (clean, tree
`738f2bf534`). No merge commit was needed.

## Acceptance notes

- `chart` is cleared per the triage direction and
objectstack-ai/objectstack#20161 (inert on a joined container); the
installed spec does not refuse it, so its clearing is pinned by absence
only.
- Deviation: the worktree-creating `git fetch origin main` ran against
the shared checkout (the role-file recipe) rather than inside the
worktree as the order asked; `origin/main` did not move (`1422a920ed`
before and after), only the shared checkout's `FETCH_HEAD` was touched.
Every later fetch used a private ref inside the worktree.
- Observation, not filed: `ReportConfigPanel` hosts this inspector too,
merges with the same spread and emits `onFieldChange(key, undefined,
next)` once per cleared key — the documented "field changed" signal,
correct for a live preview.
- The draft-over-baseline merge that re-introduces a published binding
after the first draft save is objectui#10765 (filed by the seat), not
addressed here.

---
_Generated by [Claude
Code](https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… refuse a joined container chart (objectstack-ai#20161) (objectstack-ai#20238)

Fixes objectstack-ai#20161

Clause-②: no (narrowing)

A `joined` report draws each of its blocks as a table. Nothing ever drew
a chart on one: not the container `chart`, and not a `blocks[].chart`.
Both parsed green, passed the `validate-chart-bindings` lint, and
plotted nothing. This PR retires the chart on a joined report, following
triage's direction (retire, premise first; ADR-0049 enforce-or-remove):

- `JoinedReportBlockSchema.chart` is **removed** from the closed block
shape. Writing it is answered with the upgrade prescription through the
block's `guidance` table.
- A container `chart` on a `type: 'joined'` report is **refused** by the
joined arm of `ReportSchema`'s refinement, next to the five keys PR
objectstack-ai#20160 refuses. No pointer onto `blocks[]` is given, because a block has
no chart either.
- `chart` on every **non-joined** report is untouched. It is that
report's live embedded chart.

## Premise first: the census came back clean, so nothing stopped the
retirement

Triage's stop condition was: "a joined report with a `chart` authored in
`examples/**`, the showcase, or hotcrm". The instrument is structural. A
`tsx` script imports each report module and walks the exported report
**objects**, not their text, so a chart spread in from a variable would
still be seen. It counts container and block charts on joined reports.
As a positive control, it also counts `chart` on non-joined reports.

| corpus (repo @ commit) | reports | joined | joined container `chart` |
joined block `chart` | control: non-joined with `chart` |
| :-- | --: | --: | --: | --: | --: |
| objectstack `examples/app-showcase` + `examples/app-todo` @
`a46e3cf53` | 9 | 1 | **0** | **0** | 1
(`showcase_hours_by_status_chart`) |
| hotcrm `src/**/reports/*.report.ts` @ `2f7b232` | 10 | 1 | **0** |
**0** | 5 |

The other example apps (`app-crm`, `app-multi-package`,
`embed-objectql`) author no reports. `type: 'joined'` has 1 hit in
`examples/` and 1 in hotcrm, and both are the reports counted above.
Both joined reports still parse under the new schema.

## Measured before changing anything

1. **Declarations at today's `main`** (`1c8b320a8`, after PR objectstack-ai#20160's
`2bbebf532`): `JoinedReportBlockSchema.chart` is at
`report.zod.ts:223-224`, and `ReportSchema.chart` is at `:400`. The
joined arm refused `dataset` / `rows` / `columns` / `values` / `order`,
and not `chart`. The filer's `:381` was pre-objectstack-ai#20160 numbering.
2. **The renderer, at the `.objectui-sha` pin
`f8a9d0fb0596f4521076628e2bbfe27e6ce67d52`** (a read-only shallow
clone). `DatasetReportRenderer.tsx:1462-1524` is the joined branch. It
draws each block as a `DatasetMatrixTable` / `DatasetReportTable` and
returns. The only `report.chart` read is at `:1557-1560`, after that
return. `git grep -F "block.chart"` over objectui `packages/` exits 1,
while the control `block.runtimeFilter` hits
(`DatasetReportRenderer.tsx:1473`). So the filer's reading holds.
3. **The lint.** At base, `validate-chart-bindings` resolved a block
chart's axes against the block's dataset. The test *"checks a joined
report block chart against the block dataset"* asserted one finding at
`reports[0].blocks[0].chart.xAxis`. It now checks `chart` only on a
non-joined report. A block's own selection (`dataset` / `rows` /
`columns` / `values`) is still resolved. **The save door also runs this
lint as an author-time check.** When the door test below saved a joined
report whose block dataset this stub could not resolve, the door refused
it with `chart-dataset-unknown`. So what this lint says, or leaves
unsaid, reaches the publish path.
4. **ADR-0087 route: D2 conversion plus an exact retired-key entry, and
the family's own D3 entry (ruling B on objectstack-ai#17152, added in patch round
2).** A report is a stack collection that is stored as `sys_metadata`
rows. The Studio report form offered a block `chart` input
(`reportForm`'s blocks repeater) until this change, so a stored row can
carry one. `applyConversionsToStoredItem` replays retired entries, so a
D2 strip heals such a row at rehydration instead of refusing it on its
next save. `chart-config-aria-removed` made the same choice for the same
carrier (`report.blocks[].chart.aria`). The delete is lossless, because
neither value was ever drawn. The entry is `retiredFromLoadPath: true`,
so a live author is refused at parse, never rewritten. The strict-route
removal is proven by `build-schemas` check (c) **proof 4**, and the
build log says: *"ui/JoinedReportBlock:chart — def reachable from the
metadata-type roots; writing 'chart' on it is REFUSED as an unrecognized
key and the refusal carries the prescription its `strictObject`
declaration owes it"*.

## What changed

- `packages/spec/src/ui/report.zod.ts`: removes the block `chart` and
adds its `guidance` prescription. Adds the container refusal at `path:
['chart']`. Fixes the TSDoc rider: `ReportSchema.blocks` now describes
the dataset-bound block (`dataset` / `rows` / `columns` / `values` /
`runtimeFilter` / `order`, with the container `runtimeFilter` ANDed),
not the pre-cutover `object`/`filter` shape, and the block docblock's
`filter` is corrected the same way. `ReportSchema.chart`'s `.describe()`
now says it is refused on a joined report.
- `packages/spec/src/ui/report.form.ts`: drops the block `chart`
repeater column. The container `chart` field gets `visibleWhen:
"data.type != 'joined'"`.
- ADR-0087:
`migrations/entries/retired-keys/18.ui__JoinedReportBlock__chart.ts`,
and the D2 conversion `report-joined-chart-removed` in
`conversions/registry.ts`, wired into
`MIGRATIONS_BY_MAJOR[18].conversionIds` with the step rationale
extended. `registry.ts` was regenerated with `gen:migration-registry`.
`spec-changes.json` and the upgrade guide are byte-identical, because
major-18 entries do not project yet.
- ADR-0087 D3 (patch round 2, owed under ruling B on objectstack-ai#17152):
`migrations/entries/semantic/18.ui-report-joined-chart-retired.ts`, id
`ui-report-joined-chart-retired`. It names the D2 id
`report-joined-chart-removed` as a whole id, says what the D2 already
repairs, and says what the author still owes. It is a new family entry,
not an extension of `ui-report-joined-container-selection-refused`: that
entry is the enforce arm for four keys that stay declared, with no D2.
The changeset marker now reads `registered report-joined-chart-removed,
ui-report-joined-chart-retired`.
- **Fixture disjointness, forced:** `chart-config-aria-removed`'s
fixture carried a `blocks[].chart`. The new entry strips a block's whole
`chart`, so that fixture could no longer equal its own `after` under
full-table replay. Its fixture drops the block leg (`expectedNotices` 3
→ 2). Its `apply` is left as shipped. On a stored block chart carrying
`aria`, it runs first and this entry then strips the whole chart, with
the same compound result.
- Generated: `authorable-surface/ui.json` drops
`ui/JoinedReportBlock:chart` (proof 4 above).
`content/docs/references/ui/report.mdx` is regenerated (`check:generated
--fix`, only the one artifact it proved stale). The `platform-objects`
metadata-form bundles drop `blocks.chart` in all four locales (`pnpm
i18n:extract`, pure deletion).
- `packages/spec/liveness/report.json`: the `blocks` row now names only
what the joined branch reads, and states that there is no block chart.
The `chart` row now says it is live on non-joined reports only. Both
carry `verifiedAt: 2026-09-27`. The README row notes it.
- `packages/lint/src/validate-chart-bindings.ts`: a `drawsChart` flag,
false for a joined container and for every block.
- `content/docs/ui/reports.mdx`: the joined section now lists what a
block rejects (nested `blocks`, `drilldown`, `protection`), what the
container refuses (`dataset`, and a non-empty `rows` / `columns` /
`values` / `order`, plus `chart`), and what it reads (`runtimeFilter`,
`drilldown`). The chart section says a joined report refuses `chart`.
- `.changeset/20161-joined-report-chart-retired.md`: `@objectstack/spec`
minor, `@objectstack/lint` patch and `@objectstack/platform-objects`
patch. It carries the **BREAKING** banner, FROM → TO, the fix, the
`Clause-②` line and the disposition `registered
report-joined-chart-removed`.

**Outside the claimed file surface, and why:**
- `report.form.ts` and the four generated bundles are the playbook's
forms and i18n rows.
- `conversions/registry.ts` is where a D2 entry lives.
- Three pin files had to flip in the same round, each to the new count,
with the reason written beside it:
- `lint/src/validate-predicate-path-refs.test.ts`: the shipped-predicate
census went 72 → 73, and the `==`/`!=` literal comparisons went 52 → 53,
from the new `report :: chart` gate. The corpus lost the block column,
which carried no predicate.
- `platform-objects/.../report-form-echo-decisions.test.ts`: the
population went 45 → 44 leaves, 37 → 36 field leaves, and 39 → 38
negatives.
- `platform-objects/.../object-lifecycle-panel-echo-decisions.test.ts`:
the translated-label control went 609 → 608.
-
`metadata-protocol/.../protocol.invalid-metadata-422-face-inventory.test.ts`
carries the door pin. It uses that file's already-pinned engine double,
so the ledger is not touched.

## Tests

**Patch round 2, at the head `4aecf01d2`** (dev report `5858814205`):
spec `--project local` **547 files / 16093 passed** (2 todo); spec
`--project repo` **33 / 604**; lint **109 / 4234**; platform-objects
**55 / 911**; metadata-protocol **189 (3 skipped) / 2706** (19 skipped).

The round-1 readings below are at `94a2c634d`, unless noted.

- `@objectstack/spec`, full `--project local`: **542 files / 15949
passed** (2 todo). `report.test.ts` has a new describe with 9 tests:
block refused as `unrecognized_keys` with the prescription; located at
`['blocks', 1]` under the report; container refused as `custom` at
`['chart']` with no `blocks[]` pointer; one issue per key next to
`dataset` / `order`; `defineReport` throws both;
`getMetadataTypeSchema('report')` refuses both; a block under a
non-joined container is refused too; `chart` on `tabular` / `summary` /
`matrix` round-trips; and a chart-less joined report parses unchanged.
The typecheck exits 0.
- `@objectstack/lint`: **109 files / 4234 passed**. The typecheck exits
0.
- `@objectstack/platform-objects`: **55 files / 911 passed**. The
typecheck exits 0.
- `@objectstack/metadata-protocol`: **189 passed, 3 skipped files / 2703
passed** (19 skipped). The typecheck exits 0, and `tsconfig` includes
`src/**/*`, so the test file is compiled. The new door pins:
`saveMetaItem({ type: 'report' })` answers **`INVALID_METADATA` / 422**
for a container `chart` (`custom` at `chart`) and for a block `chart`
(`unrecognized_keys` at `blocks.0`), and stores nothing. A CONTROL
without `chart` is stored.
- Other importers of the report schema, as targeted runs (pre-merge
head):
- `downstream-contract` `contract.test.ts` passed.
`consumer-specifier-ledger.test.ts` is **NOT MEASURED**: it refused
because the `@objectstack/cli` dist was absent at that moment, which is
a prerequisite and not a finding.
- `objectql`: `metadata-validation-sweep` and `overlay-precedence`, 27
passed.
  - `metadata`: `typescript-serializer-annotation`, 7 passed.
  - `rest`: two meta read/diff org-scope files, 62 passed.

**Ablations** (one-off, `scripts/ablation-replace.mjs`, anchor-verified,
and restored to the `HEAD` blob with an empty `git diff HEAD`; the
subjects import `src/` relatively, so no `dist` is involved):

| mutation | predicted | observed |
| :-- | :-- | :-- |
| container refusal disabled: `if (r.chart !== undefined)` → `if ((false
as boolean) && …)`, blob `f96fd4d6` → `8c02bbc8` | the 4 container tests
red | `report.test.ts` **4 failed / 68 passed**: exactly the container,
all-keys, `defineReport` and save-door tests |
| block `guidance` entry deleted, blob `f96fd4d6` → `632c8c5f` | the 3
prescription-asserting block tests red | **3 failed / 69 passed** |
| lint `if (!drawsChart) return;` deleted, blob `9ececb06` → `216e5e66`
| both new lint pins red | first run: **1 failed**. The joined-container
pin stayed green because a joined container binds no dataset and returns
before the chart question, so that pin could not fail. It was rewritten
to bind a resolvable container `dataset` (commit `94a2c634d`). Re-run:
**2 failed / 45 passed**. |

The door pins in `metadata-protocol` resolve `@objectstack/spec` through
its built `dist`, so ablating them needs two spec rebuilds. That
ablation is **NOT MEASURED**. It sits on the same schema instance,
`getMetadataTypeSchema('report')`, that ablation 1 turned red at the
spec level.

## Gates

**Patch round 2, at the head `4aecf01d2`:** 115 derived, 115 run, every
one exit 0 (`--ran`: 0 NOT-MEASURED, 0 UNRUN). `origin/main` `fdb26698f`
was merged through `scripts/pm/os-regen-merge.sh` as `5977052dd`, with
two both-sides-append hunks resolved by hand in hand-authored regions:
the `CONVERSIONS_BY_MAJOR[18]` tail, and the step-18 rationale plus the
`conversionIds` tail. No generated region was hand-merged.
`check:generated` reports 15/15 current at `4aecf01d2`.

Round 1: `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `94a2c634d` derived **115** families. All
115 were run, each exit code was recorded, and every one is **0**. The
`--ran` reconciliation read *"115 derived, 115 run, 0 NOT-MEASURED, 0
UNRUN"*. Three of the 115 needed a second run on the earlier pass, and
the final pass ran them clean:

- `check:skill-examples` and `check:dual-build-cjs-loads` first exited 3
(PREREQUISITE NOT MET: unbuilt packages in this worktree). They were
re-run after building those packages.
- `check:type-check-debt` was run on its own, because of its roughly
5-minute runtime.

`pnpm --filter @objectstack/spec check:generated` reports all 15
artifacts up to date after the `origin/main` merge (`a46e3cf53`, through
`scripts/pm/os-regen-merge.sh`). Sibling registry entries survived:
`automation-flow-list-route-retired`,
`filter-ne-array-comparand-refused` and `api/FlowSummary` are present at
both `HEAD` and `origin/main`.

## Acceptance notes

- **Found, not fixed here (class b, same family as this card).** `chart`
on a `type: 'matrix'` report **with** `columns` parses:
`ReportSchema.safeParse` returns `success: true`, probed against the
built `dist` at `94a2c634d`. It is never drawn. At the pin,
`DatasetReportRenderer.tsx:1533` returns the `DatasetMatrixTable` before
the only `report.chart` read at `:1557`. A matrix **without** `columns`
degrades to the summary branch and does draw it. `Seam:
spec:ReportSchema.chart (matrix with columns) →
renderer:DatasetReportRenderer (matrix cross-tab branch)`. This is
reported to the seat for routing, not fixed, because the direction (draw
it on the pivot, or refuse it there) is a separate decision. **Filed as
objectstack-ai#20293.**
- objectui's local `JoinedReportBlock` interface
(`packages/types/src/spec-report.ts:297`) still declares `chart`, along
with the pre-cutover `objectName` / `groupingsDown` / `filter` shape. It
is a TypeScript type, not stored metadata, and the spec's
`JoinedReportBlock` it would replace is still `unknown`
(`z.ZodTypeAny`), which objectui pins. Nobody is assigned to carry this
fix.
- `ChartConfig.aria`'s prescription (`ui/chart.zod.ts`) still lists
`report.blocks[].chart.aria` among the places the key was authored. That
is historically true, but an author who writes it now meets the block
`chart` refusal first. Left as shipped. Nobody is assigned to carry this
fix.
- Studio: the container `chart` control's `visibleWhen` relies on
objectui honouring a field-level `visibleWhen` in `reportForm`, the same
mechanism the `columns` row already uses. This was not browser-verified.
- objectstack-ai#20160 and objectstack-ai#19856 are cited for context only and remain as they are.
- The seat updated this body at 2026-09-27T19:26Z, after patch round 2
(the D3 entry, the round-2 readings, objectstack-ai#20293). The at-tier reviews are
`5856458831` (FAIL at `94a2c634d`) and `5859051314` (delta PASS at
`4aecf01d2`).

---
_Generated by [Claude
Code](https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ)_

---------

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/m tests tooling

Projects

None yet

2 participants