Skip to content

feat(spec)!: a joined report refuses a block that binds no dataset, at blocks[i].dataset, naming the block (#21702) - #21712

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21702-joined-block-dataset
Oct 4, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21702-joined-block-dataset

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21702
Clause-②: yes (narrowing)

Triage direction 5977861131 (enforce), claim 5977924610. The joined arm of ReportSchema's refinement now refuses each block of a joined report that binds no dataset, at blocks[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 of ReportSchema's superRefine, right after the existing "needs blocks" check. For each block whose dataset is undefined it adds one custom issue at ['blocks', i, 'dataset']. The message comes from a module-private builder, joinedBlockDatasetRequired, worded like the neighbouring joined refusals:

    a `joined` report draws each block from that block's own `dataset`, and block `NAME` binds none, so nothing queries it and it draws no rows. Bind the block to a dataset: set its `dataset` to the dataset whose measures (`values`) and dimensions (`rows`) it shows.
    

    NAME is the block's name. A block whose name is empty (its own too_small issue, which does not stop the refinement) is named by position instead, as blocks[1].

  • What stays accepted. JoinedReportBlockSchema.dataset stays .optional(): blocks is read on a joined report 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's dataset .describe() now reads "Dataset name to bind (ADR-0021); a joined report refuses a block without one", and content/docs/references/ui/report.mdx is regenerated with gen: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:

    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 edit

git grep for a type: 'joined' report across examples/**, packages/**, skills/**, content/docs/**, docs/**, scripts/** and apps/** gave 33 lines in 15 files: CHANGELOG quotations, the spec's own comments, and these reports:

where joined reports unbound blocks
examples/app-showcase TaskOverviewReport 1 0
content/docs/ui/reports.mdx example 1 0
packages/lint validate-chart-bindings.test.ts (raw stacks, never parsed) 4 0
packages/platform-objects report-form-echo-decisions.test.ts 2 0
packages/spec tests (report.test.ts, filter-save-door-face-parity.test.ts, joined-report-block-type.test.ts) and the report-joined-chart-removed conversion fixture 10 0
packages/metadata-protocol protocol.invalid-metadata-422-face-inventory.test.ts 1 1, unbound on purpose (below)
skills/** 0 0
  • Triage measured hotcrm 4054ec26 (1 joined report, 0 unbound blocks) and cloud 2205b530 (none). I took those readings as given. I read hotcrm's src/sales/reports/churn.report.ts once, to shape the preservation fixture. Every block binds a dataset: three bind account_metrics and the fourth, recently_closed_lost, binds opportunity_metrics. So triage's line "every block binds account_metrics" is slightly off; its conclusion, zero unbound blocks, stands.

  • objectui (read only, at 2e818d0b51 and at this repo's pin ab18797215). Studio's joined-report authoring can save a block with no dataset. That producer is this narrowing's reach:

    • ReportDefaultInspector.tsx renders the blocks through SchemaForm with the spec reportForm "Joined blocks" repeater;
    • RepeaterField's add() seeds a blank row with every column undefined;
    • the row's dataset column is free text and not required (the block's derived JSON Schema required is ["name"], measured);
    • the bundled ReportSchema (clientValidation.ts) and the save door both accepted the result.

    Filed as Studio report inspector: a joined report's blocks repeater saves a block with no dataset, which objectstack's ReportSchema now refuses at blocks.N.dataset objectui#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 at blocks.N.dataset.

  • The renderers, at the pin: DatasetReportRenderer's joined branch passes String(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 fails isDatasetReport and falls through to the presentation bridge. DrillDownDrawer's isDatasetBoundReport lists 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.ts section 5 (the joined-report chart door pins) left its block unbound on purpose: the door's author-time gate refuses an unresolvable dataset with chart-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 at blocks.0.dataset, and the CONTROL was refused at blocks.0.dataset. That second red is the metadata save door (422 INVALID_METADATA, writeFace: 'meta-envelope') refusing the probe shape.

  • Disposition: add the declaration. The block binds task_metrics, and the harness gains an optional makeProtocol({ datasets }), which registers that dataset through registry.listItems('dataset'). Every other caller passes nothing, so the registry lists nothing, as before. Result: Tests 23 passed (23).
  • The registration is load-bearing. I mutated the CONTROL to call makeProtocol() without datasets (scripts/ablation-replace.mjs, anchor 1 → 0, blob f718099c2d → 9dc51bd7b8). Predicted 1 red / 22 green; observed Tests 1 failed | 22 passed. The failure was chart-dataset-unknown at reports[0].blocks[0].dataset, "Declared datasets: (none)". Restore: blob back to f718099c2d, which equals HEAD, and git diff HEAD is empty.
  • This file is outside the claim's declared file surface. It is reported in the dev report as a deviation.
  • Queue check: fix(metadata-protocol)!: the save door refuses every hook with no body, including one with neither a body nor a handler (#21689) #21706, queued at this writing, appends a section to the same file at line 570 and later. A local git merge-tree of this head with that queue head exits 0. The file is not merge=os-regen routed, so that local answer is also GitHub's.

Doors, tested and probed

  • Pins (packages/spec/src/ui/report-joined-block-dataset.test.ts, 16 tests):
    • the triage probe shape is refused once per block at blocks.0.dataset and blocks.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;
    • the new issue joins the other joined-arm refusals rather than replacing them;
    • taking the advice parses;
    • CONTROLS: a block on a non-joined report, and JoinedReportBlockSchema parsed on its own, both still parse;
    • doors: defineReport throws; the registered report type schema refuses (and accepts the bound report); ObjectStackDefinitionSchema, the stack parse objectstack validate runs, refuses at reports.1.blocks.{0,1}.dataset; defineStack answers STACK_SCHEMA_INVALID / 422;
    • preservation: the showcase TaskOverviewReport (mirrored byte for byte) parses with output equal to its input, and a fixture shaped like hotcrm's customer_churn_signals parses, gaining only the drilldown default;
    • ledger: one D3 entry at protocol 18 with no conversion; the step-18 rationale names it; no JoinedReportBlock:dataset tombstone, 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/spec resolved to this worktree's build.
    • The triage probe stack (two blocks, each only name + label): exit 1, "code": "STACK_SCHEMA_INVALID", defineStack validation failed (2 issues) at reports.0.blocks.0.dataset and reports.0.blocks.1.dataset, each with the message above.
    • CONTROL, the same report with both blocks bound to a declared dataset: exit 0, "valid": true.
    • The showcase app (examples/app-showcase/objectstack.config.ts, four reports including TaskOverviewReport): exit 0, "valid": true.
  • The CLI door's verdict is this arm's, proven through dist. The CLI reads @objectstack/spec through dist, so the mutation was rebuilt:
    • mutated leg: the ctx.addIssue of the new arm became a globalThis marker assignment (anchor 1 → 0, blob fadd536165 → 2cbfdb9feb); @objectstack/spec rebuilt; ablation-dist-preflight.mjs found the marker in dist (exit 0); the probe then gave exit 0, "valid": true. That reproduces the card's reading at main;
    • restore leg: blob back to fadd536165, which equals HEAD, and git diff HEAD is empty; rebuilt; --absent found the marker in none of 228 built files (exit 0); the probe gave exit 1 STACK_SCHEMA_INVALID again.

Ablation of the refusal, at the pins (predicted first)

scripts/ablation-replace.mjs replaced the arm's ctx.addIssue(...) with a no-op (anchor 1 → 0, blob fadd536165 → 45f34370c5). The pins import ./report.zod relatively, which resolves to src, 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.ts untouched. Observed: Tests 8 failed | 80 passed (88) over the pin file and report.test.ts. The 8 were exactly the predicted ones. Restore: blob equals HEAD (fadd536165), and git diff HEAD is empty.

Verification (all at head c4e3ab9631 unless noted)

  • @objectstack/spec full 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) and pnpm --filter @objectstack/metadata-protocol typecheck: exit 0. --listFiles shows both edited test files in their programs.

  • Consumers that parse a joined report: platform-objects report-form-echo-decisions.test.ts 35 passed; lint validate-chart-bindings.test.ts 47 passed; @objectstack/example-showcase whole suite 32 files, 399 passed; metadata-protocol face-inventory 23 passed.

  • pnpm --filter @objectstack/spec check:generated: one artifact stale before regeneration (content/docs/references/**), regenerated with gen:docs; then check:generated exit 0.

  • Derived gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 115 commands. Every one was run, with its exit code recorded before any pipe. --ran reconciled 115 derived, 115 run, 0 NOT-MEASURED, 0 UNRUN.

    • Two first exited 3 (prerequisite not met, packages with no dist): check:skill-examples and check:dual-build-cjs-loads. Both were re-run green after building those packages. All 115 exited 0.
    • Among them: 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:

    1. population: eslint.config.mjs's **/*.{ts,…} block covers all five changed .ts files;
    2. --format json: 5 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.

    The repo-wide pnpm lint is 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/spec beyond the four above.

Acceptance notes (observations, not filed)


Generated by Claude Code

claude added 4 commits October 4, 2026 08:17
…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>
…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>
@github-actions

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 5 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
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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 — 138 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 7e0066af7a8e05709dc60096c69bc85e299d0331 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 77436cf1a55e418453affda9ebf9015e46ff1d64 — the merge of head c4e3ab96316d9a1f46f827781938a1b5f0843a32 into base 7e0066af7a8e05709dc60096c69bc85e299d0331, 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 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

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

@github-actions

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 37194495975 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Console Pin Gate — 失败步骤: Build the Console SPA at the pinned objectui SHA

    ✗ Built console still carries the PUBLISHED @objectstack/spec.
    

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError。 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • ⚠️ 本次没有可用的聚合签名(日志里没有能解析出测试文件名的 FAIL 行)—— 这不是「没有同签名的其他 PR」,是这一轮没测到。跨 PR 聚合本次不可用,请手工比对其他 PR 的同类评论。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 11 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

2 participants