Repository navigation
feat(spec): ComponentPropsMap rows for action:button/group/menu/icon and element:definition-list/repeater - #20420
Conversation
…and element:definition-list/repeater Six curated objectui public blocks had no row: the props gate skipped the four action:* types and component-type-unknown refused the two element:* lists. Each row is strict from birth, with its key set measured from the renderer read points at the objectui pin. Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude <noreply@anthropic.com>
… dropped filter refinement Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude <noreply@anthropic.com>
…s for the six rows Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude <noreply@anthropic.com>
…sion Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude <noreply@anthropic.com>
…esolved citation Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude <noreply@anthropic.com>
…ent-rows Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude <noreply@anthropic.com>
…authorable-surface, export-origins, declaration-map) Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude <noreply@anthropic.com>
…ction-element-rows Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude <noreply@anthropic.com>
…nts on the merged tree Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 6 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 137 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 eac80982bafa158af1fff732c3b7505b54162e85 && git checkout eac80982bafa158af1fff732c3b7505b54162e85
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3cf64493899458632f87e661fff1b130bd3a8273 5e50899a481dce659bc12ad2576b4fc534cd3893 && git checkout -B drift-repro 3cf64493899458632f87e661fff1b130bd3a8273 && git merge --no-ff 5e50899a481dce659bc12ad2576b4fc534cd3893
node scripts/docs-audit/affected-docs.mjs --json 3cf64493899458632f87e661fff1b130bd3a8273
|
…ession surfaces Three ADR-0060 conformance rows for the six predicate positions the new ComponentPropsMap rows declare, split by the fault face the objectui renderers and SchemaRenderer's node gate compose to at the pin. Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs: card #20371 (body, all five comments; triage notes ① Derived judgmentsEvery accept-set and public-surface change the diff implies, each tested against the pin:
② Semver level
③ Boundary flags
CI on this head: 46 check-runs, none in progress at render time: 39 success, 7 skipped (opt-in and label jobs), 0 failures. The previous head's red ( Implemented-by: VERDICT: PASS Generated by Claude Code |
…mponent-props-action-element-rows
… merged tree (keeps the docs title rule) Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude <noreply@anthropic.com>
|
Regen-provenance: 5868826831 ·
What the hop is: the dev's base-merge round What the seat measured on the committed trees:
This line is a pointer; the queue guard re-runs the content test itself. |
…mponent-props-action-element-rows
Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude <noreply@anthropic.com>
…eclare objectName The objectui pin moved from f8a9d0fb0596 to dd3f7e1be356. Every read point the action:* and element:* rows cite was re-derived at the new pin and re-anchored; the asserting citations now name dd3f7e1be. The new pin forwards `objectName` to the action runner from action:button and action:icon, so both rows declare it (the ActionDef string scalar, per the #7751 value posture). action:group and action:menu forward it per member, so the container rows gain no key. `params` keeps its meaning on the page path (array = input list, object = static values via properties.params). The dogfood expression-conformance rows for these keys are re-anchored at the same pin; their fault policies are unchanged across the hop. Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude <noreply@anthropic.com>
… objectName Output of `pnpm --filter @objectstack/spec check:generated --fix` after action:button and action:icon gained `objectName`. Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs: card #20371 (body and all seven comments, the round-4 report ① Derived judgmentsEvery public-surface change the diff implies, re-tested at the new pin:
② Semver level
③ Boundary flags
CI on this head: 42 check-runs, none in progress after re-polling ( Implemented-by: VERDICT: PASS Generated by Claude Code |
objectstack-ai#20454) Fixes objectstack-ai#20400 Clause-②: no ## What was wrong `validateComponentProps` does not report the props schema's required `object` when the component carries a `dataSource.object` binding. Two docblocks scope that waiver to absence: `DATASOURCE_SUPPLIED_PROP` ("the one prop whose absence this rule does NOT report") and `suppliedByDataSource` ("is this issue 'the required `object` prop is missing'"). The code matched on the issue's path alone. Any issue at exactly `['object']` was waived whenever `dataSource.object` was a non-empty string, without looking at `properties.object` or at the issue. So a present but wrong `object` beside a binding was silenced, and the same value without a binding was reported. ## Before and after (measured) Each row is one component in a one-page stack, run through `validateComponentProps` from source with `tsx`. Before is `origin/main` `6e3e5462c6`. After is this branch at `e994035362`. | component | before | after | |---|---|---| | `element:number`, `dataSource: { object: 'contact' }`, `properties: { object: 7, aggregate: 'count' }` | **0 findings** | 1 `component-props-invalid` at `properties.object` | | the same, with no `dataSource` (control) | 1 `component-props-invalid` at `properties.object` | the same, unchanged | | `object: null` beside the binding | **0 findings** | 1 `component-props-invalid` at `properties.object` | | no `object` key beside the binding | 0 | 0 (still waived) | | `object: undefined` beside the binding | 0 | 0 (still waived) | | the existing picker pin (`element:record_picker`, binding, `labelField` only) | 0 | 0 | ## The change The landing site is the expected one: the body of `suppliedByDataSource` in `packages/lint/src/validate-component-props.ts`, plus its test file and a changeset. After the existing path and binding checks, the function reads absence off the component. It waives only when `properties.object` is not present or is `undefined`, which is triage's definition of missing. Any other issue at that path passes through as the props row raised it. Both docblocks are unchanged: their contract sentences already say this, and the renderer sentence is out of scope (see Acceptance notes). The call site is unchanged. ## Pins (`packages/lint/src/validate-component-props.test.ts`) - The existing `does not report the required object prop when dataSource supplies it` is unchanged and green. - New, run for `object: 7` and for `object: null`: the same `element:number` bag is judged beside `dataSource: { object: 'contact' }` and without it. Each is reported as `[COMPONENT_PROPS_INVALID, '...properties.object']`, and the two finding lists are deep-equal. The assertions are on rule id, path and equality, never on message text. - New: `object: undefined` beside the binding reports nothing. The same bag without the binding reports `properties.object`, so the silence comes from the waiver and not from the row accepting `undefined`. ## Reverse verification The fix was committed first (`e994035362`). The one predicate line was then mutated back to the old behaviour through `scripts/ablation-replace.mjs` in WRAP mode, with an absolute-path `trap` restore around it. The line `return props?.[DATASOURCE_SUPPLIED_PROP] === undefined;` became `return true;`, which equals the old `strName(dataSource?.object) !== undefined` once the new early return has run. - On-disk proof: anchor count 1 → 0, mutant count 0 → 1, blob `f4793c5782` → `52df0d1033`. - The run: `Tests 2 failed | 46 passed (48)`. The two failures are the `object: 7` and `object: null` pins (`expected [] to deeply equal [ [ 'component-props-invalid', …(1) ] ]`). The `undefined` pin and the existing pin stayed green, as expected, because the old code waived both. - The restore: blob after restore `f4793c5782` equals the HEAD blob, `git diff HEAD` is 0 bytes, and `git status --porcelain` is empty. - The test imports the rule by relative path (`./validate-component-props.js`, resolved to `src/`), so no `dist/` leg applies. ## Verification (all at `e994035362`) - Build: `pnpm --filter '@objectstack/lint^...' build` through `os-verify-lock.sh`, `VERDICT command-exit 0`. - `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 src/validate-component-props.test.ts`: `Tests 48 passed (48)`. - `pnpm --filter @objectstack/lint test`: `Test Files 115 passed (115)`, `Tests 5329 passed | 5 skipped (5334)`. - `pnpm --filter @objectstack/lint typecheck`: exit 0. `tsc --noEmit` is clean. `check:test-typecheck` is OK with the ledger unchanged at 2 files, 6 errors and 2 signatures. `--listFiles` shows the edited test file is in the `tsconfig.test.json` program (1 hit) and is not a ledgered file. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 60 commands. Each was run with its exit code captured before any pipe, and the results were reconciled with `--ran`: `60 derived famil(ies) accounted for — 58 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)`. - 58 exited 0. That count includes `check:doc-authoring`, `check:nul-bytes`, `check:adr-0087-registration --base origin/main`, `check:changeset-no-major --base origin/main`, `check:empty-changeset` and `check:changeset-gate-self-tests`. - `check:docs-transcript-drift` and `check:lean-entry-closure` first answered `PREREQUISITE NOT MET`. They exited 0 after `pnpm --filter @objectstack/lint build` and `turbo run build --filter=@objectstack/objectql`. - NOT MEASURED: `check:dual-build-cjs-loads` and `check:type-check-debt`. Reason: both refuse without the whole workspace built (84 and 28 packages have no `dist/`). That build is CI's (`Build Core`, `Lint & Repo Gates`), not a local targeted run. - The four roster gates the derivation flags for directories this diff touches all exited 0: `check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing` and `check:filter-alias-parity`. - Published surface: `@objectstack/lint` ships `files: ["dist", "README.md", "CHANGELOG.md"]`. After a build, the new line (built spelling `return props?.[DATASOURCE_SUPPLIED_PROP] === void 0;`) appears once in each of `dist/index.js`, `dist/index.cjs`, `dist/runtime.js` and `dist/runtime.cjs`. The positive control `function suppliedByDataSource(issue, component)` also appears once in each. So this publishes, and it takes a `patch` changeset. - Diff size: +84 / -1 across 3 files. ## Acceptance notes - **`null` is reported.** Triage defined missing as "no key, or `undefined`". `object: null` is a present value that `z.string()` rejects, so it now reaches the author, like `object: 7`. It is pinned beside `7` so the boundary stays explicit. - **The renderer sentence is untouched.** `DATASOURCE_SUPPLIED_PROP`'s docblock says objectui's element renderers "read it FIRST". That is still false for `element:number` until objectui#10909 lands. objectui#10909 is not addressed here: its fix is that renderer, per triage's out-of-scope list. - **No per-type table.** Restricting the waiver to types whose renderer honours the binding would be a new per-type table on this rule. Triage ruled it out of this card. - **The two readers now agree on this input.** objectui's mirror of this waiver (PR objectui#10908) already refuses `object: 7` beside a binding, following the docblock. After this change, this gate refuses it as well. - **`main` moved after the base.** It gained `7fa3e3e07c` and `8cdbe0c6e5`, which touch `packages/rest` and `packages/metadata-protocol` only. Neither touches `packages/lint` or `packages/spec`, so the branch was not merged forward and the merge queue arbitrates. - **PR objectstack-ai#20420 is text-disjoint.** It adds `ComponentPropsMap` rows that this rule reads, but it does not touch this file. --- _Generated by [Claude Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
… — no queued PR left the queue on a draft conversion (objectstack-ai#20845) Fixes objectstack-ai#20764 Clause-②: no ## What changes `.claude/skills/pm-dispatch/references/platform-readings.md`, lines 41-43 only. The rewrite is net 0 lines, and the three new lines are 120 / 118 / 120 bytes (cap 120). | line | before | after | |:--|:--|:--| | `:41` | 转 draft 不是可靠的踢队手段:两向相反读数并存,处置按最坏走。 | 转 draft 不是可靠的踢队手段:本仓与姊妹仓均见已入队转 draft 仍保位照合,处置按最坏走。 | | `:42` | 本仓转 draft 同时掉 auto-merge 与队列成员资格,不自动恢复,转正后重挂;姊妹仓曾保位照合。 | 本仓转 draft 同秒掉 auto-merge 仅见于未入队时,转正后重挂;已入队者 2026-08-08、09-29 照合。 | | `:43` | 补救:转 draft 与卸载 auto-merge 都做 —— 本仓卸载 auto-merge 单独不踢队。 | 补救:转 draft 与卸载 auto-merge 都做,本仓卸载单独不踢队;两手齐做亦未见踢队(08-08、09-28)。 | The remedy on `:43` is unchanged: do both acts. No rule is added. The in-file citations are dates, because `pnpm check:pm-skill-id-lint` goes red on a `#`-number anywhere under `.claude/skills/pm-dispatch/`, and this file cites no PR number today. The PR numbers and timeline events are in this body. That is where the id-lint header puts a reading's provenance. ## The readings (re-readable: `GET /repos/objectstack-ai/objectstack/issues/N/timeline`) I ran a read-only timeline scan over objectstack-ai#4700-objectstack-ai#4900, objectstack-ai#6650-objectstack-ai#6850 and objectstack-ai#20400-objectstack-ai#20840. It looked for two patterns: a `convert_to_draft` between `added_to_merge_queue` and the next `removed_from_merge_queue`, and a `convert_to_draft` on a PR that was armed but not queued. The scan found every row below. The table is complete for those windows only, not for the whole repo. **Queued, then converted to draft.** None of the four left the queue because of the conversion. | PR | queued | draft | disable sent? | what followed | |:--|:--|:--|:--|:--| | objectstack-ai#6732 | 2026-08-08T14:01:50Z | 14:04:32Z | claimed in objectstack-ai#6799's text; no `auto_merge_disabled` event | the queue merged it at 14:38:56Z, 34 min later, still draft | | objectstack-ai#20420 | 2026-09-28T13:43:01Z | 14:02:28Z | claimed in the seat's 14:04Z comment; no event | its own group `pr-20420-3cf6449` had `Lint & Type Check` = failure at 14:01:49Z, before the draft; `github-merge-queue[bot]` removed it at 14:11:24Z, unmerged | | objectstack-ai#20442 | 2026-09-28T13:45:42Z | 14:02:50Z | claimed in the seat's 14:04Z comment; no event | the queue built a new group `pr-20442-b285508` for it at 14:11:29Z, 9 min after the draft; that group's `CI` = failure at 14:30:14Z; removed at 14:35:44Z, unmerged | | objectstack-ai#20695 | 2026-09-29T23:32:51Z | 23:57:02Z | no (the card discloses: draft alone) | the queue merged it at 00:04:21Z, 7 min later, still draft | **Armed, not queued, then converted to draft.** Each one dropped auto-merge. | PR | armed | draft | `auto_merge_disabled` | after | |:--|:--|:--|:--|:--| | objectstack-ai#4745 | 2026-08-03T01:28:46Z | 01:29:30Z | 01:29:30Z (same second) | ready, re-armed 01:30:52Z, queued, merged | | objectstack-ai#6727 | 2026-08-08T13:42:58Z | 13:44:36Z | 13:44:36Z (same second) | ready, re-armed 13:46:02Z | | objectstack-ai#6829 | 2026-08-08T23:37:05Z | 23:40:25Z | 23:40:26Z (1 s) | ready and enqueued 6 h later | Each disable event carries the converter as its actor. A timeline cannot tell whether the platform wrote it or a scripted follow-up did. The line records what was observed ("同秒"). ## Where the old reading came from - The "drops queue membership" reading first appears in `db6581a5` (objectstack-ai#4893, card objectstack-ai#4892, 2026-08-03). It cites no PR. That is the same day objectstack-ai#4745 dropped auto-merge on an armed PR that was not queued. The queue-membership half has no instance behind it. - `cd704cc4` (objectstack-ai#6799) later cited objectstack-ai#6732 for "only draft evicts; disable alone does not". In objectstack-ai#6732's own timeline the PR stays queued after the draft and is merged by the queue 34 minutes later. - The sister-repo half ("姊妹仓曾保位照合", objectui 2026-08-25) is carried over as it was. I did not re-read it. ## How this departs from the triage direction (5903760674) I did not choose these quietly. Each one is a place where a measurement changed what the direction assumed: 1. **`:42` is split by queue state, not "mixed" in the sense of opposite readings.** No queued PR in the scanned windows left the queue because of a draft conversion. The direction assumed the old half had its own measurement. It has none, and the one PR later cited for it reads the other way. What does go both ways in this repo is the effect by state: unqueued, auto-merge drops; queued, the PR keeps its place. 2. **`:43` says "not seen to dequeue", not "not measured".** Three PRs (objectstack-ai#6732, objectstack-ai#20420, objectstack-ai#20442) have both acts claimed in writing, and none was removed by them. The caveat: on a queued PR, the disable leaves no timeline event, so whether it was sent rests on the seats' own comments. 3. **`:41` loses "两向相反读数并存".** No opposite reading survived the re-read. ## Acceptance notes - **Out of scope, same family, Tier H.** `AGENTS.md` states the falsified reading as a premise in three places. Prime Directive objectstack-ai#14 says "draft is what removes queue membership, disabling alone drops only the arming". Multi-agent §7 says "flipping back to draft drops auto-merge and queue membership at once". The "State on your PR" paragraph says the draft flag "flipped back destroys auto-merge and queue membership at once". I did not touch them. They are reported to the seat. - **Eviction.** The only dequeue acts measured in this repo are these two. First, the queue's own ejection after a red group build (objectstack-ai#20420, objectstack-ai#20442). Second, a manual removal by a maintainer with no draft conversion (objectstack-ai#20797, `removed_from_merge_queue` 2026-09-30T07:33:36Z). Whether the remedy should name a different act is a question for the seat. It is not a rule this PR adds. - **Changeset.** `.claude/**` is not in any package's `files[]`, so this PR publishes nothing. `skip-changeset` is the seat's to apply. - **Governed surface, Tier S (`.claude/**`).** This PR stays a draft until a `## Contract review` record at `CONTRACT_REVIEW_TIER` exists for its head. ## Verification (head `02ea176a1`) - The 20 gates from `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` all exit 0. They are `check-closing-keyword-parity` (plus `--self-test`), `check-comment-mask-corpus`, `check-governed-queue-guard --self-test`, `check-harness-current --self-test`, `lint check:doc-formula-expressions` (after building `@objectstack/lint` and its dependency closure under `os-verify-lock.sh`), `check:agent-test-spelling`, `check:cross-package-test-inputs`, `check:doc-authoring`, `check:driver-memory-census`, `check:gitlink-declared`, `check:nul-bytes`, `check:pm-governed-merges`, `check:pm-half-states`, `check:pm-skill-id-lint`, `check:pm-skill-ratchet`, `check:refd-timer-probe`, `check:required-contexts`, `check:skill-frame-sync` and `check:watch-hint-literal`. - `pnpm check:pm-settings-deny-roster` was also run, because its roster lives under `.claude`. Exit 0. - `--ran` reconciliation: 20 derived, 20 run, 0 NOT-MEASURED. All 20 recorded an exit code. - `check:pm-skill-ratchet` printed: `platform-readings.md is 469 lines (ceiling 469; headroom 0)`, widest table row 0 bytes (pin 0). - Not measured locally: `check-required-contexts --verify-required-set` and `check-half-states --provenance`, which read the workflow event, and the four CI type-check lanes. This diff touches no TypeScript. ## 维护者速读(草稿) - **改了什么**:PM 协议参考文件 `platform-readings.md` 第 41-43 行,关于「已入队 PR 转 draft 能否踢出合并队列」的三条平台读数。行数不变。 - **为什么改**:旧文说本仓转 draft 会掉出队列。回查时间线后发现,本仓 4 个已入队后转 draft 的 PR 都没有因此出队。其中 2 个带着 draft 状态被队列直接合入,另外 2 个是在自身队列构建变红之后才被移出。旧说法唯一引用的实例,时间线恰好反向。转 draft 会掉 auto-merge,这一点只在「未入队」时成立。 - **风险与代价(含回滚)**:只改文档读数,不改任何规则或代码。补救仍是两手都做。风险在于读者以为有可靠的踢队手段,而实际没有。回滚就是 revert 这一个提交。 - **席位意见**: - **你要做的**:无需操作,由席位按 Tier S 复核后落地。若要让 `AGENTS.md` 里的同一旧说法一并修正(Tier H),需要你点头另开 PR。 --- _Generated by [Claude Code](https://claude.ai/code/session_01KTZmMfzVzjNvyaLyQ8mHvg)_ Co-authored-by: Claude <noreply@anthropic.com>
…ld, and both forms' sections a page-block section shape (objectstack-ai#21464, S-forms) (objectstack-ai#21742) Part of objectstack-ai#21464 Clause-②: yes (narrowing) ## What this does The S-forms stage of the `ComponentPropsMap` `z.unknown()` close-out. It executes the maintainer's rulings on forks 2 and 3 of the decision card objectstack-ai#21704 (ruling record `5978663135`, batch objectstack-ai#276, letters B and B), per the claim `5979114945`. Read points are at the `.objectui-sha` pin `2e818d0b51ec`, under objectui `packages/` unless named. Every cited reader file is byte-identical at objectui `main` `fd060f076`, and the pin is an ancestor of that `main`. | row · member | was | now | |:--|:--|:--| | `object-form` · `customFields` | `z.unknown()` | a list of the closed runtime form field (module-private, built once): camelCase, keyed by `name`, only the members the form draws; its `options` entry is the closed runtime option the form's option controls draw | | `object-form` · `sections` | `z.array(z.unknown())` | a page-block section shape of its own (module-private, built once): the form view's section keys plus the three entry arms the form reads, canonical spellings only | | `object-master-detail-form` · `sections` | `z.array(z.unknown())` | the same section instance (its parent half hands `sections` to the form verbatim, `plugin-form/src/MasterDetailForm.tsx:1692`) | The stored form view's `FormSectionSchema` (`view.zod.ts`) is not edited. Forks 1, 4 and 5 keep their enumeration-pin lines. objectstack-ai#21704 is not addressed beyond forks 2 and 3. ## The fix round (head `716f4c6522`) The at-tier contract review of `b473439752` (record `5980612890`) answered **FAIL** on one point, and the seat's order `5980628111` took its remedy 1. This round, by the session `session_016tKoy8NJa35Yih1FdzrVmn` (the director seat's dispatch), changes: 1. **`options` on the runtime form field is re-typed.** It took the form view's option (`FormSelectOptionSchema`), whose `value` is a stored field's lowercase identifier, so the shipped `object-manager` dialog's options (`{ label: 'Box', value: 'Box' }`, objectui `plugin-designer/src/ObjectManager.tsx:244`-`:245`) were refused. It is now a closed option of the keys the form's option readers draw, measured at the pin (below): `label`, `value` (`string | number | boolean`, as objectui's runtime option declares on purpose, `types/src/zod/form.zod.ts:142`-`:145`), `description` and `visibleWhen`. Every other ruled member is unchanged. 2. **The census was re-run and every quote of it corrected** (this body, the changeset, both D3 entries). The first run read a `.map` over a constant list as non-static and so never parsed the object manager's options; the instrument now evaluates it. 3. **The Dogfood Regression Gate's red is fixed.** `expression-conformance.test.ts` reported four UNCLASSIFIED surfaces (`buildObjectFormRuntimeField.visibleWhen` / `.readonlyWhen` / `.requiredWhen`, `buildObjectFormSection.visibleWhen`). They are classified in `expression-conformance.ledger.ts` as row `cel-form-block-field-rule`, and the new option's `visibleWhen` as row `cel-form-block-option-visible`, in the shape of the existing `ui/component.zod.ts` page-block rows (objectstack-ai#20420 is the precedent for a spec-lane edit of that ledger). 4. **Acceptance notes** carry the review's ③ notes 4 and 5, with carrier objectstack-ai/objectui#11615. The widget-only keys question is answered A by the record: the field stays closed at the measured set. ## Fork 2 B — the runtime form field **How a member reaches a draw.** `customFieldsMerge.ts:78-108`, called from `ObjectForm.tsx:1178-1185` and from every other `formType` arm, merges `customFields` over the generated fields. A member naming a generated field replaces its whole definition, and any other member is appended. A section's inline entry is drawn as it stands (`sectionFields.ts:369-370`). Either way the renderer hands the field to its widget as the metadata carrier (`components/src/renderers/form/form.tsx:3171`, `field.field || field`). **The draw set, measured from the readers, member by member.** These are not transcribed from objectui's `FormField`. | members | read at | |:--|:--| | `name`, `label`, `description`, `type`, `required`, `disabled`, `readonly`, `hidden`, `validation`, `visibleWhen`, `readonlyWhen`, `requiredWhen`, `colSpan` | `form.tsx` `renderFormField` destructure `:2675-2693`; `hidden` `:2696`; the three rules `:2732`; `validation` `:2795`; `colSpan` `:2988` | | `widget`, `multiple` | `:2915-2918` (`widget` ahead of `type`, arity from `multiple`) | | `options`, `dependsOn` | `:2934-2944` (the cascading option list) | | `placeholder`, `inputType` | `:3185`, `:3174` (the built-in input's `type`) | | `span`, `colSpan` | `plugin-form/src/autoLayout.ts:162-172` | | `group` | `plugin-form/src/fieldGroups.ts:52` (the object's field-group sections are derived over the drawn fields) | | `rows` · `accept`, `multiple` · `dimensions` · `reference` · `min`, `max` | `fields/src/widgets/TextAreaField.tsx:102` · `FileField.tsx:147-148` · `VectorField.tsx:11` · `LookupField.tsx:326` · `NumberField.tsx:88-89` | | `minLength`, `maxLength`, `pattern` | the built-in input and textarea branches (`form.tsx:4080`, `:4146`; `pattern` rides onto the native control) | | `returnType` · `summaryOperations` · `columns` | `FormulaField.tsx:22` · `SummaryField.tsx:15` · `GridField.tsx:588-589` | **The option, measured from the option readers.** Every option control the form reaches reads the same four keys: | key | read at | |:--|:--| | `label`, `value` | the built-in select (`form.tsx:3975-3977`, the pick mapped back to the authored value by `matchOptionValue`, `:3955`) and the four option widgets `SelectField`, `MultiSelectField`, `RadioField`, `CheckboxesField`, which draw `optionDisplayLabel` (`core/src/evaluator/optionRules.ts:173`) and stringify `value` only at the control | | `visibleWhen` | the cascade: `resolveCascadingOptions` → `resolveVisibleOptions` (`optionRules.ts:97-110`), from `form.tsx:2940` and from each widget's `useCascadingOptions` | | `description` | a lookup field's static options: its typeahead searches the description beside the label (`fields/src/widgets/LookupField.tsx:705-706`) | `color` (drawn only by the list and grid select cell renderer, never by a form option control), `default`, and objectui's `disabled` (read only by the standalone `select` node renderer, `components/src/renderers/form/select.tsx:91`, which a form field never reaches) and `icon` (read by no option control on the form path) are refused, each with a prescription. Aliases name `label` (`text`, `name`, `title`), `value` (`key`, `id`) and `visibleWhen` (`visible`, `showWhen`). **Value types.** Where this package already declares a member, its value schema is taken by reference: the object field's `FieldSchema` members for `rows`, `accept`, `dimensions`, `reference`, `minLength`, `maxLength`, `returnType`, `summaryOperations` and `columns` (`inlineColumns`); `EvaluatedExpressionInputSchema` for the three `*When` rules; and the object field's `dependsOn` list beside a bare name. The option's `label` and `description` are the object field's option's own (`SelectOptionSchema.shape`, pinned def by def). Its `visibleWhen` is the evaluated predicate declared on the option itself, because the object field's option is also re-checked by the server on write and an inline option never is. `group` takes the field-group key grammar (`SectionGroupKeySchema`). `label`, `description` and `placeholder` are plain strings, because the renderer draws each as it stands and an inline locale map would be a React child. `validation` is `{ required?, minLength?, maxLength?, min?, max? }`. Each bound rule is `{ value, message }`. `required` is a string, because the renderer deletes the rule and reads only its message (`form.tsx:2843`, `:2847`). **Read, and refused anyway, each with a prescription:** - the `grid` widget's eight snake_case keys (`min_rows`, `max_rows`, `allow_add`, `allow_delete`, `allow_reorder`, `total_field`, `add_label`, `sort_field`), by the ruling. Their carrier is objectstack-ai/objectui#11610. - `visibleOn` (`form.tsx:2767`) and the legacy `condition` (`:2717`), two more spellings of the conditional-visibility predicate. ADR-0089 D1 makes `visibleWhen` the single canonical key. The only measured `visibleOn` writer is a type-level test that never draws (below). - `id`: the renderer keys the row by `id ?? name` (`:2898`), and `name` is already unique in the drawn list. - `fields`: the member claim of the section-divider row the form builds from a section, not a member of a field. **The census's two keys outside objectui's 45 members:** - `group` is read (above), so it is typed. - `defaultValue` is not read, so it is refused. The form opens on `initialValues` and on the object's declared defaults (`schemaDefaults.ts`), and objectui's `initialRecordMerge-9760.test.tsx` row 7 pins that an inline `defaultValue` seeds nothing. The prescription moves the value into the block's `initialValues`. ## Fork 3 B — the page-block section shape - **Section keys, read:** - `name` and `label`: the heading (`ObjectForm.tsx:1693` and the per-`formType` maps at `:412`, `:492`, `:520`, `:556`, `:590`). - `description`. - `collapsible` / `collapsed` (`resolveSectionCollapse`, `:1702`). - `visibleWhen` (the divider row's predicate, `:1720`). - `columns` (`:1731`). - `pane` (`SplitForm.tsx:445`). - `group` (`sectionGroups.ts`). - `fields`. That is exactly `FormSectionSchema`'s key set, and objectui's `ObjectFormSection` declares the same (`types/src/objectql.ts:1497`). The form view's group-reference rule rides with it (`sectionGroupReferenceRefinement`, the same derived-key lists). - **Canonical spellings only.** A page block's `properties` is never parsed on the way to the form, so the form view's two folds do not run there, and the form reads only `visibleWhen` off a section and only a numeric `columns` (`clampCol`, `:1599`). A section `visibleOn` and a string `columns: '2'` were therefore dropped in silence. Both are refused with the canonical spelling. `label` is a plain string, because the form draws the heading as it stands. No member carries a schema default. - **The three entry arms:** - **A field name.** - **The form view's `{ field }` entry.** Its members ARE `FormFieldSchema`'s object half, pinned def by def, with three differences that follow from how the page block reaches the form: - its deprecated `visibleOn` is refused, as above (the form reads `visibleWhen ?? visibleOn`, `sectionFields.ts:455`); - `label`, `placeholder` and `helpText` are plain strings (copied onto the drawn field as they stand, `:386-388`); - `span` drops the default the form view fills. Its sub-fields are this same entry, recursively, so the canonical rule holds at every depth. - **The inline runtime form field.** This is fork 2's instance, by identity (pinned). - **Both rows share one section instance** (pinned by identity). A bare CEL predicate parses to its `{ dialect, source }` envelope, as on every evaluated slot. So `object-form`'s input and parsed types now differ, and the row leaves the type-alias pin's isomorphic family for an `ObjectFormPropsParsed` alias (ADR-0122), as `object-master-detail-form` did on objectstack-ai#20928. That alias is the one new export. ## The census (re-run in the fix round) **The instrument** is a TypeScript-AST walk over `.ts` `.tsx` `.js` `.jsx` `.mjs` `.cjs` `.json` and fenced code in `.md` / `.mdx`. It finds: - **Pass 1:** object literals naming either block by `type` (flat or in `properties`), literals annotated or asserted as `ObjectFormSchema` / `MasterDetailFormSchema`, the block's React component's `schema` prop, and direct parses through the row. A member that is a parameter of the enclosing helper is resolved to the argument in that position at every same-file call site. - **Pass 2:** every object literal carrying `customFields` or `sections` in a file that names a form block. Values resolve through same-file constants and spreads, and, new in this round, through a `.map` over a constant list (an arrow with identifier parameters and an expression body). The first run read such a list as non-static, so the object manager's option lists were never parsed: that miss is the review's ① 7. Every static value was parsed through this head's rows, union arms judged by their best arm. Every value with a non-static part, and every refusal, was read by hand. **Positive control.** The same extracted values, parsed through `b473439752`'s `component.zod.ts`, refuse `ObjectManager.tsx:239` members 4 and 5 (`icon`, `group`) at every `options.N.value` (`invalid_format`, the identifier rule). Through this head's rows they parse. | corpus | `customFields` | `sections` (`object-form` · `object-master-detail-form`) | |:--|:--|:--| | objectstack `316be321ef` (the previous merge base; the three commits `main` gained since add no writer) | 0 | 3 · 0. All parse: `examples/app-showcase/src/ui/pages/new-project-wizard.page.ts`, `packages/lint/src/validate-component-props.test.ts`, `packages/spec/src/ui/component.test.ts`. Field names only. | | objectui pin `2e818d0b51ec` | 31 parse, 2 refused (probes), 11 non-static | 102 with a static part parse, 2 refused (probes) · 4 parse; 26 fully non-static, read by hand | | objectui `main` `fd060f076` | as the pin, plus 2 test values that parse | as the pin, plus 1 test value that parses | | hotcrm `4054ec2680` | 0 | 0 | | cloud `2205b53010` | 0 | 0 (one form view, which these rows do not judge) | **`customFields` at objectui:** - **The 31 that parse:** - the block literals: `guideCrudAppRenders.test.tsx:170`, `objectFormCustomFieldsMembers-8071.test.tsx:191` and `submitTargetRefusal.test.tsx:155`, `:317`; - the designer's `ObjectManager.tsx:239`, the registered `object-manager` component's modal form. Its labels are `t(...)` calls (non-static); its `icon` and `group` options are now evaluated from `ICON_OPTIONS` and `OBJECT_GROUPS` (`'Box'`, `'ShoppingCart'`, `'Custom Objects'`, …) and parse as runtime option values; - 26 helper and embeddable-form arguments, which `EmbeddableForm.tsx:567` hands to the form as `customFields`. Among them: the merge pins, the sections-and-members pins, the mobile fullscreen pin with `rows` / `placeholder` / `field:textarea`, the field-group row with `group`, and `content/docs/guide/public-forms.md`. - At objectui `main`, `apps/console/src/__tests__/objectname-neither-hint-11605.test.tsx:78` and `:84` (a `{ name, label, type }` field each) parse as well. - **The 2 refused, re-read by hand, both probes and not drawn values:** - `types/src/__tests__/p1-spec-alignment.test.ts:348`: a type-annotated literal whose `visibleOn: '${data.industry != null}'` is never rendered and is not CEL. - `plugin-form/src/__tests__/initialRecordMerge-9760.test.tsx:236`: the `memo` member whose `defaultValue` the test's row 7 pins as seeding nothing. - **The 11 non-static:** run-time hand-offs (`EmbeddableForm.tsx:567`, `ObjectView.tsx:2599`, the arm forwards in `DrawerForm`, `ModalForm`, `ObjectForm`, `SplitForm`, `TabbedForm`, `WizardForm`) and helper parameters. No other `customFields` value has a non-static `options`. **`sections` at objectui:** - **Two block writers are refused, re-read by hand, both probes.** objectui's own renderer test (`plugin-form/src/__tests__/formSectionGroupReference-7051.test.tsx:270`, `:307`) states that this door refuses both shapes at parse: a section declaring neither `fields` nor `group` ("off-spec, so reachable only from a programmatic SDUI caller"), and a group-owned `label` / `collapsible` beside `group`, which the renderer reports and ignores. These are the form view's own group-reference rule. - **The parsing values include:** - the field designer's inline fields (`plugin-designer/src/FieldDesigner.tsx:344`: `name`, `label`, `type`, `required`, `placeholder`, `disabled`, `options`, `visibleWhen`). Its one non-static `options`, `flatTypeOptions`, was read by hand: `{ label: FIELD_TYPE_META[ft].label, value: ft }` per field type, two keys the option declares; - the plugin-form README's data-source-free wizard (inline fields with `inputType`); - the one `{ field }` entry (`cli/src/__tests__/spec-vocabulary-hint.test.ts:54`); - group, collapse, `columns`, `pane` and `visibleWhen` sections; - at objectui `main`, `objectname-neither-hint-11605.test.tsx:210` (an inline field in a section). - **The 26 fully non-static values** (23 on `object-form`, three on the master-detail form) are the form's own run-time hand-offs (`ObjectForm.tsx:386`, `MasterDetailForm.tsx:1692`, `DrawerForm.tsx:878`, `ModalForm.tsx:1062`, `ViewPreview.tsx:150`, `ObjectView.tsx:2603`) and test-helper parameters. Read by hand, they use declared keys only, with one exception: `sectionStyleKeysRetired-13626.test.tsx`, objectui's probe that a retired `className` / `gridClassName` reaches nothing (refused here, as objectui's own type refuses it). - **Pass 2's refused section values** are `record:details`, detail-view and object-view form-slot sections, which these rows do not judge. No measured working writer is refused under this head's shapes. ## Changes - **`packages/spec/src/ui/component.zod.ts`:** - the runtime form field, its `validation` block, its option (new in the fix round), the form view's `{ field }` entry arm and the section shape — five module-private factories, each built once (a factory and not a `lazySchema`, for the alias-integrity walk's reason, as `objectGanttMarker()`); - the two rows' members; - `ObjectFormPropsParsed`; - the imports (`FormFieldSchema`; `FieldSchema` and `SelectOptionSchema` from `../data/field.zod`). - **`packages/spec/src/ui/component-props-unknown-members.pin.test.ts`:** - the `customFields` and both `sections[]` fork lines leave; - the predicate ASTs inside the new shapes join the expression-AST lines (`customFields[]` / `sections[].fields[]` `visibleWhen` / `readonlyWhen` / `requiredWhen`, an option's `visibleWhen`, a grid column's two rules, and `sections[].visibleWhen`); - the roll-up `summaryOperations.filter{}` gets its own `shared` reason (`FILTER_CONDITION`: a query `where` over the child object's fields, judged by the filter schema's own refinement); - the `fork` stage's text drops the form field. - **`packages/spec/src/ui/component-form-custom-fields-sections-typed.pin.test.ts` (new):** - §1: 19 byte-identical census and lit-control parses (the fix round adds runtime option values `'Box'`, `'ShoppingCart'`, `1`, `2`, `true`, `false`, and an option's `description` and `visibleWhen`), the shipped `object-manager` dialog's inline fields byte-identical, the predicate envelope (an option's bare `visibleWhen` included) and the absent case. - §2: 35 refusals by `code` and `path` (the fix round adds an undeclared option key, an option `color`, an option `default`, an option with no `label`, and an object `value`), plus the prescriptions, the option's included. - §3: the exact draw set, the option's exact key set (`description`, `label`, `value`, `visibleWhen`; not the form view's option; `label` and `description` def-identical to the object field's option; `value` accepts a string, a number and a boolean and refuses `null`, `undefined`, an object and an array), no snake_case key, the form view's section keys minus `visibleOn`, the `{ field }` arm's members def by def, the inline arm's identity and the shared section instance. - §4: the two D3 ids. - **`packages/qa/dogfood/test/expression-conformance.ledger.ts` (fix round):** rows `cel-form-block-field-rule` (the inline field's three rules and the section's `visibleWhen`: objectui `resolveFieldRuleState` → `evalFieldPredicate`, fail-soft-log) and `cel-form-block-option-visible` (the option's `visibleWhen`: `resolveCascadingOptions`, fail-soft-log, UI gating only). - **`packages/spec/src/type-alias-convention.pin.test.ts`:** the `ObjectFormPropsSchema` Iso pin leaves (773 → 772), with its receipt. This file is outside the claim's list. It reds otherwise, and the convention's route is to declare the alias and delete the pin. - **`packages/spec/dropped-refinements.baseline.json`:** five new sites. These are the refinements the new shapes carry by reference, which `z.toJSONSchema` drops: the section's group-reference rule, the inline grid column's rules, and the roll-up filter's comparand refinement. `droppedRefinementSites` goes 665 → 670. The fix round's option adds no site (the build's own check passes unchanged). This is hand-edited as the ledger requires, and no rule is weakened. - **The ADR-0087 kit:** - `18.ui-object-form-custom-fields-typed.ts` and `18.ui-object-form-sections-typed.ts` (new D3 entries; the fix round corrects both census quotes and the option text, and the sections comment names the `formSectionGroupReference-7051` probes as the refused values); - `registry.ts`: the semantic region is regenerated, and the step-18 rationale fragments sit at orders **78 and 79**. - There is no D2 conversion and no `RETIRED_KEYS_BY_MAJOR` row: page-component `properties` is not parsed on the save or load path, and the refused values are nested member values. - **Generated:** `content/docs/references/ui/component.mdx` (two member rows and three nested-shape tables; the `options` row now prints the runtime option), `docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md` (`ui/` 198 → 204 and `component.zod.ts` 68 → 74, the six new strict sites), `api-surface/ui.json` and `export-origins/ui.json` (`ObjectFormPropsParsed`). - **`.changeset/21464-component-props-form-custom-fields-sections-typed.md`:** `@objectstack/spec` `minor`, a BREAKING banner, the `Clause-②` line, the ADR-0087 `registered` marker naming both ids, FROM → TO (two option rows added) and the census, corrected. ## Measurements These are at head `716f4c6522` (merge base `ff29410ed2`: `origin/main` was merged in through `scripts/pm/os-regen-merge.sh` with no conflict, and after a rebuild every generated artifact checks up to date) unless named. `component.zod.ts` is blob `373d03336dd9` from `c04a77716c` to the head. Heavy runs went through `scripts/pm/os-verify-lock.sh`, each `VERDICT command-exit` read, and every exit code was captured before any pipe. - **Red first, the fix round.** The new pin cases were committed alone (`1b08dec21d`) and run against the unfixed rows: 9 failed, 61 passed (70). The runtime option values (`'Box'`, a number, a boolean) and the object-manager dialog were refused (the old option refused even `value: 'a'`, `too_small` under the identifier rule), and an option `color` was accepted. On the fix (`c04a77716c`): 70 passed, and an undeclared option key is still refused (`unrecognized_keys` at `customFields.0.options.0`). - **Red first, the stage** (at `b473439752`). On the published `@objectstack/spec@17.6.0` (the npm tarball, `ComponentPropsMap[row].safeParse`), all 16 junk values are ACCEPTED: `customFields: 42`, a member with no `name`, a misspelled member, `visibleOn`, `defaultValue`, `min_rows` and `validation.required: true`; on `object-form`, `sections: [42]`, a section `visibleOn`, `columns: '2'`, `className`, a section with neither `fields` nor `group`, a `{ field }` entry's `visibleOn` and an inline entry's unknown key; on `object-master-detail-form`, a section `visibleOn` and `[42]`. All 16 are refused with the code and path the pins assert. - **Tests:** - `pnpm --filter @objectstack/spec test`: Test Files 614 passed (614); Tests 18285 passed, 1 todo. - `pnpm --filter @objectstack/spec typecheck`: exit 0. `check:test-typecheck` OK at 52 files / 246 errors / 135 signatures held. - `pnpm --filter @objectstack/lint test` (its closure from the full build below): Test Files 119 passed, Tests 5627 passed. - `pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/expression-conformance.test.ts`: Test Files 1 passed, Tests 7 passed. This is the test the Dogfood Regression Gate (3/3) failed on at `b473439752`. - **Public door.** `validateComponentProps` over the built lint and spec: a stack shaped like the object-manager dialog (`'Box'`, `'Custom Objects'`, `1` and `true` option values) reports 0 findings; an option `color` and an option `bogus` report `component-props-unknown-key` at `customFields.0.options.0.color` (with the prescription) and `customFields.0.options.1.bogus`. - **Ablation, the fix round** (at `e948519edd`; `component.zod.ts` blob `373d03336dd9`, the head's). The driver wraps `node scripts/ablation-replace.mjs` in its own `EXIT INT TERM` trap on the absolute path, with the HEAD blob as the restore target and an empty hash read as failure. The pins import `./component.zod` from source and the conformance test scans source, so no build or dist preflight is owed. | leg | mutation | blob after mutation | result | |:--|:--|:--|:--| | option element | `options: z.array(buildObjectFormRuntimeOption())` → `z.array(z.unknown())`, anchor x1 → x0 | `30c27aa10dd2` | red, 8 failed / 62 passed: the five option refusals, the option prescriptions, the option key set, and an option predicate's envelope | | discovery control | the ledger row `cel-form-block-option-visible` with its cover emptied | `1009587f14b8` | red, 1 failed / 6 passed: `UNCLASSIFIED surface — add a ledger row (ADR-0060): ui/component.zod.ts:buildObjectFormRuntimeOption.visibleWhen` | After each leg the blob equals HEAD and `git diff HEAD` is empty. The stage's three legs (at `3c4c7ce1a8`: `customFields`, `object-form` `sections` and `object-master-detail-form` `sections` each to `z.unknown()`, red 24, 21 and 5 failed) cover the members this round does not change. - **Derived gates.** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 114 commands at `716f4c6522` (14 paths, +1375 / −75, under the 5000 threshold). All 114 ran at that head. On the first pass 112 exited 0, and `check:skill-examples` and `check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET: this fresh worktree had no package builds); both exited 0 after the full `turbo run build` (72 tasks, 71 of them from the shared turbo cache, exit 0). The `--ran` reconciliation reads: 114 derived, 114 run, 0 NOT-MEASURED, 0 UNRUN. - **Named extras, with this body as the `pull_request` payload:** `check-changeset-no-major.mjs --base origin/main --event` exit 0 (declaration line `Clause-②: yes (narrowing)`, arm `narrowing`); `check-adr-0087-registration.mjs` exit 0 (`registered ui-object-form-custom-fields-typed, ui-object-form-sections-typed`); `check-empty-changeset.mjs` exit 0. - **Narrowed lint.** `eslint --no-inline-config --format json` over the eight changed TS files: 8 files, 0 errors, 0 warnings. - The population is read from eslint itself: each of the eight resolves a config, and the six other changed files (Markdown, MDX, JSON) answer "File ignored because no matching configuration was supplied" (`--print-config` prints `undefined`). - Invariance: `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, its own note at about line 328), so this diff moves no untouched file's verdict. - The full `pnpm lint` is CI's. - **Control bytes.** A self-scan of the 14 changed files found no hit, and `check:nul-bytes` (a derived gate) is green. - **NOT MEASURED:** - the Console Pin Gate and the full `pnpm lint`. Reason: CI-owned; objectui was read at the pin and at `main`, and not built against this spec. - CI on this head: not waited on. ## Acceptance notes (not filed) - **The React tier still publishes the older surfaces.** The React `ObjectForm` block's overlay types `customFields` as `any[]`, and its `sections` come from `FormViewSchema` (`packages/spec/src/ui/react-blocks.ts`, published as `skills/objectstack-ui/references/react-blocks.md`). The React tier is a separate, hand-declared contract for programmatic mounts, and a React prop is not authored metadata. Carrier: none. - **Two row-level rules are not carried.** The form view refuses `pane` off a split form and `group` / `visibleWhen` / a `true` collapse pair on a wizard step, in `FormViewSchema`'s own refinement. These are row-level rules coupling `formType` to the sections, not section-shape rules, so the ruled shape does not carry them. On a page block, objectui answers a wizard `group` with an empty step and a warning. Carrier: none. - **Three wider field sets are not declared:** - **Field-widget carrier reads** outside objectui's 45-member `FormField` vocabulary: `scale` and `step` (`NumberField`, `SliderField`), `language` (`CodeField`), `maxSize`, `capture` and `crop` (`FileField`, `ImageField`), and `LookupField`'s `displayField` / `idField` / `lookupColumns` / … No census writer uses one, and objectui's own strict face declares none. The record answered A: the field stays closed at the measured set and grows when a writer appears. - **The `type` / `widget` namespace rule** objectui's authoring face enforces (a colon-qualified id must name `field:`) is not carried; both are strings here. Carrier: none. - **A union refusal reads as a value verdict at the door.** A section entry refused by every arm, for example a `{ field }` entry's `visibleOn`, prints all three arms' refusals rather than the unknown-key rule id. Each arm's prescription is in the message, but the lint's single-arm routing needs exactly one arm with key-only issues. Carrier: none. - **The default `simple` arm skips an inline section entry its pool lacks** (the review's ③ note 4). On `formType: 'simple'` (the default), `ObjectForm` passes its field pool into section resolution (`plugin-form/src/ObjectForm.tsx:1617`-`:1627`), and `buildSectionFields` `continue`s on an entry the pool lacks (`sectionFields.ts:480`-`:484`). So a self-contained inline entry whose `name` neither the object nor `customFields` declares is skipped there, while the tabbed, wizard, split, drawer and modal arms resolve with no pool and draw it; objectui's own inline-sections test runs every arm except `simple` (`plugin-form/src/submitTargetRefusal.test.tsx:116`, `:331`-`:358`). The spec admits the inline arm on every `formType`, as fork 3 B rules; the gap is the renderer's and does not change the ruled shape. Carrier: objectstack-ai/objectui#11615. - **objectui's `ObjectFormSection.fields` TypeScript type has no `{ field }` arm** (the review's ③ note 5). It is `(string | FormField)[]`, and `FormField` requires `name`, so objectui's own TS face has no arm for the form view's `{ field }` entry that `sectionFields.ts:373-455` draws (its zod mirror takes `z.any()` there). Carrier: the same card, objectstack-ai/objectui#11615, which names it. - **An empty-string option `value` parses, and a select may refuse it** (the fix round's out-of-scope note, carried here at the at-tier review's request). The runtime option's `value` is `string | number | boolean`, as objectui's runtime option declares, so `''` parses. Neither the built-in select (`form.tsx:3976`) nor `SelectField` (`:212`) guards an empty item value, and Radix Select refuses one. Refusing `''` on the shared element would over-refuse radio and checkbox options, and no writer authors it. Inference, not reproduced. Carrier: none. ## Deviations - **The commit trailers follow AGENTS.md** (the model-free trailer pair), not the harness reminder, which names a model. This body was edited through the relay's `issue_patch` (`PATCH objectstack-ai/issues/21742`) and sent without a footer. The first edit was stored byte-identical (32375 bytes sent and stored, read back over REST), so no footer was appended and none is carried; the attribution is the session named under the fix round above. - **Two files outside the claim's file list are edited,** each because the change reds it otherwise: `type-alias-convention.pin.test.ts` (the ADR-0122 route) and `dropped-refinements.baseline.json` (the ledger's own refusal printed the corrected entries). The fix round adds a third, `packages/qa/dogfood/test/expression-conformance.ledger.ts`, by the seat's order. - **The `{ field }` arm reads `FormFieldSchema`'s object half off its pipe** (`.in.shape`), because `view.zod.ts` is not on the file surface and exports no base. One place does this. §3 pins every reused member's def against the form view's, so the read cannot drift unnoticed. - **The option's `visibleWhen` is a new declaring position, not the object field's option's by reference.** That one's describe and its ledger row (`cel-select-option-visible`) state the server's re-check on write, which an inline option never gets. So the option declares its own predicate, and the ledger carries a fifth row beyond the four the order named. - **The census instrument is this run's own,** extended in the fix round with `.map` evaluation. Its counts differ from the S-objectui-held stage's (27 `customFields` values, 7 inline section entries), because pass 2 and the designer's code-composed nodes are counted here. --------- Co-authored-by: Claude <noreply@anthropic.com>
…imeline items the entry kind its variant selects, and action:group / action:menu members a closed inline action (objectstack-ai#21464, S-final) (objectstack-ai#21764) Fixes objectstack-ai#21464 Clause-②: yes (narrowing) ## What this does The S-final stage of the `ComponentPropsMap` `z.unknown()` close-out, and its last. It executes the maintainer's rulings on forks 1, 4 and 5 of the decision card objectstack-ai#21704: fork 1 letter B (ruling record `5978663135`, batch objectstack-ai#276), forks 4 and 5 letters B and A (record `5979239990`, batch objectstack-ai#277), per the claim `5981629450`. Read points are at the `.objectui-sha` pin `2e818d0b51ec`, under objectui `packages/` unless named. Every cited reader file is byte-identical at objectui `main` `2abec3a96` (the pin is an ancestor of it; `plugin-timeline/src/renderHandoff.ts` and `types/src/data-display.ts` differ there in comments and a typed click slot only, not in either arm). | row · member | was | now | |:--|:--|:--| | `object-metric` · `drillDown.report` | `z.unknown()` | `ReportSchema`, by reference | | `object-timeline` · `items` | `z.array(z.unknown())` | a closed entry (module-private, built once) of objectui#6356's two arms, paired with the row's `variant` by a row refinement | | `action:group` · `actions[]` | `z.record(z.string(), z.unknown())` per member | a closed inline action (module-private): `action:button`'s keys by `type`, plus the inline button's `size` | | `action:menu` · `actions[]` | the same | the same member without `size` | With these three typed, the enumeration pin's `fork` lines are gone, the `fork` stage is gone with them, and a new §6 pins the close-out: no stage is declared and no ledger line is `staged`. ## Fork 1 B — `drillDown.report` is `ReportSchema` - **The read.** The tile hands `report` to the shared drawer verbatim (`plugin-dashboard/src/ObjectMetricWidget.tsx:742`). `DrillDownDrawer.tsx` draws it as a `report` node when `isDatasetBoundReport` holds (`:92`, used at `:115`) — a non-empty `dataset`, or a `joined` report with a block that binds one — joining the metric's filter into the report's own `runtimeFilter` (`:150-153`). Any other value lists the records. objectui types the member as this package's `ReportSchema` author input (`SpecReportInput`). Both files are byte-identical from `ab1879721595`, where the fork was measured. - **The premise, re-measured: admitted implies drawn.** Since objectstack-ai#21702 (`ed15448217`) the joined arm refuses every block with no `dataset`, and every other type needs `dataset` and `values`. The new pin's §4 restates `isDatasetBoundReport` from the pin and checks it over 14 candidate reports: every report the member admits is drawn (zero exceptions), and the four writer shapes are admitted (a lit control). - **The remaining difference runs one way only, and is pinned as such.** The drawer also draws four incomplete reports the member refuses: a joined report with only some blocks bound, a joined report with a container `dataset`, a report with no `name` / `label`, and a summary report with no `values`. No measured writer authors any of them. - **`drillDown`'s other members, stage 5's.** `enabled`, `title`, `target`, `columns` and `maxRows` stay the chart drill-down's by reference, and `filter` / `mode` stay refused by name. The block's describe and docblocks now say `report` is `ReportSchema`. The metric family pin's §3 key set is unchanged. - **Parsed type.** `ReportSchema`'s defaults (`type`, `drilldown`) materialize on parse, so `ObjectMetricPropsParsed` now also differs from the authored type on `drillDown.report`. Its docblock says so. A page component's `properties` is not parsed on the way to the renderer, so the drawer still reads the report as written. ## Fork 4 B — the timeline entry, both arms closed - **The arms, read at the pin.** The feed branches read `time`, `title`, `description`, `variant`, `icon`, `content` and `className` (`plugin-timeline/src/renderer.tsx:1612-1659` vertical, `:1697-1716` horizontal). The gantt branch reads a row's `label` (`:1899-1900`) and `items` (`classifyGanttRows`, `:617-621`; drawn at `:1908`), and each bar's `startDate`, `endDate` (`:1909`), `variant` (`:1916`) and `title` (`:1918`, `:1921`). That is exactly objectui's `TimelineFeedItem` (seven keys) and `TimelineGanttItem` / `TimelineGanttItemBar`, taken as ruled. - **One entry shape, not a union.** The entry declares every member of both arms, each optional, closed against anything else — objectui's own `TimelineItemSchema` shape. A union would fold an off-shape bar's issue into one `invalid_union` at the entry, as objectui's docblock records. - **The row refinement** (`objectTimelineItemsFitVariant`, restating objectui's `timelineItemsFitVariant`) pairs each entry with the arm the row's `variant` selects (absent means `vertical`). It refuses, each at the key it names: the arm's required key absent (`title` on a feed entry, `label` on a gantt row), or a key only the other arm declares. The arm key lists are read off the two shapes, never restated. - **`content` is opaque**, by the ruling: `z.unknown()`, its describe says "Held opaque", and the enumeration pin records it under a new `opaque` reason naming the ruling record (§2 checks both). It is not a slot position. - **Gantt-bar dates** are a string or a finite number (`z.number()` refuses `Infinity` and `NaN`). The `Date` arm is left out, by the ruling. - **The record-composed keys** (`color`, `startDate`, `endDate`, `group`, `meta` on an entry) are refused, each with what an authored entry writes instead. `date` is an alias for `time`. ## Fork 5 A — the container members, measured from the reads - **The read set, measured** from `action-group.tsx` (`InlineActionButton` `:91-174`, `DropdownActionItem` `:188-247`, `handleExecute` `:306-385`), `action-menu.tsx` (`ActionMenuItem` `:92-150`, `ActionAutoTrigger` `:177-191`, `handleExecute` `:244-334`), `static-params.ts` (`:142-148`, `:172-183`) and `auto-trigger.ts` (`:96`). It is not transcribed from `UIActionSchema`. All four files are byte-identical from `ab1879721595` and at objectui `main`. - Drawn: `label` (or `name`), `icon`, `variant` and `tags` (`separator-before`, the one tag read, `:224` / `:408`). - Gated: `visible` and `disabled`. - Placed: `locations` (`actionRendersAt` on the group, `:304`). - Forwarded to the runner: `type`, `name`, `label`, `description`, `target`, `openIn`, `method`, `params`, `bodyExtra`, `bodyShape`, `operation`, `patch`, `confirmText`, `successMessage`, `errorMessage`, `refreshAfter`, `locations`, `toast`, `resultDialog`, `onSuccess` and `objectName`. - **That is `action:button`'s keys by `type`**, with two differences the reads decide, and §3 derives the key set from `ActionButtonPropsSchema.shape` to pin them. `undoable` and `recordIdField` are not forwarded by either container. `tags` is drawn by both. `size` is read only by a group's inline button (`:124`, `md` drawn as `default`); an `action:menu` item reads none and declares none (the `action:icon` precedent). - **Value schemas are the rows'**, key by key. `visible` / `disabled` take the rows' own `actionCondition()`, and §3 checks the same accept set and the same envelope. The runner-forwarded blocks stay `z.unknown()` with "forwarded to the runner" in their describes, so the enumeration pin's `runner` reason holds for each. - **Refused, each with a prescription:** - `actionType` → `type` (the rows' alias table, turned round); - `endpoint` / `url` / `path` / `href` → `target` (the rows' `ACTION_TARGET_ALIASES`); - `enabled` (the rows' own text) and `autoTrigger` (the rows' text on `action:menu`; on `action:group`, which never reads it, the text says so); - `outcomeMessages` → `successMessage`; - a member `className` → `variant`, or the node's own `className`; - `properties` → `bodyExtra`, or the action as its own `action:button`; - `undoable` / `recordIdField`; - an `action:menu` member's `size`. - **`outcomeMessages` stays undeclared on all four action blocks.** §3 pins that none of `action:button`, `action:icon`, `action:group`, `action:menu` and neither member shape declares it. The `action:button` / `action:icon` rows are not edited. ## The census (writers of all three members) **Instrument.** This run's TypeScript-AST walk over code and fenced docs, with same-file constants and spreads, `.map` over a constant list, templates and same-file helper calls evaluated. It reads: - object literals naming the block (the `type` also through a spread constant); - typed literals; - direct parses through the row (receiver constants resolved); - the block's JSX component (`schema={…}`, or `ObjectMetricWidget`'s own props); - helper parameters at every same-file call site, for both member and whole-node positions; - a loose pass over every `report` / `items` / `actions` key in a file naming a block. Every static value was parsed through this branch's rows; each value with a non-static part, and each refusal, was read by hand. **Cross-check:** it reproduces stage 5's `drillDown` population exactly (26 values) and the S-objectui-held census's 40 `action:group` / 19 `action:menu` values. - **objectstack** at `1289925c0a` (the base; `main` moved 5 commits to `33f97917ac`, merged here through `os-regen-merge.sh`, none touching a census corpus file): - one drill report (the metric pin's own), which parses; - one timeline `items` (`component-element-navigation-17987.test.ts:213`, a feed entry), which parses; - three container members (`component-action-element-rows-20371.test.ts`), which parse, plus that file's two probes the old row already refused; - no example, doc or skill writes any of the three. - **objectui** at the pin `2e818d0b51ec` and at `main` `2abec3a96`, the same counts at both: - **`drillDown.report`:** of the 26 `drillDown` values, 2 carry a `report`. The drawn one (`objectMetricDrillDownMembers-8071.test.tsx:281`) parses. The other is that file's `it.each` pair (`:305`), the two values the drawer does NOT draw, both refused. The loose pass's 34 `report` keys: 17 parse (drill-mirror tests and the dashboard guide); 9 are refused, all refusal probes on objectui's own faces (`{ name }`, `{ name: 42 }`, the matrix with no `values` in `drill-down-report-name-retired-11517.test.ts`); 4 are not static and 4 are not report objects (i18n strings). - **`items`:** 18 values. 15 parse: feed entries, gantt rows and the empty gantt. The 3 refused are the render-time gantt date diagnostic's own probes, an array, `false` and `null` bar date (`timeline-gantt-date-spelling-6907.test.tsx:338`, `timeline-gantt-date-type-rule-6781.test.tsx:419`, `timeline-gantt-null-date-6770.test.tsx:220`). - **Members:** `action:group` 40 values (26 parse, 2 refused, 12 partly non-static) and `action:menu` 19 (11 parse, 3 refused, 5 partly non-static), all in tests but one run-time hand-off. Every refused member is a probe of a read the ruling refuses: - the member pin's `className` (`action-group-menu-inputs-11168.test.tsx:249`); - the `outcomeMessages` forward tests (`action-outcomeMessages-forward-11344.test.tsx:147`, `:158`); - the `properties.params` static-value tests (`action-container-member-params-10290.test.tsx`, read by hand); - the host's `autoTrigger` flag (`action-overflow-autotrigger.test.tsx:298`, and as a test device in `action-onSuccess-forward.test.tsx:182`, `action-objectName-onClick-4202.test.tsx:127` and `action-menu-host-disabled-11182.test.tsx`). The partly non-static members are predicate variables, helper-built members and code-composed `onClick` functions, read by hand; their static keys parse. - **The run-time hand-off** is `action:bar`'s overflow menu (`action-bar.tsx:287`). It hands the bar's own members — the registered actions a host passes — to `action:menu` at run time, never through the component-props gate, and those members are `ActionSchema` entries (the ruled-out option C). Recorded, not a block writer. - **hotcrm** at `4054ec2680` and **cloud** at `2205b53010`: no writer of any of the three. hotcrm's four `object-metric` tiles declare no `drillDown`. - **No measured working writer is refused**, so the stop valve does not trip on any of the three members. ## Release - `.changeset/21464-component-props-report-items-action-members-typed.md`: `'@objectstack/spec': minor`, the BREAKING banner, `Clause-②: yes (narrowing)`, the ADR-0087 `registered` marker naming the three ids, a FROM → TO table and the census. - Three D3 entries in step 18: `18.ui-object-metric-drill-down-report-typed.ts`, `18.ui-object-timeline-items-typed.ts` and `18.ui-action-group-menu-members-typed.ts`. The semantic region was regenerated by `gen:migration-registry`. - Three rationale fragments at orders 80, 81 and 82, the next free after S-forms' 79. - No D2 conversion and no `RETIRED_KEYS_BY_MAJOR` row: page-component `properties` is not on the save or load path, and no declared key is removed. - No export is added or removed: the entry, bar and member builders are module-private. - Generated: `content/docs/references/ui/component.mdx` (two member tables and the timeline entry table) and the strictness counts (`ui/` 204 → 208, `component.zod.ts` 74 → 78: the entry, the bar and the two members). - `dropped-refinements.baseline.json` 670 → 676. `ObjectMetricProps` gains the five `ReportSchema` refinement sites carried by reference, the S-forms growth-by-reuse precedent. `ObjectTimelineProps` gains its root, the new pairing refinement. - Dogfood expression-conformance ledger: two rows, `cel-action-member-visible` (fail-closed) and `cel-action-member-disabled` (fail-closed), for the two new positions `actionContainerMemberShape.visible` / `.disabled`. Each has one evaluation leg, the container's, and no node gate (objectstack-ai#20420 and the S-forms rows are the precedent). ## Tests - **Red first,** through the published `@objectstack/spec@17.6.0` (npm tarball, `ComponentPropsMap[type].safeParse`): 33 of the 34 values this branch refuses are ACCEPTED there. The one refused, `items: 42`, was already refused by the old `z.array` (a control). On this branch all 34 are refused. - **The new pin `component-report-items-action-members-typed.pin.test.ts`:** - §1: the writer shapes parse — timeline entries and members byte-identical, drill reports to exactly `ReportSchema.parse(report)`; - §2: 34 refusals by `code` and `path` (the red-first set), plus the prescriptions; - §3: the vocabulary pins (the report def identity, the arms' key sets, the member key set derived from the button's, `outcomeMessages` absent on all six faces, the shared condition); - §4: admitted implies drawn; - §5: the D3 ids. - **The enumeration pin:** 23 new reasoned lines (the member predicates' `ast`, the runner-forwarded members, the report's two `runtimeFilter` positions, the opaque `content`), the four fork lines gone, and §2 and §6 added. **The metric family pin:** the drawn report row moves out of the byte-identical table into its own case, asserting the parse equals `ReportSchema`'s answer (no `expect` removed, no skip). - `pnpm --filter @objectstack/spec test` at `b7335d8374`: Test Files 615 passed (615); Tests 18358 passed, 1 todo. `pnpm --filter @objectstack/spec typecheck` at the same head: exit 0, test layer held (52 files / 246 errors / 135 signatures; the touched pins are on `tsconfig.test.json`). `pnpm --filter @objectstack/lint test`: 119 files, 5627 tests passed. The showcase `validate`: passed. Dogfood `test/expression-conformance.test.ts`: 7 passed. - **Ablation**, one leg per member (two for the timeline), each through `scripts/ablation-replace.mjs` in a driver with an EXIT / INT / TERM trap, restored and proven by blob hash (HEAD blob `b4dcaf34d2`, `git diff HEAD` empty after every leg): - `report` → `z.unknown()`: 17 red; - `items` element → `z.unknown()`: 18 red; - the pairing refinement dropped: 5 red; - the `action:group` member → an open record: 15 red; - the `action:menu` member → an open record: 13 red. - **Public door,** through built `lint`'s `validateComponentProps`. Nothing is reported for the drawn report drill, feed entries or group members. `component-props-invalid` is reported at `drillDown.report.dataset` / `drillDown.report` and at `items.0.title` / `.label` / `.items`. `component-props-unknown-key` is reported for an entry's `color`, a member's `actionType` and a menu member's `outcomeMessages`. - **Derived gates:** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` gives 114 commands (merge base `33f97917a`, 14 paths, 1705 changed lines). All 114 exit 0 at `b7335d8374`, and the `--ran` reconciliation reads 114 derived, 114 run, 0 NOT-MEASURED, 0 UNRUN. Two first ran as PREREQUISITE NOT MET (`check:skill-examples`, `check:dual-build-cjs-loads`) and were re-run green after `turbo run build --filter=!@objectstack/docs` (72 successful). - **The changeset gates with this body as the `pull_request` payload** (`--event`): `check-changeset-no-major` (LEVEL AXIS: `yes (narrowing)`, no moved package graded `patch`), `check-adr-0087-registration` (one declared-breaking changeset, `registered` with the three ids) and `check-empty-changeset`, each exit 0. - **eslint, a proven narrowing of `pnpm lint`:** `eslint --no-inline-config --format json` over the 10 changed `.ts` files reads 10 files, 0 errors, 0 warnings. The population is the repo's one `eslint.config.mjs`, which ignored none of the 10. The narrowing excludes nothing: that config never enables type-aware linting (`eslint.config.mjs:327-328`, no `parserOptions.project`), so this diff cannot move a verdict on an untouched file. - **NOT MEASURED:** the Console Pin Gate, the Dogfood Regression Gate's full suite and the full `pnpm lint`, reason: CI-owned. objectui was read at the pin and at `main`, not built against this spec. ## Acceptance notes Noted, not filed: - **A member's object `params`** is forwarded as the request payload of a `type: 'api'` member only. On any other type the container drops it with a development warning (`static-params.ts:172-183`). The member keeps `params` as the rows do (`z.unknown()`, runner-forwarded), and its describe says so. - **The `action:bar` hand-off** above composes `action:menu` members from the host's registered actions at run time. Those carry `ActionSchema` keys (`outcomeMessages`, `order`, `component`, …) the member shape refuses. No gate judges that composition, and the `action:button` row's docblock already records the same member path for `outcomeMessages`. - **The D2 conversion `action-block-endpoint-to-target`** rewrites a stored `endpoint` on `action:button` / `action:icon` nodes only. A stored member `endpoint` is not rewritten. The census found no writer of one, and the refusal carries the rename. - **Earlier step-18 rationale fragments** (`ui-object-metric-drill-down-typed`, `ui-object-timeline-mapping-typed`) still say these members "stay open". The new fragments (orders 80-82) follow them and say they are now typed, so the joined text reads in order. The older fragments are not edited, the S-forms precedent. - **The grid widget's camelCase keys** (objectui#11610, landed on objectui `main` as `2abec3a9`, not yet at the `.objectui-sha` pin) are not declared on the S-forms runtime form field here. That is fork 2's follow-up, outside this claim's file surface; its carrier is the next pin bump. ## Not in this PR - No `action:button` / `action:icon` row edit, and no `view.zod.ts` / `report.zod.ts` edit. - No objectui change and no pin bump. --- _Generated by [Claude Code](https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20371
Clause-②: yes
What this does
ComponentPropsMapgains six rows for the curated objectui public blocks that had none:action:button,action:group,action:menu,action:icon,element:definition-list,element:repeater. Each row is astrictObjectfrom birth, and each key set is measured from the renderer's read points in objectui, not transcribed fromUIActionSchema, from the registrations'inputs, or from this package's object-metadataActionSchema(triage execution note 1).Before this change the six failed in two ways:
action:*types sit outside every namespace thePageComponentTypeenum populates, so the props gate skipped them. Any key insidepropertiesparsed, was stored, and was ignored by the renderer.element:*types sit inside the reservedelement:namespace with no enum member and no row, socomponent-type-unknownrefused the whole node (severity error), although objectui registers, publishes and offers both.A row closes both.
component-type-vocabulary.tsderives the known set fromObject.keys(ComponentPropsMap), so the twoelement:*types join theelement:vocabulary through their rows. This is theelement:metadata_viewershape: no enum member and no string-arm ledger entry (the vocabulary test forbids a ledger entry for a type the map declares). The three-part evidence the ledger comment asks for (registration, publication, authorship) is written on the map rows, with pin citations.Findings stay at the props gate's existing warning tier.
PageComponentSchemaparsing is unchanged, because the opentypearm already admitted all six.Read points: measured at the pin (
.objectui-shadd3f7e1be356)First measured at
f8a9d0fb0596. After #20436 moved the pin, every read point was re-derived atdd3f7e1be356(2026-09-28) and re-anchored together with the sha.Per-key citations are in
component.zod.tssection 4b and in each schema's docblock. The decisions the measurement made:action:buttonname,label,icon,actionType,variant,size,visible,disabled+ 21 keys forwarded to the runner (params,target,openIn,endpoint,method,bodyExtra,bodyShape,operation,patch,confirmText,successMessage,errorMessage,refreshAfter,undoable,recordIdField,locations,toast,resultDialog,onSuccess,description,objectName)nameis optional, because the renderer readsschema.name ?? schema.label(action-button.tsx:119).variantacceptsprimaryandsizeacceptsmdbecause the renderer maps both (:137-138).typeis refused with a rename toactionType.enabled(the legacy fallback) andautoTrigger(a host transport flag, "NOT persisted metadata") are refused with a prescription.action:iconsize,undoable,recordIdField:107pins the icon size, so there is nosize.undoableandrecordIdFieldare not in its forward (:134-196).action:groupactions,display,location,label,icon,variant,size,visibleactionsis a list of action objects (:248); the registration publishestype: 'object'. There is no group-levelname: the registration publishes it (:415), nothing reads it, and it is refused with a prescription.sizetakes the primitive's four values:mdis mapped only on the dropdown trigger (:357) and reaches the Button primitive unmapped in the default inline mode (:398,:91).action:menuactions,label,icon,variant,size,visiblevariantandsizego to the Button primitive unmapped (:230-231), so there is noprimaryand nomd.element:definition-listitems(strict{ term, description? }),columns,inlinecolumnsis the number1 | 2, because the renderer compares=== 2(data-list.tsx:49). The registration's enum publishes the strings'1'/'2', and the string'2'is refused with a prescription.itemsis optional: absent and empty both render "No details".termis required.element:repeaterobject(required),titleField,fields,filter,sort,limit,emptyText,dividedfilterandsortuse the family's one orthography,ViewFilterRule[]andSortItem[]. Both reach the query:ObjectStackAdapter.findlowers rule arrays and serializes sort items.fieldstakes a name or{ field }; thelabelthat the TS type advertises is never rendered and is refused.The value posture follows #7751. A key the renderer interprets itself gets a value schema. A key it only forwards to the action runner gets the scalar that
ActionDefdeclares for it, orz.unknown()whereActionDefuses a spec-derived block.objectName, carried by the new pin. Atdd3f7e1be356,action:buttonandaction:iconforwardobjectNameto the runner (action-button.tsx:307,action-icon.tsx:195), and the console dispatches to that object instead of the page object. Both rows declareobjectNameas theActionDefstring scalar.action:groupandaction:menuforward it per member (action-group.tsx:323,action-menu.tsx:313), so it rides each member object and the container rows gain no key. The same pin reads static values fromproperties.params(objectui#10289,static-params.ts:91-101). On a page node that is the row itself, soparamskeeps its meaning: an array is the input list and an object is the static values. Its value schema is unchanged.Surface beyond the claim, and why
The claim lists
component.zod.ts, tests insrc/ui/, generator output and.changeset/. Three gates required three more files, two inpackages/specand one inpackages/qa/dogfood. All three edits follow from the new rows, and none was stopped on:dropped-refinements.baseline.json:ElementRepeaterPropspublishesViewFilterRuleSchema, whose refinement the JSON Schema projection drops. The build refuses to publish until the site is declared. I added the entry the build printed, plus the header totals its test holds (211→212 schemas, 609→610 sites).type-alias-convention.pin.test.ts:gen:docsrequires a type alias for every documented schema, andcheck:spec-parsed-aliasrequires an isomorphic alias to be pinned.ElementDefinitionListPropsis the only isomorphic one of the six, so it gets one pin. The count is 780→781 after the merges with [finding] four more exported spec types resolve tounknownwhile their TSDoc promises a shape —ViewMetadataParsed,InlineAction,AssembledViewArtifact,JoinedReportBlock(the #19871 class, other sites) #19920's 786→783 and main's connector-retirement 783→780 (both intents stacked). The other five declareXParsed.packages/qa/dogfood/test/expression-conformance.ledger.ts: gate-forced by theDogfood Regression Gate(expression-conformance.test.ts, ADR-0060 checkLedger). The sixvisible/disabledpredicate positions the new action rows declare needed a classification. It has three rows, split by fault face as the objectui renderers andSchemaRenderer's node gate compose at the pin: button/menuvisiblefail-closed, icon/groupvisiblefail-soft-log, button/icondisabledfail-closed. Round 4 re-anchored the three rows atdd3f7e1be356; their fault faces are unchanged, because the evaluators are code-identical across the pin hop.component-type-vocabulary.tswas not edited. ItsKNOWN_COMPONENT_TYPESdocblock lists the string-arm rows "exactly" (element:metadata_viewer, the plugin widgets,object-*), and that list no longer covers the six new rows. It is noted below, not fixed here.element:repeater'sfilteris the barez.array(ViewFilterRuleSchema)door thatrecord:related_listdeclares, not aruleArrayFilterErrordoor. That prescription speaks to a door that used to take the record form, and wiring it would pull the repeater into the reach of the stored-row conversionpage-component-filter-record-to-rule-array(conversions/registry.ts, whose test holds the two equal). That registry is outside this card.Premise checks (order zone 2)
ab6fb027,git grepfor each quoted type inpackages/spec/src: the fouraction:*hit onlyaction.zod.ts:983-986(theAction.componentenum) plusexpression-bindable-text-keys.test.ts:126. The twoelement:*types hit 0. Control:'element:text'hitscomponent.zod.ts:4609(the row).origin/mainare listed above. The pin was not bumped.propertiesis accepted in silence — the props bag is a passthrough record and the SDUI props gate has no schema to dispatch forobject-*blocks #7751 / spec:record:reference_railhas no ComponentPropsMap row — an entryfilterparses, typechecks, validates, ships, and silently does nothing #8691 / spec:record:alert/record:quick_actions/record:historyhave no ComponentPropsMap row — same silent no-op mechanism #8691 closed for the rail #8744 method is followed. The three-part evidence is on the twoelement:*rows.node scripts/check-sdui-manifest.mjs: see the gate table. The manifest was not touched.['"]action:(button|group|menu|icon)['"]|['"]element:(definition-list|repeater)['"]plus thetype:YAML form, excludingnode_modules/dist. The hits aresdui.manifest.json,action.zod.ts'scomponentenum, three generated reference pages (a type union) and one test. Control with the same shape:'element:text'gives 105 occurrences in 29 files, includingexamples/**andplatform-objects. No shipped metadata is refused by the new rows.Through the lint door
A one-off probe (not a permanent test) runs
validateComponentTypesandvalidateComponentPropsfrompackages/lint/srcagainst the built spec, using one stack with a planted typo on each row pluselement:repeatras a control:component-type-unknownfires only onelement:repeatr.component-props-unknown-key(warning) fires onaction:button.typo_key, onaction:group.name, and onelement:repeater.fields.0.label, which the lone union arm unpacks.component-type-unknownfires on bothelement:*types and onelement:repeatr, and the props gate reports nothing.Tests
The readings below are at HEAD
5e50899a4(after mergingorigin/mainat3cf644938, the pin bump):pnpm --filter @objectstack/spec test(thelocalproject): 565 files, 16684 passed, 1 todo, exit 0. This includes the newsrc/ui/component-action-element-rows-20371.test.ts(45 tests):element:repeatrcontrol.pnpm --filter @objectstack/spec test:repo: 37 files, 684 passed, exit 0.pnpm --filter @objectstack/spec typecheck(tsc --noEmit,check:scripts-typecheck,check:test-typecheck): exit 0....@objectstack/specsweep: the three the order names):pnpm --filter @objectstack/lint test: 115 files, 5331 passed, exit 0.pnpm --filter @objectstack/metadata-core test: 16 files, 289 passed, exit 0.packages/qa/dogfoodtest/expression-conformance.test.ts: 7 passed, exit 0.@objectstack/cliunit layer (exec vitest run --project unit; not re-run this round, this is the round-1 reading): 2579 passed, 29 skipped, 0 assertion failures. 52 files are NOT MEASURED: they fail to load onMODULE_NOT_FOUNDfor workspace dependencies not built here (@objectstack/plugin-email,create-objectstack,@objectstack/verify,@objectstack/cloud-connection, the cli's owndist). The integration layer is declared to CI, since the diff touches no spawn entry.@objectstack/specitself;--filter @objectstack/formula --filter @objectstack/sdui-parser --filter @objectstack/lint);--filter "@objectstack/client-react..." --filter "!@objectstack/spec") forcheck:skill-examples.pnpm lint(that one is CI's):eslint --no-inline-config --format jsonover the diff's three TS files (the only lintable files in it; the rest are JSON, MD and MDX, which the config'sfilesglobs do not select): 3 files, 0 errors, 0 warnings.--print-configresolves a config for each of the three files.eslint.config.mjsenables no type-aware linting (noparserOptions.project, as its own comment near line 327 states), so this diff cannot move any untouched file's verdict.7bd546c1fixed it.Gates
The derived union at
5e50899a4is 108 commands (node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, with no paths, off the merge base). It is a superset of the 72-line dispatch list, adding 36 families for the changeset, the docs and the pin test. Every exit code was written to disk before its output was read.check:type-check-debt(to a verdict this time: 4 ledger entries re-measured, none above its recorded number),check:dts-closure,check:generated,check:api-surface,check:authorable-surface,check:docs,check:strictness-ledger,check:objectui-pin-citations,check:spec-parsed-alias,check:yaml-examples,check:liveness,check:issue-citations,check:nul-bytesandcheck:skill-examples(after building the client closure).pnpm check:dual-build-cjs-loads. It exited 3 (PREREQUISITE NOT MET) because it reads every workspace package'sdist, which needs a whole-repo build. This diff changes only@objectstack/spec's build output.dispatch-gates --ran: "108 derived famil(ies) accounted for — 107 run, 1 NOT-MEASURED".node scripts/check-sdui-manifest.mjs: exit 0. The manifest is untouched by this branch and recorded at pindd3f7e1be356(A4: no lockstep gate moved).check:objectui-pin-citations: exit 0 (49 asserting citations matchdd3f7e1be, 61 historical).--verify-anchorsagainst add3f7e1beclone: exit 0.Acceptance notes (not filed; the seat decides)
registry-inputs-spec-paritygate will surface each of these on the spec bump):action:groupregistration publishesname, which nothing reads, and asizeenum withmd, which inline mode does not map;element:definition-listregistration'scolumnsenum is the strings'1'/'2'(the designer writes numbers);element:repeater's TS type and registration description advertisefields[].label, which is never rendered.action:menuspreads...restonto its trigger afterdisabled={loading}, soSchemaRenderer'sdisabled: undefinedcan override the in-flight disable (the objectui#9131 shape it fixed on button/icon);action:menuand inlineaction:groupspread hoisted props (actions,label, …) raw onto DOM elements.component-type-vocabulary.ts'sKNOWN_COMPONENT_TYPESdocblock enumerates the string-arm rows as "exactly"element:metadata_viewer, the plugin widgets andobject-*; the six new rows are not in that list. This is prose drift and not edited, because the file is outside the claimed surface (carrier: the next edit of that file).element:repeater.filteris a bare rule-array door. Wiring it toruleArrayFilterErrormeans addingelement:repeatertoRULE_ARRAY_FILTER_BLOCK_TYPESinconversions/registry.tsin the same change (carrier: none).Changeset
@objectstack/specminor: six new public rows, two types admitted to theelement:vocabulary, and nothing that a declared row accepted is refused.Downstream
objectui#10872 can now arm the six by reference. When it bumps
@objectstack/spec, itsregistry-inputs-spec-paritygate will judge the six in both directions:nameonaction:groupand the'1'/'2'string enum oncolumns.action:button, 20 onaction:icon,location/visibleon the group, andsize/visibleon the menu.That reconciliation belongs to objectui.
The
Surface beyond the claimsection was amended by thedomain:specseat 1 (session_01B3TqpoQbTAfG7G74GMDWNW) after the patch round, from the dev's delta5868569396.Round 4 (after #20436 moved
.objectui-shatodd3f7e1be356): the read-points heading, the table anchors, theobjectNameparagraph, Tests, Gates, Acceptance notes and Downstream were amended by the same seat from the dev’s delta (report5873527479).