Repository navigation
feat(spec)!: a joined report refuses a block that binds no dataset, at blocks[i].dataset, naming the block (#21702) - #21712
Conversation
…t blocks[i].dataset The joined arm of ReportSchema's refinement now enforces the 'each block dataset-bound' contract the schema comment and the reports guide already state. ADR-0087 D3 entry and step-18 rationale fragment. Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
…to a registered dataset A joined report's block with no dataset is now refused at blocks.0.dataset, so the fixture that left its block unbound (to dodge chart-dataset-unknown) binds it and the harness registers the dataset it names. Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
…the joined block dataset refusal Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
📓 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 — 138 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 77436cf1a55e418453affda9ebf9015e46ff1d64 && git checkout 77436cf1a55e418453affda9ebf9015e46ff1d64
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7e0066af7a8e05709dc60096c69bc85e299d0331 c4e3ab96316d9a1f46f827781938a1b5f0843a32 && git checkout -B drift-repro 7e0066af7a8e05709dc60096c69bc85e299d0331 && git merge --no-ff c4e3ab96316d9a1f46f827781938a1b5f0843a32
node scripts/docs-audit/affected-docs.mjs --json 7e0066af7a8e05709dc60096c69bc85e299d0331
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 37194495975 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
…set picker, marked required (objectstack-ai#21714) (objectstack-ai#21819) Fixes objectstack-ai#21714 Clause-②: no Claim `5987856715` (the card's Change, unblocked by the director seat `5987403710`). In `reportForm`, the "Joined blocks" repeater's `dataset` row now declares `widget: 'ref:dataset'` and `required: true`. Studio's report inspector now draws a joined report's block `dataset` as the dataset picker, with the required marker, instead of a free-text cell with no marker. Since objectstack-ai#21702 (PR objectstack-ai#21712), the joined arm of `ReportSchema` refuses a block that binds no `dataset`, at `blocks.N.dataset`. The old cell therefore let an author add a block that was invalid by construction. ## What changes - **The row** (`packages/spec/src/ui/report.form.ts`, the "Joined blocks" repeater): `{ field: 'dataset', label: 'Dataset' }` becomes `{ field: 'dataset', label: 'Dataset', widget: 'ref:dataset', required: true }`, with a comment saying why. No other row, section or form changes. - **One pin** (`packages/spec/src/ui/report-joined-block-dataset.test.ts`, beside the tests for the refusal itself): the row declares `widget: 'ref:dataset'` and `required: true`. The pin reads the exported `reportForm`, which is the output of `defineForm`'s parse, so a form schema that stripped either key would also turn it red. The nested row is the same strict, recursive `FormFieldSchema` that already carries `widget` and `required` on top-level fields. - **Changeset:** `@objectstack/spec` `patch` (measurement below). - ⛔ No schema, accept-set, type or export change. `JoinedReportBlockSchema.dataset` stays optional on the block shape, and the joined arm's refusal is unchanged. `defineForm` returns `FormViewParsed`, so `reportForm`'s exported type does not move. No objectui file. ## The renderer side, measured at the pin `.objectui-sha` on `main` is `9dfaca654311`. Every reading below comes from `git show 9dfaca654311:PATH` in an objectui clone, read-only. `git merge-base --is-ancestor b508ac50d9 9dfaca654311` exits 0, so objectui PR 11611's merge is in the pin. That reading is an exit 0 and therefore holds in a shallow clone too. | claim | where, at objectui `9dfaca654311` | |:--|:--| | `ref:dataset` is registered in `WIDGETS` | `packages/app-shell/src/views/metadata-admin/widgets.tsx:3035` (`'ref:dataset': RefDatasetWidget`). The widget is at `:683`, and it is declared `'control'` at `:3144`. | | `RepeaterField` resolves a row field's widget from the row spec | `packages/app-shell/src/views/metadata-admin/SchemaForm.tsx:2809`: the grid cell calls `resolveFieldWidget({ name: s.field, schema: sub, fieldSpec: s, widgetContext })`. The card layout passes `fieldSpec={s}` to `FieldRow` (`:2884`), which resolves through the same function (`:1838`). `inferWidget` returns an explicit `fieldSpec.widget` first (`:447`), and a spec that pins a widget skips every name detector (`:1031`). | | it passes the row field's `required` | `SchemaForm.tsx:2799` (grid cell) and `:2882` (card row) both pass `required={Boolean(s.required)}`. The grid header draws the marker at `:2772`. For a non-boolean field, `FieldRow` draws the star (`:1878`, `:1917`) and passes `aria-required` through `FieldControl` (`:1930`). | The joined `blocks` repeater declares no `widget`, so `useGrid` is false (`SchemaForm.tsx:2643`) and its rows render in the card layout, as `FieldRow`s. Three things connect the inspector to this row: - `ReportDefaultInspector.tsx:374`–`:381` feeds the dataset catalog as `widgetContext.datasets`, and passes it to the spec form at `:652`. - `report-schema.ts:80`–`:126` (`getReportForm`) prunes only top-level section fields, so the `blocks` row specs reach `SchemaForm` unchanged. - The inspector reads `reportForm` from `@objectstack/spec/ui` (`report-schema.ts:25`). objectstack's console build injects this tree's spec into that import (`scripts/build-console.sh`, `OBJECTSTACK_SPEC_DIST`). **Browser check: not done.** It is optional on the card. It would need objectui's console built at the pin with this tree's spec injected, plus a backend and a logged-in Studio session, which is heavy on a shared container. The static chain above is the evidence. The browser reading of the card's second pin is still open. ## Reverse verification The fix was committed first (`02307d05b6`). Then `node scripts/ablation-replace.mjs` replaced the anchor `widget: 'ref:dataset', required: true }` with `widget: 'ref:dataset' }` in `report.form.ts`: anchor count 1 to 0, blob `8bc1038e3612` to `b0da741da01a`. The test imports `./report.form` from source, so no `dist/` is in the resolution path. - **Predicted:** the new pin turns red at `expect(row?.required).toBe(true)` and the other 16 stay green. - **Observed:** `Tests 1 failed | 16 passed (17)`, `AssertionError: expected undefined to be true` at `report-joined-block-dataset.test.ts:261`. The direction is the usual one, red. - **Restore** (the tool's own leg, plus a shell trap): the blob equals HEAD (`8bc1038e3612`) and `git diff HEAD` is empty. ## Regenerated artifacts None needed. `pnpm --filter @objectstack/spec build` followed by `check:generated` reports all 15 generated artifacts up to date. The only generated files built from form specs are the nine packages' i18n bundles, which carry `label` and `helpText`. `pnpm check:i18n` reports `OK (9 package(s) — all bundles in sync, no undeclared authoring keys)`. ## Changeset measurement `@objectstack/spec`'s `files[]` ships `dist` and `src/**/*.zod.ts`. `report.form.ts` itself is not packed (`npm pack --dry-run --json`: 2068 entries, no `report.form.ts`), but its bundled bytes are. After the build, `widget: "ref:dataset", required: true` occurs in 6 packed files: `dist/ui/index.{js,mjs}`, `dist/system/index.{js,mjs}` and the two `dist/browser/system` twins. Positive control: an unchanged string of the same form, `Additional dataset-bound blocks stacked into a single report`, occurs in the same 6. The diff therefore publishes from `@objectstack/spec`, so it carries a `patch` changeset and no `skip-changeset`. ## Tests and gates, at `d7c811ff38` - `pnpm --filter @objectstack/spec test`: `Test Files 615 passed (615)`, `Tests 18361 passed | 1 todo`. - `pnpm --filter @objectstack/spec typecheck` exits 0: `tsc --noEmit`, `check:scripts-typecheck` and `check:test-typecheck` (`52 file(s) / 246 error(s) / 135 pinned signature(s) held`). Per `tsc --listFilesOnly`, the edited test file is in `tsconfig.test.json`'s program (1 hit) and not in `tsconfig.json`'s. - The three `platform-objects` tests that walk `reportForm` (`repeater-row-properties`, `report-form-echo-decisions`, `dataset-panel-echo-decisions`): `3 passed`, `62 passed`. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 86 commands. - Every exit code was recorded before any pipe. - `--ran` reconciliation: `86 derived, 86 run, 0 NOT-MEASURED, 0 UNRUN`. - Five gates first exited 3 (`PREREQUISITE NOT MET`) and `check:type-check-debt` ran past my per-command timeout. All six were re-run after a full workspace build (`turbo run build --concurrency=2`, 72 of 72 tasks) and exit 0. - `check-changeset-no-major --base origin/main` exits 0. Its clause-② level axis is PR-scoped and not applicable on a local run; CI reads this body's `Clause-②` line. ## Acceptance notes - `reportForm` reaches Studio from the `@objectstack/spec` that the console bundles. Through objectstack's console build that is this tree, because of the `OBJECTSTACK_SPEC_DIST` injection. A standalone objectui build uses its own lockfile's `@objectstack/spec` and gets the row once a spec release carrying it lands there. - The top-level `dataset` hint (`report.form.ts:39`) still never renders in the inspector, which prunes that field and draws its own curated picker (`FORM_FIELDS_OWNED_ELSEWHERE`, `report-schema.ts:33`). That was already known on the card, and nothing here changes it. --- _Generated by [Claude Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
… params unless its type is api, with the action:button prescription (objectstack-ai#21855) (objectstack-ai#21869) Fixes objectstack-ai#21855 Clause-②: yes (narrowing) ## What this does An `action:group` / `action:menu` member's `params` now takes the array form (`ActionParam[]`, the input list) only, unless the member's `type` is `api`. Any other `params` value on a non-`api` member (an object, a string, a number or `null`) is refused at the component-props gate, at `actions.N.params`, with the member prescription in the file's existing voice: static values belong on an `action:button` node, whose `params` object carries them. This is the card's step, under the governing text it cites: - The maintainer's ruling A on objectstack-ai/objectui#10289 (`5825589480`), verbatim: "⛔ `params` never carries two shapes, and ⛔ no new value-bag key is declared." - objectstack-ai#21704 fork 5 A (`5979239990`) refused a member's `properties.params` and kept the member's `params` as `z.unknown()`, the button row's value schema. - The member's own `properties` prescription in `component.zod.ts` already pointed at `action:button` for static values. - triage's answer on objectstack-ai/objectui#11638 (`5990495682`, amended by `5991243780`) withdrew the other direction (the container reading an object `params`) and filed this card for the gate half. | | before | after | |:--|:--|:--| | a non-`api` member, `params` an array | accepted | accepted, byte-identical | | a non-`api` member (an absent `type` included), `params` not an array | accepted, then dropped at run time | refused: `custom` at `actions.N.params`, with the prescription | | a `type: 'api'` member, any `params` | accepted (the request-payload window, to 18) | **unchanged** | | `action:button` / `action:icon` node, object `params` | accepted (its static values) | **unchanged** | The refusal message, for a `navigate_edit` menu member: ```text `params` on an `action:menu` member is the list of inputs the runner collects from the user before the action runs — an `ActionParam[]` array. The container forwards any other `params` value only for a `type: 'api'` member, as its request payload (write `bodyExtra` for that), and this member's `type` is `'navigate_edit'`, so the object written here is dropped and never reaches the action. A member's static parameter values are not part of the inline action vocabulary: to run an action with static parameter values, author it as its own `action:button` node, whose `params` object carries them. ``` ### How - **`packages/spec/src/ui/component.zod.ts`.** - A module-private refinement, `actionContainerMemberParamsFitType(container)`, goes on both member builders (`buildActionGroupMember`, `buildActionMenuMember`) through `.superRefine`. - The member's `params` stays `z.unknown()`, so it keeps the enumeration pin's `runner` line. Its `.describe()` now states the accept set and still says "forwarded to the runner". - The members' docblock gains a section with the read points at the `.objectui-sha` pin `0abd4f9f8`. The objectui renderer directory is byte-identical there, at `2e818d0b5` and at objectui `main` `f1a177c41` (`git diff --quiet`). - `strictObject`'s unknown-key refusal stays terminal, so a member already refused for a key is not judged a second time (pinned). - ⛔ **What stays the same:** no key added or removed, no second shape for `params`, no export moved, and the `api` window is untouched. ### The ADR-0087 kit (the objectstack-ai#21702 / PR objectstack-ai#21712 and objectstack-ai#21464 / PR objectstack-ai#21764 shape) - The D3 semantic entry `packages/spec/src/migrations/entries/semantic/18.ui-action-group-menu-member-params-array-only.ts`. - Its `STEP18_RATIONALE` fragment at **order 83**, inserted where its id sorts. 83 is the next free order: the highest on `main` is 82, re-read at `5b2d189e28` and again at `2df3d13d16` after the merge. - `registry.ts`'s generated region, written by `gen:migration-registry`. - One BREAKING `@objectstack/spec` `minor` changeset, `.changeset/21855-action-member-params-array-only.md`. It carries the `Clause-②` line, the `adr-0087: registered ui-action-group-menu-member-params-array-only` disposition marker and a FROM → TO table. - No tombstone, because no key is removed. No D2 conversion, because the static values belong on a different node, which no rewrite can build in the author's place. - **No regeneration is owed for `spec-changes.json` and `docs/protocol-upgrade-guide.md`.** Both project the registry from the support floor up to the current protocol major (17), so a step-18 entry is not in either yet. `check:spec-changes` and `check:upgrade-guide` are green with both files untouched, as on the two precedents. - **`packages/spec/dropped-refinements.baseline.json`** gains `ui/ActionGroupProps` and `ui/ActionMenuProps` (site `actions.element` each), exactly as `build-schemas` printed them. Its `measured` counts go 218 → 220 schemas and 676 → 678 sites. The JSON Schema projection has no arm for a `custom` check; the S-final stage declared its timeline refinement the same way. - **`content/docs/references/ui/component.mdx`** was regenerated by `check:generated --fix`. That run proved this the only stale artifact; the change is the two member `params` rows. ## Census, re-run before writing (at base `5b2d189e28`), with a lit control The question: which `action:group` / `action:menu` members author a non-array `params` on a non-`api` type? **Instrument** (scratchpad `census.cjs`): - A TypeScript-AST walk over `.ts`/`.tsx`/`.js`/`.jsx`/`.mjs`/`.cjs`/`.mts`/`.json` and fenced Markdown code. YAML is read as text. - Pass 1 lists every `params` key in every file that names either block, with its value kind and its owner's `type`. Same-file constants are resolved. A member is classified automatically when its `actions` owner (flat, inside a node's `properties` bag, or through a same-file constant array) carries the block `type`. - Pass 2 lists every non-array `params` on an element of any `actions` array corpus-wide, to catch members built in files that never name a block. - Every non-array hit was read by hand. Helper positions (`mount(surface, entry)`, `schema(member({ … }))`) are resolved that way. - **Lit control:** a planted fixture with four non-`api` object members (flat, inside `properties`, through a const array, in a Markdown fence), one `api` member and one array member. The instrument found and classified all six. Separately, the hand-read loose pass reached objectui's own helper-position drop probes, below. | corpus | files | naming a block | `params` keys there | not an array | container members with a non-array `params` on a non-`api` type | |:--|--:|--:|--:|--:|:--| | objectstack `5b2d189e28` | 10069 | 24 | 32 | 23 | **0**. The 23 are inline `element:button` action conversion fixtures, schema source, the liveness ledger's `params` row, CHANGELOG quotations, and one `properties.params` refusal probe. | | objectui pin `0abd4f9f8` | 7451 | 89 | 47 | 41 | **0 writers.** The only such members are objectui's own tests asserting that the container drops the value: `action-entry-object-params-10462.test.tsx:144`, `:193` and `action-container-member-params-10290.test.tsx:196`. The `type: 'api'` controls beside them stay accepted. | | objectui `main` `f1a177c41` | 7467 | 89 | 47 | 41 | the same; zero hits differ between pin and main | | hotcrm `4054ec2680` | 888 | 0 | 0 | 0 | **0** | | cloud | — | — | — | — | **not reachable** from this session: `git ls-remote` asks for credentials, the API answers 403, and `add_repo` (read) answers "you don't have access" | Deployed metadata was not measured. So the change narrows a zero-writer spelling, and `Clause-②: yes (narrowing)` holds. ## Tests - **New pin** `packages/spec/src/ui/component-action-member-params-array-only.pin.test.ts`, 39 tests: - §1: the refusal on both containers, each case asserting `[{ code: 'custom', path: 'actions.N.params' }]` exactly. The values are an object on `navigate_edit`, on an absent `type` and on `url`; an empty object; a string; a number; and `null`. One more case puts the issue on the second member. The message names the container, the member's `type` and the `action:button` prescription. - §2: the accept set, byte-identical. That is an array on non-`api` and `api` members, an empty array, the `api` member's object and string `params`, no `params`, and `bodyExtra`. A CONTROL shows `action:button` / `action:icon` nodes keep their object `params`. - §3: one complaint only, because the unknown-key refusal is terminal. - §4: the D3 entry is registered with no conversion, and the step-18 rationale names it. - **Ablation, predicted first.** Prediction: 18 red (all of §1), 21 green in the pin, and the two neighbour pins untouched. - Mutation, at `7a28c5194a` (the `component.zod.ts` blob is unchanged since, through the merge): `scripts/ablation-replace.mjs` replaced the refinement's guard with a bare `return` (anchor ×1 → ×0, blob `d8565fd7a8` → `930000300e`), inside a driver carrying its own `EXIT INT TERM` restore trap on the absolute path. - Observed over the three pins: `Tests 18 failed | 160 passed (178)`. The 18 were exactly the §1 cases. - Restore: blob `d8565fd7a8` equals HEAD's, and `git diff HEAD` is empty. - The pin imports `./component.zod` relatively, so it reaches `src` and no `dist` is involved. - **The public door.** lint's `validateComponentProps`, from `src`, ran over this branch's built `@objectstack/spec`: - a `navigate_edit` group member and a menu member with no `type`, each with an object `params`, each give one `component-props-invalid` finding (`warning`) at `pages[0].regions[0].components[0].properties.actions.0.params`, carrying the message above; - CONTROLS give 0 findings: an array on a `script` member, an object on an `api` member, and an object on an `action:button` node. - The before-state is the card's own reading: the door reported nothing for such a member. ## Verification All at head `810b1c4b18` (the merge of `main` `2df3d13d16`, below) unless noted. - **`@objectstack/spec`, `vitest run --project local --maxWorkers=2`** (the package's `test` script), under the verify lock: `Test Files 616 passed (616)`, `Tests 18426 passed | 1 todo`. Before the merge, at `a6f230772f`, both projects (`local` and `repo`): `Test Files 669 passed (669)`, `Tests 19325 passed | 1 todo`. - **`pnpm --filter @objectstack/spec typecheck`**, run at `a6f230772f`: exit 0. That covers `tsc --noEmit`, `check:scripts-typecheck` and `check:test-typecheck: OK` (52 files / 246 errors / 135 signatures held, unchanged). `tsc -p tsconfig.test.json --listFilesOnly` names the new pin, and the D3 entry is in the `tsconfig.json` program. - **`@objectstack/lint`, whole suite**, the component-props gate's package: `Test Files 119 passed (119)`, `Tests 5627 passed`. - **`@objectstack/spec` build, then `check:generated`**: `All 15 generated artifacts are up to date`. The `check:generated --fix` run before the merge proved `check:docs` the only stale artifact, and only it was regenerated. - **Derived gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) derived 114 commands at `810b1c4b18`, against merge base `2df3d13d1` (7 paths). Each was run with its exit code recorded before any pipe. `--ran` reconciled **114 derived, 114 run, 0 NOT-MEASURED, 0 UNRUN**, and all 114 exited 0. - Six first answered `PREREQUISITE NOT MET` (exit 3) because workspace packages were unbuilt: lint's `check:doc-formula-expressions` and `check:doc-security-posture`, spec's `check:skill-examples`, `check:docs-transcript-drift`, `check:dual-build-cjs-loads` and `check:lean-entry-closure`. After `turbo run build --filter=!@objectstack/docs --concurrency=2` they were re-run at the same head, each reaching its own verdict with exit 0. The record holds the re-runs. - That build reported `@objectstack/hono#build` failed in its DTS-emitted check. The `dist/index.d.ts` it named missing was on disk right after, and a rebuild was green (`31 successful, 31 total`). The adapter is outside this diff. - Among the 114: `check-adr-0087-registration --base origin/main` (one declared-breaking changeset, `registered ui-action-group-menu-member-params-array-only`), `check-changeset-no-major`, `check-empty-changeset`, `check:migration-registry`, `check:spec-changes`, `check:upgrade-guide`, `check:authorable-surface`, `check:api-surface`, `check:docs`, `check:strictness-ledger`, `check:doc-authoring`, `check:issue-citations`, `check:cross-package-test-inputs`, `check:nul-bytes`, `check:type-check-debt`. - **eslint, narrowed and proven.** These three together make the narrowing a measurement: 1. Population: `eslint.config.mjs`'s `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` block covers all four changed `.ts` files. 2. `eslint --no-inline-config --format json`: 4 files, 0 errors, 0 warnings. 3. Invariance: the config enables no type-aware linting (no `parserOptions.project`, as its own comment states), so this diff cannot move a verdict on an untouched file. - **Merge.** `origin/main` moved 4 commits after the base (`5b2d189e28` → `2df3d13d16`). It was merged through `scripts/pm/os-regen-merge.sh` (⛔ no rebase), and none of those commits touches a file of this PR. A re-fetch just before this PR showed 4 more commits on `main`, none touching these files, so no second merge. - **Not measured here:** the full `pnpm lint`, the Console Pin Gate, the Dogfood Regression Gate and the `repo` vitest project after the merge. Reason: CI-owned. ## Acceptance notes - **Interpretation, stated:** "array form only" is read literally. A string, a number or `null` `params` on a non-`api` member is refused as well as an object. The container drops each of those the same way (`readActionEntryParamValues` returns nothing for any non-array value on a non-`api` type), and the census found none of them either. If the reviewer reads the card as objects only, the change is one condition in the refinement's guard plus the string, number and `null` cases in §1. - **The `api` window is untouched, and ending it is not this PR's job.** An `api` member's object `params` stays accepted. When protocol 18 ends that window, the member refinement's `type === 'api'` arm is the one line that moves. Carrier: whoever ends that window. Noted, not filed. - **objectui remainder, already carried.** `readMemberStaticParamValues` still reads a member's `properties.params`, which the spec refuses. objectstack-ai/objectui#11638, as re-scoped by triage (`5991243780`), carries it. No objectui file is touched here. - **Inert number in a hand-edited ledger.** `dropped-refinements.baseline.json`'s `measured.refinementSitesThatDidProject` reads 369, while this build prints "450 refinement site(s) DID reach the file". No gate reads that number. It is left as found; changing it is outside this card. Carrier: none. Noted, not filed. --- _Generated by [Claude Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21702
Clause-②: yes (narrowing)
Triage direction
5977861131(enforce), claim5977924610. The joined arm ofReportSchema's refinement now refuses each block of ajoinedreport that binds nodataset, atblocks[i].dataset, naming the block, with the prescription to bind the block to a dataset. The refinement comment "each block dataset-bound" and the reports guide's type-table cell "(each block dataset-bound)" were declarations; they are now enforced.What changes
The refusal (
packages/spec/src/ui/report.zod.ts). A per-block arm inside the joined branch ofReportSchema'ssuperRefine, right after the existing "needsblocks" check. For each block whosedatasetis undefined it adds onecustomissue at['blocks', i, 'dataset']. The message comes from a module-private builder,joinedBlockDatasetRequired, worded like the neighbouring joined refusals:NAMEis the block'sname. A block whosenameis empty (its owntoo_smallissue, which does not stop the refinement) is named by position instead, asblocks[1].What stays accepted.
JoinedReportBlockSchema.datasetstays.optional():blocksis read on ajoinedreport only, so the requirement lives on the joined arm, and a block on a non-joined report is not judged (pinned). No type, export or JSON Schema key changes. The block'sdataset.describe()now reads "Dataset name to bind (ADR-0021); a joined report refuses a block without one", andcontent/docs/references/ui/report.mdxis regenerated withgen:docs(two table rows).The ADR-0087 kit, in feat(spec)!: FlowSchema refuses a create_record, update_record or delete_record node whose static objectName is a stored-metadata table, with the runtime's prescription (#21654) #21687's shape:
entries/semantic/18.ui-report-joined-block-dataset-required.ts;STEP18_RATIONALEat order 77, inserted where its id sorts. 77 is the next free order: the highest onmainis 76, re-read at7e0066af7ajust before this PR. feat(spec)!: object-gantt markers, object-timeline mapping and both forms' fields take the shape each block reads; five objectui-held contracts reported as forks (#21464, S-objectui-held) #21699 (74 to 76) and fix(spec): the protocol 17 → 18 upgrade rationale states each cited decision in words instead of a tracker number (stage 9) #21698 landed before this branch's base16d241a6af, so no merge was needed;registry.tsregenerated withgen:migration-registry;@objectstack/specminorchangeset,.changeset/21702-joined-report-block-dataset-required.md, carrying theClause-②line, theadr-0087: registered ui-report-joined-block-dataset-requireddisposition marker and a FROM → TO table.The fragment and entry text state each decision in words and carry no tracker number. No tombstone (no key is removed) and no D2 conversion (only the author knows which dataset a block was meant to show).
content/docs/ui/reports.mdx: no edit. No sentence there became false; the type-table cell became true. See Acceptance notes.Census, at base
16d241a6af, before any editgit grepfor atype: 'joined'report acrossexamples/**,packages/**,skills/**,content/docs/**,docs/**,scripts/**andapps/**gave 33 lines in 15 files: CHANGELOG quotations, the spec's own comments, and these reports:examples/app-showcaseTaskOverviewReportcontent/docs/ui/reports.mdxexamplepackages/lintvalidate-chart-bindings.test.ts(raw stacks, never parsed)packages/platform-objectsreport-form-echo-decisions.test.tspackages/spectests (report.test.ts,filter-save-door-face-parity.test.ts,joined-report-block-type.test.ts) and thereport-joined-chart-removedconversion fixturepackages/metadata-protocolprotocol.invalid-metadata-422-face-inventory.test.tsskills/**Triage measured hotcrm
4054ec26(1 joined report, 0 unbound blocks) and cloud2205b530(none). I took those readings as given. I read hotcrm'ssrc/sales/reports/churn.report.tsonce, to shape the preservation fixture. Every block binds a dataset: three bindaccount_metricsand the fourth,recently_closed_lost, bindsopportunity_metrics. So triage's line "every block bindsaccount_metrics" is slightly off; its conclusion, zero unbound blocks, stands.objectui (read only, at
2e818d0b51and at this repo's pinab18797215). Studio's joined-report authoring can save a block with nodataset. That producer is this narrowing's reach:ReportDefaultInspector.tsxrenders the blocks throughSchemaFormwith the specreportForm"Joined blocks" repeater;RepeaterField'sadd()seeds a blank row with every columnundefined;datasetcolumn is free text and not required (the block's derived JSON Schemarequiredis["name"], measured);ReportSchema(clientValidation.ts) and the save door both accepted the result.Filed as Studio report inspector: a
joinedreport'sblocksrepeater saves a block with nodataset, which objectstack's ReportSchema now refuses atblocks.N.datasetobjectui#11601: category ①, no labels, dedupe query and hit count in its body. After the spec bump, Studio's live validation and the save door both refuse such a block atblocks.N.dataset.The renderers, at the pin:
DatasetReportRenderer's joined branch passesString(block.dataset ?? '')to each block's table, and that table's query hook goes idle on an empty name (:443). A report whose blocks all lack one failsisDatasetReportand falls through to the presentation bridge.DrillDownDrawer'sisDatasetBoundReportlists the records instead of drawing it.Fixture triage: one test-only file outside the claim's file surface
protocol.invalid-metadata-422-face-inventory.test.tssection 5 (the joined-reportchartdoor pins) left its block unbound on purpose: the door's author-time gate refuses an unresolvable dataset withchart-dataset-unknown, and the stub engine had no dataset universe. The new refusal turned 2 of its 23 tests red (Tests 2 failed | 21 passed): the container case got a second issue atblocks.0.dataset, and the CONTROL was refused atblocks.0.dataset. That second red is the metadata save door (422 INVALID_METADATA,writeFace: 'meta-envelope') refusing the probe shape.task_metrics, and the harness gains an optionalmakeProtocol({ datasets }), which registers that dataset throughregistry.listItems('dataset'). Every other caller passes nothing, so the registry lists nothing, as before. Result:Tests 23 passed (23).makeProtocol()without datasets (scripts/ablation-replace.mjs, anchor 1 → 0, blobf718099c2d→9dc51bd7b8). Predicted 1 red / 22 green; observedTests 1 failed | 22 passed. The failure waschart-dataset-unknownatreports[0].blocks[0].dataset, "Declared datasets: (none)". Restore: blob back tof718099c2d, which equals HEAD, andgit diff HEADis empty.body, including one with neither abodynor ahandler(#21689) #21706, queued at this writing, appends a section to the same file at line 570 and later. A localgit merge-treeof this head with that queue head exits 0. The file is notmerge=os-regenrouted, so that local answer is also GitHub's.Doors, tested and probed
packages/spec/src/ui/report-joined-block-dataset.test.ts, 16 tests):blocks.0.datasetandblocks.1.dataset, each naming its block; a bound block beside an unbound one draws one issue, at the unbound index; an empty name is named by position;JoinedReportBlockSchemaparsed on its own, both still parse;defineReportthrows; the registeredreporttype schema refuses (and accepts the bound report);ObjectStackDefinitionSchema, the stack parseobjectstack validateruns, refuses atreports.1.blocks.{0,1}.dataset;defineStackanswersSTACK_SCHEMA_INVALID/ 422;TaskOverviewReport(mirrored byte for byte) parses with output equal to its input, and a fixture shaped like hotcrm'scustomer_churn_signalsparses, gaining only thedrilldowndefault;JoinedReportBlock:datasettombstone, with a control on a known tombstone.objectstack validate, the real CLI (packages/cli/bin/run.js, built here). Fixtures were temporary, in the session scratchpad, outside the repo;@objectstack/specresolved to this worktree's build.name+label): exit 1,"code": "STACK_SCHEMA_INVALID",defineStack validation failed (2 issues)atreports.0.blocks.0.datasetandreports.0.blocks.1.dataset, each with the message above."valid": true.examples/app-showcase/objectstack.config.ts, four reports includingTaskOverviewReport): exit 0,"valid": true.dist. The CLI reads@objectstack/specthroughdist, so the mutation was rebuilt:ctx.addIssueof the new arm became aglobalThismarker assignment (anchor 1 → 0, blobfadd536165→2cbfdb9feb);@objectstack/specrebuilt;ablation-dist-preflight.mjsfound the marker indist(exit 0); the probe then gave exit 0,"valid": true. That reproduces the card's reading atmain;fadd536165, which equals HEAD, andgit diff HEADis empty; rebuilt;--absentfound the marker in none of 228 built files (exit 0); the probe gave exit 1STACK_SCHEMA_INVALIDagain.Ablation of the refusal, at the pins (predicted first)
scripts/ablation-replace.mjsreplaced the arm'sctx.addIssue(...)with a no-op (anchor 1 → 0, blobfadd536165→45f34370c5). The pins import./report.zodrelatively, which resolves tosrc, so no build was needed. Predicted: 8 red, the 4 refusal pins and the 4 door pins; 8 green, the advice, the 2 controls, the 2 preservation pins and the 3 ledger pins;report.test.tsuntouched. Observed:Tests 8 failed | 80 passed (88)over the pin file andreport.test.ts. The 8 were exactly the predicted ones. Restore: blob equals HEAD (fadd536165), andgit diff HEADis empty.Verification (all at head
c4e3ab9631unless noted)@objectstack/specfull suite (vitest run --project local --maxWorkers=2):Test Files 613 passed (613),Tests 18201 passed | 1 todo.pnpm --filter @objectstack/spec typecheck(tsc, scripts, test layer:check:test-typecheck: OK) andpnpm --filter @objectstack/metadata-protocol typecheck: exit 0.--listFilesshows both edited test files in their programs.Consumers that parse a joined report:
platform-objectsreport-form-echo-decisions.test.ts35 passed;lintvalidate-chart-bindings.test.ts47 passed;@objectstack/example-showcasewhole suite32 files, 399 passed;metadata-protocolface-inventory 23 passed.pnpm --filter @objectstack/spec check:generated: one artifact stale before regeneration (content/docs/references/**), regenerated withgen:docs; thencheck:generatedexit 0.Derived gates:
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 115 commands. Every one was run, with its exit code recorded before any pipe.--ranreconciled 115 derived, 115 run, 0 NOT-MEASURED, 0 UNRUN.dist):check:skill-examplesandcheck:dual-build-cjs-loads. Both were re-run green after building those packages. All 115 exited 0.check:adr-0087-registration,check:changeset-no-major,check:migration-registry,check:spec-changes,check:upgrade-guide,check:authorable-surface,check:api-surface,check:docs,check:doc-authoring,check:issue-citations,check:nul-bytes,check:cross-package-test-inputs.eslint, narrowed and proven:
eslint.config.mjs's**/*.{ts,…}block covers all five changed.tsfiles;--format json: 5 files, 0 errors, 0 warnings;parserOptions.project, as its own comment states), so this diff cannot move a verdict on an untouched file.The repo-wide
pnpm lintis CI's.Declared to CI: the path-scheduled jobs (Test Core shards, Dogfood, Temporal Conformance, Build Core), the whole-workspace type-check lanes and every downstream consumer suite of
@objectstack/specbeyond the four above.Acceptance notes (observations, not filed)
reports.mdxsays ajoinedreport "must declare at least one block". That is still true, but it no longer names every requirement: each block must now also bind adataset. Not edited, under the claim's rule that only a sentence this change makes false is edited. Carrier: none.reportForm(packages/spec/src/ui/report.form.ts, "Joined blocks" repeater) offersdatasetwith noref:datasetwidget and norequired. Studio report inspector: ajoinedreport'sblocksrepeater saves a block with nodataset, which objectstack's ReportSchema now refuses atblocks.N.datasetobjectui#11601 names this as a possible spec-side fix for the Studio producer, to be measured first. Carrier: Studio report inspector: ajoinedreport'sblocksrepeater saves a block with nodataset, which objectstack's ReportSchema now refuses atblocks.N.datasetobjectui#11601's acting seat.ui/Reportalready sits indropped-refinements.baseline.json, and the new arm adds no site there. The.describe()text is now the JSON-Schema-visible hint.Generated by Claude Code