Repository navigation
finding: nothing mechanically compares the page-block designer's BLOCK_CONFIG field names against the node schemas — measured cost on objectui#7772, where a REQUIRED key had no control for three months #8216
Description
Found while landing objectui#7772 (branch claude/issue-7772-page-block-kanban-groupby-control). Filed unassigned and NOT touched there — objectui#7772's triage comment fenced this off explicitly ("Whether that gate should cover block-config is its own card"). Measured on origin/main 0c8dbc49.
What
packages/app-shell/src/views/metadata-admin/previews/block-config.ts (BLOCK_CONFIG) is the page-block inspector's field table: one entry per authorable property, and its own file header states the rule it lives by —
Keep each field
namealigned with the property name the corresponding renderer reads.
Nothing mechanical checks that.
scripts/check-designer-field-key-parity.mjsjudgesPAYLOAD_SHAPES— the field / object / permission payloads. Ran it on this branch: exit 0, and its own output names what it covers. It never readsBLOCK_CONFIG.apps/console/src/__tests__/registry-inputs-spec-parity.test.tsjudges the REGISTRYinputsdeclarations (objectui#8067 / objectui#8068), a different table on a different face.- What does exist is per-block prose pins inside
previews/__tests__/block-config.test.ts, written one at a time as defects were found:page:header.icon(objectui#3829),page:accordion.title+ itemsvalue(objectui#5212), theglobal_navoption (objectstack#6888), and nowobject-kanban(objectui#7772).
That is the same VOLUNTARY shape objectui#8068 argued was not good enough for the registry inputs: three keys did it right, the fourth did not, and nothing noticed the fourth. Here the pins are also voluntary, and each one exists only because a human or an agent tripped over the defect first.
Why this is worth a card rather than a note — the measured cost
objectui#7772 is not a stale-key defect. BLOCK_CONFIG['object-kanban'] offered four controls (objectName / groupField / titleField / cardFields) while ObjectKanbanSchema declared:
groupByREQUIRED (packages/types/src/objectql.ts:2765, no?;packages/types/src/zod/objectql.zod.ts:1001, no.optional()) — with no control at all;groupFieldaretirementTombstone()(objectql.zod.ts:1002) and?: neveron the TS face since objectui#7322 — as the only control able to set grouping.
Measured, not inferred. ObjectKanbanSchema.safeParse on the node those four controls produce reports TWO issues:
{ code: 'invalid_type', path: ['groupBy'], message: 'Invalid input: expected string, received undefined' }
{ code: 'invalid_type', path: ['groupField'], message: 'RETIRED (objectui#7322) - ... author `groupBy`. ...' }
So the panel could not author a valid object-kanban node however it was filled in. The control landed 2026-06-21 (objectui#1831, released in @object-ui/app-shell@17.5.0 and every build after), and groupBy became REQUIRED with objectui#7322 — through all of it every derived check stayed green, because nothing in the repo reads this table against a schema.
@objectstack/spec: PageSchema.safeParse does not descend into a block's properties at all — a page carrying properties: { zzzTotallyBogusKey: 1 } parses green, control run in the same script. The refusal lives only on the NODE face (@object-ui/types/zod), which the designer never invokes.
Suggested shape
BLOCK_CONFIG entries, how many field names are not accepted keys of the node schema the block validates against?
The join is not free, and scoping it is most of the work:
BLOCK_CONFIGis keyed by DESIGNER block type (object-kanban,page:header,element:button,record:details, ...), which is not one vocabulary — some map to@object-ui/typesnode schemas (ObjectKanbanSchema), some to@objectstack/spec/uiprops shapes (PageHeaderProps,RecordDetailsProps), and some to neither.- Blocks with no resolvable schema must be an EXPLICIT exemption with a reason and a card number, never a silent skip —
check-designer-field-key-parity.mjsalready has that idiom and it should be copied rather than reinvented. - The retired direction matters as much as the missing one: a tombstoned key is still a declared member, so an "is it declared?" check reads green while the parser refuses every value by name.
@object-ui/test-support'sisShapeKeyTombstonedis the existing judge for that andblock-config.test.tsalready uses it.
If the count is small, close the gaps and turn the gate on. If it is large, use the self-deleting exemption idiom. Either way the number belongs in the report.
p2 rather than a p3. It is also the half most likely to need exemptions, since an inspector need not expose every declared key (objectui#7772's triage records that as a product decision, not a contract gap).
⛔ Ablation is the acceptance criterion: break one block's alignment and the new gate must go red and name that block, with a positive control on the unmodified tree in the same run.
Related
objectui#7772 (the defect that measured this gap) · objectui#5761 (the same class one designer over — the FIELD designer; closed) · objectui#8067 / objectui#8068 (the same voluntary-pin argument for registry inputs; both closed) · objectui#3829, objectui#5212 (two earlier BLOCK_CONFIG keys that outlived their renderers, each found by hand)
Filed by the dev of objectui#7772, session session_01YBWFb5YgMU5dw8p2VKj16S.
Activity
claude commentedon Sep 7, 2026
Claim: PM loop round R46 —
domain:devx @ objectuiexecution seat.Session:
session_01FhBNJcLRZLe8M87VcUgpKrBranch:
claude/issue-8216-block-config-schema-parityWorktree:
/home/user/objectui-issue-8216Domain:
domain:devxFile surface:
packages/app-shell/src/views/metadata-admin/previews/__tests__/(a new parity test besideblock-config.test.ts) and, only if the measured gap is small,packages/app-shell/src/views/metadata-admin/previews/block-config.tsfor the closed gaps. ⛔ Notscripts/check-designer-field-key-parity.mjs, not the rootpackage.json, notpackages/types/**.Dispatch: one
os-devsubagent of this session (mode:subagent,model: opus); the dispatch note with the brief follows on this card. Labelspm:queue→pm:dispatchedand assignee set in one write, read back at 2026-09-07T07:44Z.Generated by Claude Code
claude commentedon Sep 7, 2026
Dispatched —
domain:devx @ objectuiexecution seat, PM sessionsession_01FhBNJcLRZLe8M87VcUgpKr, R46, 2026-09-07T07:46Z. Devs in flight 3/3 (#7882, #8195, this); PRs #8271 and #8276 are in landing. File surfaces disjoint: #7882 holds the rootpackage.jsonscripts block, so this gate is a vitest test inside the package (theregistry-inputs-spec-parity.test.tsprecedent), not acheck:*script.Dev. One
os-devsubagent of this session on branchclaude/issue-8216-block-config-schema-parity, worktree/home/user/objectui-issue-8216, baseorigin/mainatb6f821a8bor newer. Race re-read at 07:44Z: the newestClaim:on this card is5567120213, naming that branch.Brief, in one paragraph. Measurement first, per the card: for every
BLOCK_CONFIGentry resolve the schema the block validates against —@object-ui/typeszod node schemas,@objectstack/spec/uiprops shapes (say how a TS-only props type is judged, or exempt it explicitly), or no resolvable schema → explicit exemption with reason and card number in the idiom ofcheck-designer-field-key-parity.mjs(copied, ⛔ that file not edited). Report per block: field names not accepted by the schema (missing direction), names whose key is tombstoned (isShapeKeyTombstoned,test-support/src/spec-tombstones.ts:232), and — REPORT ONLY, not gated — schema-required keys with no control. Then the gate as a new test besideblock-config.test.ts: zero missing + zero tombstoned for every non-exempt block, exemptions self-deleting; gap ≤ ~5 names → close them inblock-config.tsand go strict, else ledger the residue with the count in the report. Acceptance is ablation on the committed tree: a renamed field name → RED naming the block; a tombstoned key (groupFieldonobject-kanban) → RED naming the tombstone; unmodified tree → GREEN, same run; restores proven bygit diff HEADempty. Changeset percheck-changeset-presenceifblock-config.tsmoves. PR first lineFixes #8216when strict, elsePart of #8216with the residue.Premise check (read 07:43Z on
b6f821a8b):block-config.ts,block-config.test.ts,block-config-i18n.test.tsand the parity script exist; #7772 is closed and its fix (PR #8217) is on main, so the card's live instance no longer exists — the positive control has to be planted, unless the census finds another. ⛔packages/types/**and@objectstack/specstay untouched; a wrong schema is a filed finding.Generated by Claude Code
claude commentedon Sep 7, 2026
os-dev-report
{ "issue": 8216, "status": "done", "branch": "claude/issue-8216-block-config-schema-parity", "pr": "https://github.com/objectstack-ai/objectui/pull/8282", "base_sha": "f20e57d621764ddd48f574d9d0d7af7ade8e4f87", "final_head": "27e838489ed0b0e52cb96fa0d6a463d6d53758ea", "premise_still_valid": true, "summary": "Landed the census and the gate as a vitest test in the package (previews/__tests__/block-config-schema-parity-8216.test.ts), not a root check:* script. Two DERIVED oracle tables, because BLOCK_CONFIG is keyed by designer block type and that is not one vocabulary: ComponentPropsMap[type] from @objectstack/spec/ui (42 rows, STRICT, the only face that refuses by name) and the @object-ui/types/zod component arms indexed by their type literal (104 arms, BaseSchema.passthrough(), the only face carrying retirementTombstone()). Both directions are judged (a name the oracle does not declare; a name it still lists but tombstones), nested array item controls included against the array element shape, and an unreadable element shape is reported rather than skipped. 22 blocks: 18 resolve to a spec props shape, 4 to a node arm, 3 to neither and carry a self-deleting exemption. Three live MISSING-direction names are ledgered with the card that owns each resolution; zero RETIRED. block-config.ts was NOT edited: none of the three is closable inside the declared file surface (one is upstream in @objectstack/spec, one in packages/types, one needs metadata-admin/i18n.ts plus a nested read-door decision), which is the same position check-designer-field-key-parity.mjs takes for the same reason. A changeset with EMPTY frontmatter was added because check-changeset-presence.mjs demanded one for a src/ file of a released package.", "census": { "blocks_total": 22, "bucket_types": { "count": 4, "blocks": [ "object-grid", "object-form", "object-kanban", "grid" ], "oracle": "@object-ui/types/zod component arms, indexed by the `type` literal each arm declares (registry DERIVED at run time, 104 arms resolved). BaseSchema is .passthrough(), so this face DECLARES and never refuses — a violation here is a declaration gap, reported under a distinct @node id." }, "bucket_spec_props": { "count": 18, "blocks": [ "object-grid", "object-form", "object-metric", "object-kanban", "element:text", "element:image", "element:number", "element:button", "page:header", "page:card", "page:tabs", "page:accordion", "record:related_list", "record:highlights", "record:details", "record:alert", "record:path", "record:quick_actions" ], "oracle": "ComponentPropsMap[type] from @objectstack/spec/ui (42 rows). STRICT zod objects: an undeclared key draws unrecognized_keys naming it, verified through safeParse in the test rather than inferred from the key set. This answers the card question about how a props TYPE is judged at run time: a zod mirror IS available and is what the platform component-props lint dispatches on, so no tsc-on-a-generated-literal route and no exemption is needed for this bucket.", "overlap_with_types": [ "object-grid", "object-form", "object-kanban" ] }, "bucket_exempt": [ { "block": "element:definition-list", "reason": "objectui-native element block: absent from PageComponentType and ComponentPropsMap, and no @object-ui/types/zod arm declares it. Its only declared face is the registry `inputs` list in components/renderers/basic/data-list.tsx, a different table judged by a different gate (objectui#8067/objectui#8068) that declares TOP-LEVEL inputs only.", "card": "objectui#8281" }, { "block": "element:repeater", "reason": "objectui-native element block: same absence on both faces, same registry-inputs-only declared face.", "card": "objectui#8281" }, { "block": "ai:input", "reason": "Not a spec PageComponentType, no ComponentPropsMap row, no zod arm, and no renderer beyond the opt-in PROTOCOL_COMPONENTS placeholder.", "card": "objectui#8281" } ], "exemptions_are_self_deleting": "each row asserts its block resolves to NEITHER oracle, so the moment one gains a schema the gate goes red and the row must go", "missing_direction": [ { "block": "object-kanban", "names": [ "limit" ], "face": "spec", "evidence": "ComponentPropsMap[object-kanban].safeParse({objectName,groupBy,limit:50}) yields success:false, unrecognized_keys naming limit; the same call without limit parses clean. The renderer honours it ($top: schema.limit ?? DEFAULT_KANBAN_LIMIT) and both @object-ui/types faces declare it, so the spec is the side that is behind.", "ledgered_to": "objectui#8172 (already open, exact match — NOT filed again)" }, { "block": "page:tabs", "names": [ "items[].key" ], "face": "spec", "evidence": "ComponentPropsMap[page:tabs].safeParse({items:[{key,label,children:[]}]}) yields unrecognized_keys naming key at items.0; the same item spelled `value` parses clean. The tabs renderer reads it.value with a tab-INDEX fallback and reads it.key nowhere.", "ledgered_to": "objectui#8278 (filed from this census)" }, { "block": "object-form", "names": [ "formType" ], "face": "node", "evidence": "not a declared key of the zod ObjectFormSchema mirror. The TS interface declares it, the spec declares it, and PageBlockCanvas reads it — so it rides BaseSchema.passthrough() unjudged rather than being refused.", "ledgered_to": "objectui#6152 (its UnmirroredDeclared table already carries objectql.zod.ts#ObjectFormSchema carrying formType — NOT filed again)" } ], "tombstoned": [], "tombstoned_note": "Zero. Both tombstone mechanisms are exercised as INSTRUMENTS so the zero is a reading: PageHeaderProps.icon (ADR-0087 D2 z.never() on a still-listed key) and ObjectKanbanSchema.groupField (retirementTombstone()), each paired with a live neighbour that must read back NOT tombstoned.", "required_direction_report": { "gated": false, "why_not": "objectui#7772 triage records an inspector need not expose every declared key as a product decision; the card asks for the number, not a gate.", "top_level": [], "top_level_count": 0, "nested": [ { "block": "page:tabs", "keys": [ "items[].children" ] }, { "block": "page:accordion", "keys": [ "items[].children" ] } ], "nested_note": "children is the canvas nesting slot — written by dragging a block into the tab or panel, never by the inspector. It is the one row a future REQUIRED gate would have to exempt.", "non_vacuity": "12 of the 25 (block, face) rows DO carry required keys (object-form@node objectName/mode, object-kanban@node objectName/groupBy, record:related_list@spec objectName/relationshipField, and nine more), all of which have controls — so the zero at top level is a measurement, not a broken probe." } }, "gate": { "mode": "ledgered", "residue": 3, "residue_names": [ "object-kanban.limit@spec", "page:tabs.items[].key@spec", "object-form.formType@node" ], "exemptions": 3, "ratchets_both_ways": "an unledgered violation is red; a ledger row that no longer applies (name accepted, or control gone) is equally red; a ledger row naming a control that does not exist is red; an exemption whose block now resolves is red.", "judged_population": "19 blocks, 68 top-level controls plus the item controls of 4 array fields; asserted non-trivial in the test itself so the gate cannot go vacuously green." }, "ablation": { "tree": "COMMITTED at 27e838489; block-config.ts mutated one leg at a time, restored with `git checkout HEAD -- ABSOLUTE_PATH` under trap EXIT INT TERM", "build_precondition": "none — the gate imports ../block-config relatively and both oracle packages through the root vitest alias table, so no dist is on the resolution path", "exit_capture": "redirect then $?, never through a pipe", "legs": [ { "leg": "0 positive control", "mutation": "none", "exit": 0, "evidence": "13 passed; tree byte-identical to HEAD" }, { "leg": "A MISSING / spec face", "mutation": "page:card.bordered renamed to borderedTypo", "on_disk_proof": "removed-anchor count 0, injected-anchor count 1", "exit": 1, "names": "page:card::borderedTypo@spec [MISSING] not a declared key (schema declares 7 keys)" }, { "leg": "B RETIRED / spec face", "mutation": "page:header regains the tombstoned icon", "on_disk_proof": "removed-anchor 1, injected-anchor 1", "exit": 1, "names": "page:header::icon@spec [RETIRED] declared but tombstoned — the parser refuses every value by name", "why": "icon is a LISTED key, so the MISSING channel stays green on it and only the tombstone judge can catch it — this is what proves the two channels are not one channel" }, { "leg": "C RETIRED / node face", "mutation": "object-kanban regains groupField", "on_disk_proof": "removed-anchor 1, injected-anchor 1", "exit": 1, "names": "object-kanban::groupField@node [RETIRED] ... and object-kanban::groupField@spec [MISSING] not a declared key (schema declares 13 keys)" } ], "restore_proof": "per leg by BLOB HASH, not by exit code: git hash-object equals HEAD:...block-config.ts = 25163b0022b2f647cef99141d00f771b65babcc3 after every leg, and git diff HEAD is empty. Final tree clean." }, "files_changed": [ "packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-schema-parity-8216.test.ts (new, 411 lines)", ".changeset/block-config-schema-parity-8216.md (new, empty frontmatter)" ], "gates": { "new test alone": "exit 0 — 13 passed", "pnpm exec vitest run packages/app-shell/src/views/metadata-admin/previews/": "exit 0 — Test Files 48 passed (48), Tests 557 passed (557)", "pnpm --workspace-concurrency=2 --filter @object-ui/app-shell^... build": "exit 0 (VERDICT command-exit 0, 123s under the shared verify lock)", "pnpm --filter @object-ui/app-shell type-check": "exit 0 — and it really reads the new file: tsconfig.test.json includes src/**/*.test.ts", "pnpm --filter @object-ui/app-shell lint": "exit 0 — 0 errors, 2920 pre-existing warnings package-wide; 6 no-explicit-any in the new file, matching the neighbouring block-config.test.ts pattern. pnpm lint:root deliberately ignores packages/*/**, so it does NOT cover this file and the package target is the right one.", "node scripts/check-changeset-presence.mjs": "exit 1 before the changeset (named the file and the package), exit 0 after — declares 1 changeset, EMPTY frontmatter, the explicit no-release pass", "node scripts/check-changeset-no-major.mjs": "exit 0", "node scripts/check-governed-queue-guard.mjs --test (both paths)": "exit 0 — NOT GOVERNED, 2 paths against 5 governed surfaces, none matched", "pnpm check:control-bytes": "exit 0 — 6580 tracked text files; plus a direct grep -naP scan of the new file, grep exit 1 = no control bytes", "pnpm check:unreferenced-sources": "exit 0", "CI": "in_progress — reported at draft-PR time per the dispatch; not waited on" }, "changeset": ".changeset/block-config-schema-parity-8216.md — EMPTY frontmatter (no release). Owed because a src/ file of a released package changed; test-only, so nothing to publish. No skip-changeset label applied: in this repo that label is inert and the empty changeset is the mechanism.", "filed": [ "filed as objectui#8278: page:tabs items write items[].key — PageTabsProps declares value, the renderer reads only value (index fallback), key is refused by name", "filed as objectui#8279: element:definition-list items write label/value while DefinitionListRenderer reads term/description — every designer-built list renders an empty term and a literal em-dash per row", "filed as objectui#8280: ai:input has a curated BLOCK_CONFIG panel and four i18n entries but is not in the palette, not a spec PageComponentType, and has no real renderer", "filed as objectui#8281: three BLOCK_CONFIG blocks resolve to no runtime-judgeable schema on either face — the structural card every exemption row in the gate cites, with four routes laid out", "NOT filed (deduped to an existing open card): object-kanban.limit is objectui#8172 verbatim; object-form.formType is a named row in objectui#6152 UnmirroredDeclared (objectql.zod.ts#ObjectFormSchema)" ], "dedup_channel": "REST list of all open issues (452 non-PR, 5 pages until a page returned 0, so the listing is complete rather than truncated) plus a local grep. Control term BLOCK_CONFIG had to hit and did (1 hit, this card). Near-misses read in full before deciding: objectui#8172 (exact duplicate), objectui#6152 (exact row), objectui#3912 (same box, different defect — validation affordance, not spelling), objectui#7804 (same passthrough exposure, handler-key population only).", "deviations": [ "block-config.ts NOT edited. The dispatch allowed closing a small gap and three is small, but none of the three is closable inside the declared file surface: limit needs @objectstack/spec, formType needs packages/types, and page:tabs.items[].key needs metadata-admin/i18n.ts (the field name is the last segment of its translation key) plus a read-door decision for stored documents nested inside items[], which stripRetiredBlockProps does not reach. Ledgered with a card each instead — the precedent check-designer-field-key-parity.mjs sets in its own header for exactly this situation.", "A changeset was added, outside the declared file surface, on the verdict of check-changeset-presence.mjs as the dispatch instructed.", "BOTH oracle faces are gated, with distinct violation ids. The card names the node schemas; the spec props shapes are the only face that can REFUSE, and the node arms are the only face carrying retirementTombstone(). Dropping either would make one of the two directions unmeasurable.", "The dispatch anticipated that a props TYPE might only exist as a TS type and would then need tsc-on-a-generated-literal or an exemption. Measured: it does not — ComponentPropsMap is a table of live STRICT zod objects, so that bucket is judged at run time and needs neither." ], "mcp_calls": "0 — every GitHub read and write went through repo-scoped REST; no MCP GitHub tool was called", "open_questions": [ { "question": "Should the REQUIRED direction be gated now that it measures 0 at top level and 2 nested?", "options": [ "A — leave it reported only, as the card asks and as this PR does", "B — gate it at top level with `children` on page:tabs/page:accordion item shapes as the one standing exemption" ], "recommendation": "A for now, and B is cheap to add later. Business need: nobody is blocked today — the number is 0. Long-term: B would have caught objectui#7772 three months earlier, which is the card own argument, so it is the more valuable half. AI-error resistance: B is the direction that stops a panel from being structurally unable to author a valid node, which is the strongest of the four axes here. Startup focus: gating a direction whose current value is 0 adds a ledger with one exemption and no live debt, so it is cheap but it is also not pulling anything today. The blocker on B is not the code — it is that objectui#7772 triage recorded not exposing every declared key as a PRODUCT decision, and a gate over a product decision gets waived. That decision needs restating before B lands." }, { "question": "Is objectui#8216 done, or does it stay open until the three ledgered names drain?", "options": [ "A — the PR says Part of and the card stays open as the umbrella for the residue", "B — the card is a tooling card, the tool exists and is ablation-proven, so it closes and objectui#8172/objectui#8278/objectui#6152 carry the residue" ], "recommendation": "B, and the PR is deliberately worded Part of so the merge does not decide this silently. Every ledgered name already has its own owning card and the ledger ratchets stale rows red, so the residue cannot be lost by closing the umbrella. Left to the PM because it is a routing call, not an implementation one." } ], "out_of_scope_findings": [ "filed as objectui#8278: page:tabs items[].key vs the declared items[].value", "filed as objectui#8279: element:definition-list label/value vs the renderer term/description", "filed as objectui#8280: ai:input orphan config panel", "filed as objectui#8281: three BLOCK_CONFIG blocks with no runtime-judgeable schema (the exemption bucket)" ] }Generated by Claude Code
claude commentedon Sep 7, 2026
LANDED and CLOSED —
domain:devx @ objectuiseat, PM sessionsession_01FhBNJcLRZLe8M87VcUgpKr, 2026-09-07T09:40Z.PR #8282 merged at 09:38:06Z as
72b84e62c; content probe on re-fetchedorigin/mainpassed (the gate test present with itsEXEMPTandLEDGERtables;block-config.test.tsas control).What is on main now: a parity gate over every
BLOCK_CONFIGblock — 22 blocks, 19 judged (18 against@objectstack/spec/ui's strictComponentPropsMap, 4 against the@object-ui/types/zodarms, 3 overlapping) and 3 exempt with a reason and card (#8281). Both the MISSING and the RETIRED direction are gated; three live MISSING names are ledgered to their owning cards (object-kanban.limit→ #8172,page:tabs.items[].key→ #8278,object-form.formType→ #6152) and the ledger reds when a row stops applying, so the residue cannot be lost. Ablation-proven in three legs.Why the card closes now rather than waiting for the residue: it is a tooling card — "nothing mechanically compares" — and the tool exists, is on main, and ratchets; every ledgered name has its own card. Closed as completed;
pm:dispatchedremoved and assignee cleared in the same write.Routed: the REQUIRED direction (0 top-level / 2 nested, reported not gated, pending a restated #7772 product decision) → #8306 (
finding). Dev-filed during this work: #8278 #8279 (already triaged todomain:ui) #8280 #8281.Generated by Claude Code