Repository navigation
fix(spec): a joined report refuses a top-level dataset / rows / columns / values, pointing each onto blocks[] - #20160
Conversation
… / 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
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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
|
Contract reviewServed-tier: ① Derived judgmentsMethod: card #19856 body + 4 comments read; PR body + diff (5 files vs merge-base
CI at review time (head check-runs, fetched twice): 25 success, 6 skipped, 0 failure; 6 still ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
…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>
… 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>
Fixes #19856
Clause-②: no (narrowing)
A
type: 'joined'report selects nothing itself: each block binds its owndatasetand picks its ownrows/columns/values.ReportSchema's refinement already refused a container-levelorderfor 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 ontoblocks[], the wayorderhas always been refused.Measured first
Parse probe at
origin/main8d1f7ab7(re-run, per the card's request, against the specdistbuilt at that commit). Base is a valid joined report with one dataset-bound block; each row adds one key.8d1f7ab7dataset: 'sales'customat['dataset']rows: ['stage']customat['rows']columns: ['region']customat['columns']values: ['revenue']customat['values']order: [{ by: 'revenue' }]['order']runtimeFilter: {…}rows: [], columns: [], values: []chart: {…}measures/fields/dimensions/dataSet/objectNameunrecognized_keysnaming the rename targetThe renderer, at this repo's
.objectui-shapinf8a9d0fb0596f4521076628e2bbfe27e6ce67d52(the card's reading was at objectui's working HEAD0cf2d66, not the pin; this is the re-read). Inpackages/plugin-report/src/DatasetReportRenderer.tsx, the component resolvesouterFilterfromreport.runtimeFilter(:1455) anddrillSinkfromreport.drilldown(:1457). The joined branch opens at:1462and returns a stack of per-block tables. It reads onlyreport.blocksandreport.name, plus those two. The top-level reads ofcolumns/order/dataset/rows/values/chartstart 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:
8d1f7ab7: every joined-report site outside generated references. These are the showcaseshowcase_task_overviewreport, thecontent/docs/ui/reports.mdxexample, 3 fixtures inpackages/lint/src/validate-chart-bindings.test.ts, 2 inpackages/platform-objects/.../report-form-echo-decisions.test.ts, the spec's ownreport.test.ts, andscripts/analytics-reconcile/reconcile.ts, which readsblocksonly. None carries a containerdataset/rows/columns/values.@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".48d7066:git grepfor a joined report type exits 1 (zero matches). As a control, the same grep fortype: 'summary'does hit.ReportDefaultInspector. Its type picker commitsonPatch({ type: v })and nothing else. It then hides the top-level binding for joined reports (datasetBound = reportType !== 'joined',:328), andSchemaForm'shiddenFieldshides all five keys. So a report that was bound first and then switched tojoinedkeeps itsdataset/values/rowsinvisibly. After this PR, saving that draft is refused with a located issue atdataset. 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'ssuperRefinenow loops over a module-privateJOINED_CONTAINER_SELECTION_KEYS = ['dataset', 'rows', 'columns', 'values']. It is not exported, so the public surface is unchanged andcheck:api-surfaceis green. It adds onecustomissue per offending key atpath: [key], with this exact text:(
KEYstands for the key's own name.) The existingorderrefusal 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, andcheck:generatedreports all 15 artifacts current.dataset: "For ajoinedreport, the data lives onblocks: a top-leveldataseton one is refused — each block binds its own."rows: "A non-empty list on ajoinedreport is refused — seeblocks[].rows."columns: "Ignored for the other non-joined types; a non-empty list on ajoinedreport is refused — seeblocks[].columns."values: "A non-empty list on ajoinedreport is refused — seeblocks[].values."ADR-0087: a new semantic entry,
packages/spec/src/migrations/entries/semantic/18.ui-report-joined-container-selection-refused.ts.registry.tswas regenerated withgen:migration-registry.spec-changes.jsonand the upgrade guide were regenerated too and came out byte-identical, because major-18 entries do not project until the protocol reaches 18 (thesaved-report-stack-retiredentry, 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 asregistered 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.
saveMetaItemvalidates 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.normalizeStackInputwould hide exactly the mistake the card is about.2. The threshold is
order's own: a presentdataset, and a non-empty list. An emptyrows/columns/valuesselects 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 ther.order?.lengththreshold one line below in the same arm. This choice is pinned by a test, so it reads as deliberate.3.
chartis not refused here, although it is inert on a joined container too. The obvious prescription, "movechartontoblocks[]", would send the author toblocks[].chart. At the pin, the joined branch never reads that key either:git grep -F "block.chart"over objectuipackages/at the pin exits 1, while the controlblock.runtimeFilterexits 0. So the fix shape forchartis 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 blockchart.Alias routing (card Zone 2, item 3): the alias tables stay as they are.
measures→valuesanddataSet→datasetstill rename first, and the renamed key on a joined container then meets this refusal. That is the same two-step answersort→orderalready 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')inpackages/spec/src/ui/report.test.ts, 13 tests:{ code: 'custom', path: [key], message: PRESCRIPTION(key) }.orderissue with its message asserted byte-identical.defineReportthrows the prescription (the authoring door).getMetadataTypeSchema('report')refuses with the same envelope (the metadata save door's schema).rows: [], columns: [], values: [], order: []on a joined container parses.runtimeFilter+drilldown: falseparses and round-trips, and a matrix report with the same four keys parses.measures→valuesanddataSet→dataset: the alias getsunrecognized_keysnaming 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 wasif (value === undefined || … ) continue;and it was replaced with an unconditionalcontinue;. The anchor went from 1 hit to 0; the blob wentf13efbaf→743ff83c. Predicted direction: red. Observed:report.test.ts9 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.zodtosrc/, so nodistwas involved. Restore: blob equalsHEADf13efbaf, andgit diff HEADis empty.Verification (all at head
87c19461)packages/specfull 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.ReportSchema, after rebuilding everypackages/*dist, 71 packages):packages/lintsrc/validate-chart-bindings.test.ts: 45 passed.packages/platform-objectssrc/apps/translations/report-form-echo-decisions.test.ts: 35 passed.@objectstack/example-showcase, whole suite: 29 files, 384 passed (vitest run --maxWorkers=2).git grepover 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.node scripts/pm/dispatch-gates.mjs --commandson the actual 5 changed paths gave 88 commands. Every one was run with its exit code recorded before any pipe, and--ranreconciled 88 run, 0 NOT-MEASURED, 0 UNRUN. Five first exited 3 (prerequisite not met). They were re-run green afterturbo run build --filter='./packages/*' --filter='./packages/*/*', and after a--depth=1fetch of the teardown self-test's pinned fixture commit.Findings reported for the seat (not filed here)
JoinedReportBlockSchema.chart("Optional inline chart configuration") and theliveness/report.jsonblocksnote. Reach:ReportSchemaparse accepts both (measured above). At the pin, the joined branch renders tables only, andblock.charthas zero reads in objectuipackages/at the pin and at HEAD5c61e52.packages/lintstill validates block charts against the block dataset. Seam:spec:JoinedReportBlockSchema.chartandspec:ReportSchema.chart(joined) →renderer:DatasetReportRenderer(joined branch).ReportDefaultInspector.tsxat the pin, where the type picker patches onlytype. After this PR, that draft is refused at save atdataset. 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 becomesjoined.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-levelfilteris ANDed…". The key isruntimeFilter, and object/groupings are gone. This PR does not touch it. Carrier: the next PR to editreport.zod.ts.content/docs/ui/reports.mdxnames onlyorderamong a joined container's refused keys. It is still true, just not complete. Carrier: none (outside this claim's file surface).blocksis accepted and ignored. That is declared ("Renderers must ignoreblockswhentype !== 'joined'"), so this is an observation only.Generated by Claude Code