Skip to content

feat(spec): ComponentPropsMap rows for action:button/group/menu/icon and element:definition-list/repeater - #20420

Merged
objectstack-fleet[bot] merged 17 commits into
mainfrom
claude/issue-20371-component-props-action-element-rows
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 17 commits into
mainfrom
claude/issue-20371-component-props-action-element-rows

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #20371

Clause-②: yes

What this does

ComponentPropsMap gains 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 a strictObject from birth, and each key set is measured from the renderer's read points in objectui, not transcribed from UIActionSchema, from the registrations' inputs, or from this package's object-metadata ActionSchema (triage execution note 1).

Before this change the six failed in two ways:

  • The four action:* types sit outside every namespace the PageComponentType enum populates, so the props gate skipped them. Any key inside properties parsed, was stored, and was ignored by the renderer.
  • The two element:* types sit inside the reserved element: namespace with no enum member and no row, so component-type-unknown refused the whole node (severity error), although objectui registers, publishes and offers both.

A row closes both. component-type-vocabulary.ts derives the known set from Object.keys(ComponentPropsMap), so the two element:* types join the element: vocabulary through their rows. This is the element:metadata_viewer shape: 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. PageComponentSchema parsing is unchanged, because the open type arm already admitted all six.

Read points: measured at the pin (.objectui-sha dd3f7e1be356)

First measured at f8a9d0fb0596. After #20436 moved the pin, every read point was re-derived at dd3f7e1be356 (2026-09-28) and re-anchored together with the sha.

Per-key citations are in component.zod.ts section 4b and in each schema's docblock. The decisions the measurement made:

Row Declared keys Measured, not assumed
action:button name, 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) name is optional, because the renderer reads schema.name ?? schema.label (action-button.tsx:119). variant accepts primary and size accepts md because the renderer maps both (:137-138). type is refused with a rename to actionType. enabled (the legacy fallback) and autoTrigger (a host transport flag, "NOT persisted metadata") are refused with a prescription.
action:icon same as the button, minus size, undoable, recordIdField :107 pins the icon size, so there is no size. undoable and recordIdField are not in its forward (:134-196).
action:group actions, display, location, label, icon, variant, size, visible actions is a list of action objects (:248); the registration publishes type: 'object'. There is no group-level name: the registration publishes it (:415), nothing reads it, and it is refused with a prescription. size takes the primitive's four values: md is mapped only on the dropdown trigger (:357) and reaches the Button primitive unmapped in the default inline mode (:398, :91).
action:menu actions, label, icon, variant, size, visible The trigger variant and size go to the Button primitive unmapped (:230-231), so there is no primary and no md.
element:definition-list items (strict { term, description? }), columns, inline columns is the number 1 | 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. items is optional: absent and empty both render "No details". term is required.
element:repeater object (required), titleField, fields, filter, sort, limit, emptyText, divided filter and sort use the family's one orthography, ViewFilterRule[] and SortItem[]. Both reach the query: ObjectStackAdapter.find lowers rule arrays and serializes sort items. fields takes a name or { field }; the label that 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 ActionDef declares for it, or z.unknown() where ActionDef uses a spec-derived block.

objectName, carried by the new pin. At dd3f7e1be356, action:button and action:icon forward objectName to the runner (action-button.tsx:307, action-icon.tsx:195), and the console dispatches to that object instead of the page object. Both rows declare objectName as the ActionDef string scalar. action:group and action:menu forward 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 from properties.params (objectui#10289, static-params.ts:91-101). On a page node that is the row itself, so params keeps 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 in src/ui/, generator output and .changeset/. Three gates required three more files, two in packages/spec and one in packages/qa/dogfood. All three edits follow from the new rows, and none was stopped on:

  • dropped-refinements.baseline.json: ElementRepeaterProps publishes ViewFilterRuleSchema, 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:docs requires a type alias for every documented schema, and check:spec-parsed-alias requires an isomorphic alias to be pinned. ElementDefinitionListProps is 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 to unknown while 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 declare XParsed.
  • packages/qa/dogfood/test/expression-conformance.ledger.ts: gate-forced by the Dogfood Regression Gate (expression-conformance.test.ts, ADR-0060 checkLedger). The six visible / disabled predicate positions the new action rows declare needed a classification. It has three rows, split by fault face as the objectui renderers and SchemaRenderer's node gate compose at the pin: button/menu visible fail-closed, icon/group visible fail-soft-log, button/icon disabled fail-closed. Round 4 re-anchored the three rows at dd3f7e1be356; their fault faces are unchanged, because the evaluators are code-identical across the pin hop.

component-type-vocabulary.ts was not edited. Its KNOWN_COMPONENT_TYPES docblock 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's filter is the bare z.array(ViewFilterRuleSchema) door that record:related_list declares, not a ruleArrayFilterError door. 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 conversion page-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)

Through the lint door

A one-off probe (not a permanent test) runs validateComponentTypes and validateComponentProps from packages/lint/src against the built spec, using one stack with a planted typo on each row plus element:repeatr as a control:

  • After: component-type-unknown fires only on element:repeatr. component-props-unknown-key (warning) fires on action:button.typo_key, on action:group.name, and on element:repeater.fields.0.label, which the lone union arm unpacks.
  • Before, emulated in process by deleting the six rows and the two known types from the same module instance the rules read: component-type-unknown fires on both element:* types and on element:repeatr, and the props gate reports nothing.

Tests

The readings below are at HEAD 5e50899a4 (after merging origin/main at 3cf644938, the pin bump):

  • pnpm --filter @objectstack/spec test (the local project): 565 files, 16684 passed, 1 todo, exit 0. This includes the new src/ui/component-action-element-rows-20371.test.ts (45 tests):
    • key sets asserted whole;
    • one objectui-sourced accepted example per type;
    • an unknown-key refusal on every row;
    • each measured decision above;
    • vocabulary admission, with the element:repeatr control.
  • 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.
  • Consumers, downstream of spec (not a full ...@objectstack/spec sweep: 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/dogfood test/expression-conformance.test.ts: 7 passed, exit 0.
    • @objectstack/cli unit 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 on MODULE_NOT_FOUND for workspace dependencies not built here (@objectstack/plugin-email, create-objectstack, @objectstack/verify, @objectstack/cloud-connection, the cli's own dist). The integration layer is declared to CI, since the diff touches no spawn entry.
  • Builds (upstream direction):
    • @objectstack/spec itself;
    • lint's upstream (--filter @objectstack/formula --filter @objectstack/sdui-parser --filter @objectstack/lint);
    • client-react's upstream (--filter "@objectstack/client-react..." --filter "!@objectstack/spec") for check:skill-examples.
  • eslint, a proven narrowing rather than the repo-wide pnpm lint (that one is CI's):
    • eslint --no-inline-config --format json over the diff's three TS files (the only lintable files in it; the rest are JSON, MD and MDX, which the config's files globs do not select): 3 files, 0 errors, 0 warnings.
    • --print-config resolves a config for each of the three files.
    • eslint.config.mjs enables no type-aware linting (no parserOptions.project, as its own comment near line 327 states), so this diff cannot move any untouched file's verdict.
  • Before the merge, the spec suite had one failure: the dropped-refinements header totals. 7bd546c1 fixed it.

Gates

The derived union at 5e50899a4 is 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.

  • 107 exit 0. These include 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-bytes and check:skill-examples (after building the client closure).
  • NOT MEASURED: pnpm check:dual-build-cjs-loads. It exited 3 (PREREQUISITE NOT MET) because it reads every workspace package's dist, 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 pin dd3f7e1be356 (A4: no lockstep gate moved).
  • check:objectui-pin-citations: exit 0 (49 asserting citations match dd3f7e1be, 61 historical). --verify-anchors against a dd3f7e1be clone: exit 0.

Acceptance notes (not filed; the seat decides)

  • objectui producer side (carrier: the objectui#10872 follow-up the seat files at ACCEPT; its registry-inputs-spec-parity gate will surface each of these on the spec bump):
    • the action:group registration publishes name, which nothing reads, and a size enum with md, which inline mode does not map;
    • the element:definition-list registration's columns enum is the strings '1'/'2' (the designer writes numbers);
    • element:repeater's TS type and registration description advertise fields[].label, which is never rendered.
  • objectui renderer, read-only inference, not reproduced (carrier: none):
    • action:menu spreads ...rest onto its trigger after disabled={loading}, so SchemaRenderer's disabled: undefined can override the in-flight disable (the objectui#9131 shape it fixed on button/icon);
    • action:menu and inline action:group spread hoisted props (actions, label, …) raw onto DOM elements.
  • component-type-vocabulary.ts's KNOWN_COMPONENT_TYPES docblock enumerates the string-arm rows as "exactly" element:metadata_viewer, the plugin widgets and object-*; 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.filter is a bare rule-array door. Wiring it to ruleArrayFilterError means adding element:repeater to RULE_ARRAY_FILTER_BLOCK_TYPES in conversions/registry.ts in the same change (carrier: none).

Changeset

@objectstack/spec minor: six new public rows, two types admitted to the element: 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, its registry-inputs-spec-parity gate will judge the six in both directions:

  • The forward direction flags name on action:group and the '1'/'2' string enum on columns.
  • The reverse direction lists the read-but-unpublished keys: 22 on action:button, 20 on action:icon, location/visible on the group, and size/visible on the menu.

That reconciliation belongs to objectui.

The Surface beyond the claim section was amended by the domain:spec seat 1 (session_01B3TqpoQbTAfG7G74GMDWNW) after the patch round, from the dev's delta 5868569396.

Round 4 (after #20436 moved .objectui-sha to dd3f7e1be356): the read-points heading, the table anchors, the objectName paragraph, Tests, Gates, Acceptance notes and Downstream were amended by the same seat from the dev’s delta (report 5873527479).

…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>
…esolved citation

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>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation protocol:ui tests tooling labels Sep 28, 2026
@github-actions

github-actions Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 28 documentable anchor(s). ⚠️ 6 changed file(s) yielded no anchor (packages/spec/api-surface/ui.json, packages/spec/authorable-surface/ui.json, packages/spec/declaration-map/ui.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/automation/flows.mdx (via actionType (literal, a string literal in ACTION_NODE_ALIASES))
  • content/docs/deployment/validating-metadata.mdx (via actionType (literal, a string literal in ACTION_NODE_ALIASES))
  • content/docs/protocol/objectui/layout-dsl.mdx (via ComponentPropsMap (symbol, a top-level const object))

⛔ 6 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via actionType (literal, a string literal in ACTION_NODE_ALIASES))
  • content/docs/releases/v17/17-0.mdx (via actionType (literal, a string literal in ACTION_NODE_ALIASES))
  • content/docs/releases/v17/17-1.mdx (via ComponentPropsMap (symbol, a top-level const object))
  • content/docs/releases/v17/17-3.mdx (via ComponentPropsMap (symbol, a top-level const object))
  • content/docs/releases/v17/17-4.mdx (via ComponentPropsMap (symbol, a top-level const object))
  • content/docs/releases/v17/17-5.mdx (via autoTrigger (symbol, a field of const object ACTION_NODE_GUIDANCE))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 6 changed file(s) yielded no anchor (packages/spec/api-surface/ui.json, packages/spec/authorable-surface/ui.json, packages/spec/declaration-map/ui.json, …) — pages documenting those are invisible to this run
  • 1 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 — 137 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 3cf64493899458632f87e661fff1b130bd3a8273 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from eac80982bafa158af1fff732c3b7505b54162e85 — the merge of head 5e50899a481dce659bc12ad2576b4fc534cd3893 into base 3cf64493899458632f87e661fff1b130bd3a8273, 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 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

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

…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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 29fd4591e4c58735a265b1a19df759acd05471d5
Local-runs: none

Inputs: card #20371 (body, all five comments; triage notes 5863783308 binding), PR #20420 (body, 14-file list, net diff against main), the check-runs on the head (polled until none was in progress), origin/main files by git show, and objectui source at the pin the rows cite (.objectui-sha = f8a9d0fb0596, fetched and read, never built or run). The seat's ACCEPT (5868610882) was treated as a claim and re-tested. The head did not move during the review.

① Derived judgments

Every accept-set and public-surface change the diff implies, each tested against the pin:

  1. Vocabulary admission of element:definition-list / element:repeater — right. KNOWN_COMPONENT_TYPES is Object.keys(ComponentPropsMap) plus the enum plus the string-arm ledger (component-type-vocabulary.ts:96-100), so the two rows admit both types with no enum member and no STRING_ARM_REGISTERED_TYPES entry, the element:metadata_viewer shape; the vocabulary test forbids a ledger entry for a type the map declares. The three-part evidence holds at the pin: registration components/src/renderers/basic/data-list.tsx:75 and :184 (namespace element); publication core/src/registry/public-blocks.ts:109-110, and the tracked sdui.manifest.json on main carries both; authorship app-shell/.../previews/block-types.ts:129-130 (palette) and previews/block-config.ts:282-316 (inspectors). The element:repeatr control stays refused: the namespace was not opened, two members were named.
  2. The four action:* rows add dispatch, not vocabulary — right. action: is in no enum member, so hasReservedComponentNamespace stays false and the rows only give the props gate a schema to dispatch on. PageComponentSchema parsing is unchanged (open type arm; the test pins it). Findings land at the props gate's existing severity: 'warning' (packages/lint/src/validate-component-props.ts), so no previously accepted node is refused at any door.
  3. action:button, 28 keys — right. Each read point re-checked at the pin: name :118 (schema.name ?? schema.label) and :188, so optional is the measured state; label :346/:192; icon :133; actionType :187; variant/size :136-137 map primary and md to default, which is why those two values are accepted only here; visible/disabled :116-119/:129 with the gates at :298/:333-339; the twenty forwarded keys are exactly the forwarded: ActionDef literal at :177-271 plus the params payload at :153-155. Value posture matches the runner's ActionDef (core/src/actions/ActionRunner.ts): openIn is 'self' | 'new-tab' (:316), refreshAfter/undoable boolean (:270/:272), method/endpoint/target/confirmText/successMessage/errorMessage string, locations the spec's own, and the spec-derived blocks (bodyExtra, bodyShape, operation, patch, onSuccess, resultDialog) z.unknown(). Refusals are measured too: type aliased to actionType (the objectui#7415 rename the forward at :177-187 documents), enabled (:130/:336, legacy fallback) and autoTrigger (:291; auto-trigger.ts:18-19 says it is not persisted metadata) refused with prescriptions, className a node key (:306), objectName not read anywhere in the four renderers at the pin (grep: test files only), so deferring it to the pin bump is correct. One nit, not a wrong accept: toast is left z.unknown() where ActionDef:264 types a small concrete object; a later ratchet, as section 4b already says of the forwarded blocks.
  4. action:icon, 25 keys — right. size is fixed to the primitive's icon at :106 and refused with a prescription; undoable/recordIdField are absent from the forward at :119-174; label :137/:243/:252/:258/:264, description :138/:264; visible :96 + :207, disabled :101 + :236; variant :105 maps primary, default ghost. The 18-key forwarded set in the test equals the literal at :132-173.
  5. action:group, 8 keys — right. actions is read as a list (:243, schema.actions || []) although the registration publishes type: 'object' (:381); location feeds actionRendersAt (:244; types/src/ui-action.ts:85-98 takes an ActionLocation and passes every member when it is undefined); display :309; label/icon are dropdown-only (:328/:313); variant has no primary map at group level (:319, and :360 to :88 maps only a member's own value); size maps md on the dropdown trigger only (:320) and reaches the Button primitive raw in inline mode (:361 to :89), so declaring the primitive's four sizes is the read; visible :233 + :306. No group-level name is read anywhere in action-group.tsx or action-menu.tsx (grep: zero schema.name hits; the only name reads are member action.name keys), while the registration publishes it (:378), so refusing it with a prescription is right. Option A on the dev's open question is the measured answer (③.1).
  6. action:menu, 6 keys — right. actions :299; label :341 and :350-351; icon :224; variant/size handed to the primitive unmapped (:225-226), so no primary/md; visible :219-222 with throwOnError and the gate at :293. The registration (:381-390) publishes neither size nor visible.
  7. actions members on group/menu as z.array(z.record(z.string(), z.unknown())) — right as the list shape, with the member left unjudged. The row is strict; the member is not a page component (the containers draw and run it themselves) and judging its keys would mean transcribing UIActionSchema, which triage forbade. A bare string is refused (the record:quick_actions name-list confusion), which the test pins. The member key set is a later ratchet, named here so nobody reads the row as having measured it.
  8. element:definition-list, 3 keys — right, and isomorphic. items optional (:48 guards with Array.isArray, :51-53 renders "No details" for absent and empty alike); item strict { term, description? } (:66/:68, toText), with label/value aliased to term/description (the objectui#8279 pair block-config.ts:288-300 records); columns the number literal 1 | 2 because :49 compares === 2, the designer writes a number (block-config.ts:306), and the registration's string enum (:82) is refused with a prescription that names the collapse to one column; inline :63. No default, transform, catch or pipe anywhere, so the alias pin is the correct ADR-0122 disposition.
  9. element:repeater, 8 keys — right. object required (:122 never queries without it; registration :190 agrees); titleField :170-171; fields a name or { field } (:116, :175), with label refused because RepeaterColumn.label (:93) is never rendered; filter ViewFilterRule[] reaches $filter (:131) and ObjectStackAdapter.find lowers the object-form array through translateFilterArray (data-objectstack/src/index.ts:4793-4804); sort SortItem[] reaches $orderby (:132) and serializeOrderBy (:772-777) serializes { field, order } items; limit positive int (:133); emptyText :160; divided :165. The Studio field-list control writes string[] (inspectors/PageBlockInspector.tsx:86), so designer-built repeaters pass the row. The aliases are a subset of ElementDataSourceSchema's (page.zod.ts:197-203) plus objectName. The bare z.array(ViewFilterRuleSchema) door (the record:related_list :1270 shape) rather than ruleArrayFilterError is right: RULE_ARRAY_FILTER_BLOCK_TYPES (conversions/registry.ts:10741) is the object-* family whose filter had a record-form past; this door never did.
  10. Strict from birth — right. All six rows and both nested items are strictObject; the strictness ledger counts move ui/ 180 to 188 sites, strict 170 to 178, component.zod.ts 48 to 56, which is exactly six rows plus two items.
  11. dropped-refinements.baseline.json entry — right. ui/ElementRepeaterProps at filter.element is the same site shape every ViewFilterRuleSchema door in the map carries; totals 211 to 212 schemas and 609 to 610 sites are one schema, one site.
  12. Alias pin — right. Iso_ui_component__ElementDefinitionListPropsSchema is the only isomorphic row of the six: the four action:* rows carry EvaluatedExpressionInputSchema (bare string normalized to the envelope) and the repeater carries ViewFilterRuleSchema (operator normalized), and each of those five declares XParsed. Count 780 to 781 was re-derived from the merged file after the two stacked merges; check:spec-parsed-alias is inside the green Lint & Repo Gates.
  13. The three ADR-0060 ledger rows — all three classes right at the pin. visible and disabled are node-gate chain keys (SchemaRenderer.tsx:249, :313), so every surface has two legs, as the rows say.
  • cel-action-block-visible-closed (button/menu): useCondition(..., { throwOnError: true }) at action-button.tsx:116-119 / action-menu.tsx:219-222 returns false on a throw and warns once (react/src/hooks/useExpression.ts:215-238); the renderers return null (:298 / :293). The node gate answers fail-soft true (SchemaRenderer.tsx:1133) and reports. The legs AND, so a faulting predicate hides the block: fail-closed.
  • cel-action-block-visible-soft (icon/group): useCondition without the option (action-icon.tsx:96, action-group.tsx:233) reaches evaluateCondition's catch, which calls onFault and returns true (core/src/evaluator/ExpressionEvaluator.ts:387-408); both legs show and only the node gate's report logs it: fail-soft-log. action-icon.tsx:197-198 names its own policy in those words.
  • cel-action-block-disabled (button/icon): the renderer leg returns true on a fault (:129 / :101) and the gate ORs it in (:333-339 / :235-241); the node gate's evaluateEnablementPredicate (:1772-1784) also answers true, which on this leg is greyed out (:1149-1156), and reaches the renderer as hostDisabled. The legs OR, so the action is refused: fail-closed. The covers keys name exactly the six positions the discovery found (the previous head's red, this head's green Dogfood Regression Gate (3/3)).
  1. Generated artefacts — additive. api-surface/export-origins +17 (6 schemas, 6 author types, 5 XParsed), json-schema.manifest +6, declaration-map +12, references index 1516 to 1522 and component.mdx +173; check:generated / check:api-surface / check:authorable-surface are inside the green Lint & Repo Gates.
  2. Tests — right shape. Key sets asserted whole per row; one objectui-sourced accepted specimen per type (the action:button node is objectui AGENTS.md:98 verbatim); unknown-key refusal per row; every measured decision above pinned; vocabulary admission with the typo control and the action: unreserved control.

② Semver level

@objectstack/spec minor with Clause-②: yes (no (widening)/(narrowing) arm, which the rule allows) matches the diff. What publishes: six new exported schemas and eleven type aliases from @objectstack/spec, six new ComponentPropsMap rows, two new members of the element: vocabulary. Nothing declared is retired or renamed, so no migration text and no ADR-0087 marker is owed. The accept set widens (two types refused as component-type-unknown are accepted; four types previously skipped by the props gate are judged at warning tier); the PR's A5 sweep found zero authored nodes of the six types in this repo, and I found none in content/docs outside the generated reference (the actions.mdx:215 hit is Action.component, a different declaration). The only other touched package, @objectstack/dogfood, is private: true and the change is a test ledger. @objectstack/lint's door behaviour moves only through its spec dependency; no lint source changed. Check Changeset is green. The PR body carries Clause-②: yes on its second line. Not a governed surface: the file list touches none of docs/adr/**, docs/NORTH-STAR.md, .claude/**, skills/**, AGENTS.md, CLAUDE.md; Governed Surface Queue Guard is green.

③ Boundary flags

  1. open_questions[0] — name on action:group / action:menu: answered, option A is right. Neither renderer reads schema.name at the pin (grep over both files: zero hits; action.name on members only, action-group.tsx:336/:358, action-menu.tsx:322/:362). Triage note 1 ("measure each row from the renderer's read points") and ADR-0049 (declared means enforced) both decide A. The group refuses it with a prescription because its registration publishes it (:378); the menu's registration does not (:381-390), so the generic unknown-key refusal there is enough.
  2. cel-action-block-disabled fail-closed against the existing cel-action-disabled fail-soft-log: answered right, and one part escalated. The split is correct: different declaration (ActionButtonPropsSchema.disabled vs actionObject.disabled), different path (page-component renderer plus node gate vs the registered-action surfaces), and the measured face at the pin is closed. Leaving the old row untouched is correct under the claim's surface. Escalated, not fixed here: the old row's face rests on a console CHANGELOG line about the empty-disabled fix, which visibility-gate.ts:21-29 confirms was about '' and empty envelopes, not faulting predicates; the member leaves of action:group/action:menu evaluate a registered action's disabled through the same useCondition without throwOnError (action-group.tsx:84/:167, action-menu.tsx:106), which greys out on a fault. So cel-action-disabled may be mis-measured for the same reason this PR's row is closed. Read-only inference, not reproduced. Carrier: a follow-up card on the dogfood ledger, filed by the seat, not this PR.
  3. Docs drift, content/docs/protocol/objectui/layout-dsl.mdx:736-742: answered, no edit needed. The callout is generic ("for the platform's own types the authoring rules dispatch ComponentPropsMap and reject a misspelled prop; a custom.* type has no entry there"); it names no type and no row, and the page mentions none of the six types. The claim becomes more true with six more rows. Its "reject" wording against the gate's warning tier predates this diff and is not this card's.
  4. Out-of-surface files: all four accepted as gate-forced companions, each minimal and correctly formed. dropped-refinements.baseline.json (build refuses to publish an undeclared dropped site; the entry is the build's printed form; hand-edited by that file's own design, so outside the claim's "generated" letter but inside its intent); type-alias-convention.pin.test.ts (forced by check:spec-parsed-alias, one pin, count re-derived); packages/qa/dogfood/test/expression-conformance.ledger.ts (forced by the Dogfood gate's checkLedger; classes verified in ①.13; tracker ids kept in // comments for check:doc-authoring); docs/audits/...counts.md (generated, counts consistent). The dev reported each as a deviation rather than stopping, and the seat amended the PR body with the third; that is the right handling for a companion a gate forces.
  5. KNOWN_COMPONENT_TYPES docblock (component-type-vocabulary.ts:81-84) — escalated. It still enumerates the string-arm rows "exactly" as element:metadata_viewer, the plugin widgets and object-*; the map now exceeds that list by six. The derivation is code and stays correct; only the prose lags, in a file the claim excluded. Carrier: the next edit of that file (a one-line docs-only change; the objectui follow-up's spec bump is the natural moment).
  6. objectName on the four action rows — answered, right not to declare. Not read at the pin; objectui origin/main forwards it, so it joins with the pin bump that carries that read.
  7. element:repeater.filter as a bare rule-array door — answered, right (①.9); wiring ruleArrayFilterError would require adding the type to RULE_ARRAY_FILTER_BLOCK_TYPES in the same change, which is a conversions decision outside this card.
  8. objectui-side findings — verified at the pin, carried to the objectui follow-up the seat files at landing (Blocked-by: #20371). Registration publishes an unread name and an unmapped md on action:group (:378, :395); columns enum is strings (data-list.tsx:82); fields[].label advertised and unrendered (:93, :192); action:menu spreads ...rest after disabled={loading} (action-menu.tsx:340-342), the objectui#9131 shape, which no carrier names yet: the seat should add it to that follow-up.

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 (Dogfood Regression Gate (3/3)) is green on this head, and mergeable_state reads clean.

Implemented-by: claude/issue-20371-component-props-action-element-rows
Reviewed-by: session_01B3TqpoQbTAfG7G74GMDWNW

VERDICT: PASS


Generated by Claude Code

… merged tree (keeps the docs title rule)

Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Regen-provenance: 5868826831 · 29fd4591e4c58735a265b1a19df759acd05471d5 → 28681fc5baabf169602c2de284b3ab99ebeeba1d · the PR's net diff of hand-written files, old head against its merge base vs new head against its merge base → (empty)

domain:spec seat 1 (session_01B3TqpoQbTAfG7G74GMDWNW) · 2026-09-28T12:19Z. The at-tier record 5868826831 (PASS at 29fd4591) carries over one pure-regeneration hop.

What the hop is: the dev's base-merge round 5869671220. bash scripts/pm/os-regen-merge.sh merged main e4d3f2ca6 (#20401, the title rule for the generated reference pages) as ded6f2066, and 28681fc5 regenerated the docs. No source file changed.

What the seat measured on the committed trees:

  • The merge bases are 50e273fd for the old head and e4d3f2ca for the new.
  • git diff --name-only BASE HEAD lists the same 14 files at both heads.
  • For each hand-written file, the added and removed lines hash identically at both heads (old base..old head against new base..new head): component.zod.ts 23b7188e, the row test e1d28ca8, the dogfood ledger 5b3fcccb, dropped-refinements.baseline.json 85944912, the changeset 4af65152. The pin test's hunk still reads 780→781.
  • The rest of the files that moved between the two heads are main's own movement plus the regenerated references. ui/component.mdx keeps docs(spec): generated reference pages follow the docs title rule, sidebar labels kept via navTitle #20401's title / navTitle and adds the six sections.

This line is a pointer; the queue guard re-runs the content test itself.

@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 28, 2026
…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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 5e50899a481dce659bc12ad2576b4fc534cd3893
Local-runs: none

Inputs: card #20371 (body and all seven comments, the round-4 report 5873527479 included), PR #20420 (body, 14-file list, net diff against its merge base 3cf644938, the patch round's own diff abf69011a..5e50899a4, the earlier record 5868826831, the Regen-provenance line and the audit comment 5871523278, all read as claims and re-tested), the check-runs on this head (polled until none was in progress), origin/main files by git show, and objectui in a scratch clone read with git show and never built or run: dd3f7e1be356 for every anchor below, and f8a9d0fb0596 only to diff the evaluators across the hop. The head did not move during the review; origin/main's .objectui-sha reads dd3f7e1be356 at review time; the PR is mergeable: clean, draft, auto-merge off.

① Derived judgments

Every public-surface change the diff implies, re-tested at the new pin:

  1. Re-anchoring is a re-measurement, not a sha rewrite — right. The patch round's own diff touches 174 lines of component.zod.ts, 18 of the ledger, 19 of the row test and 4 of the changeset, and every moved anchor I opened lands on the line it claims. Spot-checked per row at dd3f7e1be356: button :119 (schema.name ?? schema.label), :130, :134, :137-138, :176-179, :211-212, :216, :307, :335, :370-376, :383, :395-416; icon :97, :102, :106-107, :130-133, :147-148, :152-153, :195, :229, :257-263, :265, :274, :280-286; group :81-134, :91, :166-204, :238, :248-249, :274-280, :323, :343, :346, :350, :356-357, :365, :397-398, :415, :418, :432; menu :80, :108-147, :224-227, :229-231, :253-259, :313, :322, :328, :349-356, :369-371, :379-380, :410-419; static-params.ts:91-101, :142-148, :172-183; data-list.tsx:42-49, :51-53, :63-68, :75, :82, :93, :97-107, :119-120, :128, :143-146, :152-155, :181, :186, :191-196, :205, :213; public-blocks.ts:117-122; block-types.ts:136-137; block-config.ts:283-317 (:307 the number control); ActionRunner.ts:406; data-objectstack/src/index.ts:4782-4793 and :760-786; ui/button.tsx:19-35; auto-trigger.ts:18-19; objectui AGENTS.md:98. The two asserting citations (section 4b and the map rows) read .objectui-sha = dd3f7e1be, the first measurement is kept in the historical spelling, and check:objectui-pin-citations runs green inside Lint & Repo Gates on this head.

  2. objectName on action:button and action:icon, and on nothing else — right. The hop's renderer diff is exactly what the report says: the button and icon forward literals gained objectName: (schema as any).objectName (action-button.tsx:307, action-icon.tsx:195), and the two container handleExecutes gained objectName: (action as any).objectName off the MEMBER (action-group.tsx:323, action-menu.tsx:313). A grep of both container files finds no schema.objectName read at all, so a container-level key would be read by nothing, and declaring it would ship the declared-but-unenforced key ADR-0049 forbids. The row test pins all three halves (accepted on button/icon, accepted on a member, refused at container level). The value is z.string().optional(), the scalar ActionDef types it as (SpecActionInput['objectName'], ActionRunner.ts:406) and the shape every sibling row's objectName carries; the describe restates the renderer's own comment at :295-306 (dispatch target action.objectName, falling back to the page object). The regenerated authorable-surface/ui.json and component.mdx gain exactly the two lines.

  3. params keeps z.unknown() and its meaning — right. At the new pin readStaticParamValues (static-params.ts:91-101) reads the static values off properties.params and warns on a node-level object that is not the hoisted copy (:97-98). On a page node this row IS properties, so an array is still the input list (actionParams, :177-178 / :131-132) and an object is still the static values; the docblock now says so and the describe stays true. Nothing this row judges changed shape.

  4. element:repeater.filter through useResolvedFilter — right, no schema change. data-list.tsx:119-120 resolves context tokens in the rule array before $filter (:152); the value is still ViewFilterRule[], and a rule's string value already admits a token. The bare z.array(ViewFilterRuleSchema) door is the record:related_list (:1282) and picker (:1318) shape, not a ruleArrayFilterError door, and page-component-filter-record-to-rule-array.test.ts:500-503 derives RULE_ARRAY_FILTER_BLOCK_TYPES from the rows that refuse a record with that prescription, so the repeater correctly stays out of the conversion's reach and that test holds.

  5. The six key sets and value schemas otherwise — right and unchanged at the new pin. Button 29 keys (8 interpreted plus 21 forwarded: the literal :211-307 and the params payload); icon 26 (no size, :107 fixes icon; no undoable / recordIdField, absent from :147-195); group 8 (actions a list at :248 against the registration's type: 'object' at :418; no group-level name, published at :415 and read nowhere; size the primitive's four because inline mode hands it raw at :398 and :91 maps only a member's own md); menu 6 (variant / size unmapped at :230-231); definition-list 3 (columns === 2 at :49 against the string enum at :82; items optional, :48 / :51-53; item strict { term, description? }, :66 / :68); repeater 8 (object required, :143-146; fields a name or { field }, :128 / :196, label at :93 / :213 never rendered). Button primitive vocabulary confirmed at ui/button.tsx:19-35 (six variants, four sizes). openIn is 'self' | 'new-tab' (ActionDef:316), refreshAfter / undoable boolean (:270 / :272). Same nit as the earlier record: toast is z.unknown() where ActionDef:264 types a small object; a later ratchet, as section 4b says.

  6. Vocabulary admission of element:definition-list / element:repeater — right. KNOWN_COMPONENT_TYPES is the enum plus Object.keys(ComponentPropsMap) plus the string-arm ledger (component-type-vocabulary.ts:96-100), so the rows admit both with no enum member and no ledger entry. The three-part evidence holds at the new pin: registration data-list.tsx:75 / :205 (namespace element), publication public-blocks.ts:117-118 and the tracked sdui.manifest.json on main carrying all six, authorship block-types.ts:136-137 and block-config.ts:283-317. The element:repeatr control stays refused. The four action:* rows add dispatch, not vocabulary (action: is in no enum member; the test pins hasReservedComponentNamespace false).

  7. Dropped-refinements baseline — right. ui/ElementRepeaterProps at filter.element, 211 to 212 schemas, 609 to 610 sites: one schema, one site, unchanged by the merge.

  8. Type-alias pin — right. ElementDefinitionListPropsSchema is the only isomorphic row (a strict item of z.string() and z.unknown(), z.literal([1, 2]), z.boolean(), no default, transform, catch or pipe); the other five carry EvaluatedExpressionInputSchema or ViewFilterRuleSchema and declare XParsed. Count 780 to 781, re-derived from the merged file; check:spec-parsed-alias is inside the green Lint & Repo Gates, and tsc proves the pin.

  9. The three ADR-0060 ledger rows and their fault faces — right, and the "code-identical across the hop" claim is verified. I diffed the two pins: evaluateVisibilityPredicate (old :1048-1140 against new :1157-1249), evaluateEnablementPredicate (old :1138-1200 against new :1247-1309) and isDisabled (old :1772-1784 against new :1879-1891) are byte-identical; useExpression.ts differs in comments only; ExpressionEvaluator.ts is unchanged. At the new pin: button/menu visible use throwOnError: true (:117-120, :224-227), which returns false on a throw (useExpression.ts:215-238) and the block returns null (:335, :322), so the legs AND and the face is fail-closed; icon/group visible use useCondition bare (:97, :238), which reaches the evaluateCondition catch and answers true (ExpressionEvaluator.ts:387-408), and action-icon.tsx:219-221 names the policy, so fail-soft-log; button/icon disabled answer true on a fault (:130 / :102), the gate ORs it in (:370-376 / :257-263) and the node gate greys out (:1258-1265), so fail-closed. covers names the six positions; Dogfood Regression Gate (3/3) is green.

  10. Generated artefacts — additive against the merge base. api-surface / export-origins +17 (6 schemas, 6 author types, 5 XParsed), json-schema.manifest +6, declaration-map +12, authorable-surface +80 / 0 removed, references 1516 to 1522 (UI 159 to 165), strictness counts +8 sites (six rows plus two nested items: ui/ 180 to 188, component.zod.ts 48 to 56). check:generated, check:api-surface, check:authorable-surface, check:docs and check:strictness-ledger are inside the green Lint & Repo Gates.

  11. Tests — right shape. 45 tests: key sets asserted whole per row (objectName in FORWARDED), one objectui-sourced specimen per type (the button node is objectui AGENTS.md:98 verbatim), an unknown-key refusal per row, each measured decision pinned including the new objectName pin, and vocabulary admission with both controls.

② Semver level

@objectstack/spec minor with Clause-②: yes and no arm. Read with clause2-line.mjs's definition, yes takes at least minor, and the line is present in the changeset, on the PR body's third line and in the claim (5864633609). What widens: six new public rows and their exported schemas and types, two members of the element: vocabulary, and objectName on two rows (an optional key on rows that do not exist on main, so a widening of the accept set and of the authorable surface, a narrowing nowhere). Nothing declared is retired or renamed, so no migration text and no ADR-0087 marker is owed. The only other touched package, @objectstack/dogfood, is private: true. Not a governed surface (none of the 14 paths is a register row); Governed Surface Queue Guard and Check Changeset are green.

③ Boundary flags

  1. Deviation A — objectName on two rows rather than the four the order named: answered, the two-row reading is right. The order's condition was "if the pin carries it"; what the pin carries on the containers is a per-member forward (①.2). Declaring it on the container would be read by nothing, and the containers' registrations do not publish it, so the generic unknown-key refusal (pinned) is proportionate; a prescription like the group's name one is not owed because no producer writes it there.

  2. Deviation B — no quoted first lines added, ASSERTED_ANCHOR_FLOOR (7) unchanged: acceptable on this head, escalated. The gate's own design makes coverage "a ratchet, not a migration" and refuses a bulk fill, so an absent quote is not a gate breach. But section 4b now carries roughly a hundred anchors with zero machine-checkable content assertions, which is exactly the class that cost this PR a merge-queue ejection and a patch round, and the dev re-read every anchor by hand, the one moment the gate's docblock names for adding quotes. On this head my own re-read (①.1) is the compensating control. Carrier: a follow-up on the six rows' anchors (a quoted first line on the load-bearing anchors, and the floor raised by that number), filed by the seat or folded into the next pin bump; not this PR.

  3. The out-of-scope finding (readActionEntryParamValues returns an object params as the api payload until 18, static-params.ts:179-180): answered, no carrier owed here. It concerns a MEMBER's params, which the container rows deliberately do not judge (①.5), and it is objectui's own documented window (static-params.ts:162-166). Nothing in these rows should change.

  4. Earlier acceptance notes still open, each re-read at the new pin:

    • KNOWN_COMPONENT_TYPES docblock (component-type-vocabulary.ts:81-84 on main) still says "exactly" element:metadata_viewer, the plugin widgets and object-*; the map exceeds it by six. Prose drift in a file the claim excluded; the derivation is code and correct. Carrier: the next edit of that file. Escalated.
    • action:menu spreads ...rest after disabled={loading} (action-menu.tsx:369-371), and inline action:group spreads ...rest onto its wrapping div (:390): both still present at dd3f7e1be356. Read-only inference; no carrier names it yet. Escalated: the seat should add it to the objectui follow-up it files at landing.
    • cel-action-disabled may be mis-measured (the earlier record's ③.2): the member leaves still evaluate a registered action's disabled through useCondition without throwOnError (action-group.tsx:86 / :169, action-menu.tsx:108) and grey out on a fault, so that row's fail-soft-log face rests on the empty-disabled fix, not on a faulting predicate. Unchanged by this PR and correctly left alone under the claim's surface. Carrier: a follow-up card on the dogfood ledger, filed by the seat. Escalated.
    • element:repeater.filter as a bare rule-array door: answered right (①.4).
    • Docs drift, layout-dsl.mdx:736-742: the callout is generic (it names no type and no row) and becomes more true with six more rows; no edit owed.
    • toast as z.unknown(): a later ratchet, not a wrong accept.
  5. objectui producer-side notes carried by the objectui#10872 follow-up — verified at the new pin, still true, carried. The action:group registration publishes an unread name (:415) and a size enum with md (:432) that inline mode does not map (:398, :91); the element:definition-list registration's columns enum is the strings '1' / '2' (data-list.tsx:82) against the number compare (:49); element:repeater's type (:93) and registration description (:213) advertise fields[].label, never rendered (:194-196). Its registry-inputs-spec-parity gate will surface each on the spec bump. Carrier: the objectui follow-up the seat files at ACCEPT, Blocked-by: #20371.

  6. Fixes #20371 — right. The card asks for six measured rows (name optional where it is read, actions a list, rows strict from birth, the two element: types admitted on three-part evidence, no mirror of UIActionSchema or the object-metadata Action); every item is delivered on this head, and the cross-repo arming is triage note 3's separate carrier. The card this PR closes must claim this branch is green.

  7. NOT MEASURED — pnpm check:dual-build-cjs-loads (exit 3, needs a whole-repo build): covered. ci.yml:2116 runs it inside Build Core, which is green on this head. The CLI unit layer's 52 unloadable files (a round-1 reading, not re-run) are covered by the six green Test Core shards.

CI on this head: 42 check-runs, none in progress after re-polling (Check Changeset finished success): 38 success, 4 skipped (Auto Label first run, Check PR Size first run, Console Pin Gate, the opt-in tarball smoke), 0 failures. Type Check · source gates, the job that ejected the previous head on check:objectui-pin-citations, is green.

Implemented-by: claude/issue-20371-component-props-action-element-rows
Reviewed-by: session_01B3TqpoQbTAfG7G74GMDWNW

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 28, 2026 16:02
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
Merged via the queue into main with commit 75b2169 Sep 28, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20371-component-props-action-element-rows branch September 28, 2026 16:23
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
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>
This was referenced Sep 30, 2026
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
… — 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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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>
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/xl tests tooling

Projects

None yet

2 participants