test(metadata-admin): pin BLOCK_CONFIG field names to the schemas their blocks are judged by - #8282
Conversation
…ir blocks are judged by `previews/block-config.ts`'s header states the rule — "keep each field `name` aligned with the property name the corresponding renderer reads" — and nothing mechanical checked it. `check-designer-field-key-parity.mjs` judges the field / object / permission payloads and never reads this table; what covered BLOCK_CONFIG was a per-block prose pin in `block-config.test.ts`, each written after a defect was found by hand (objectui#3829, objectui#5212, objectstack#6888, objectui#7772). Two derived oracles, because the table is keyed by designer block type and that is not one vocabulary: `ComponentPropsMap[type]` from `@objectstack/spec/ui` (strict — the face that refuses by name) and the `@object-ui/types/zod` component arms indexed by their `type` literal (passthrough — the face carrying `retirementTombstone()`). Neither table is hand-listed. Both directions are judged: a name the oracle does not declare, and a name it still lists but tombstones. Nested array item controls are judged against the array element shape, and an unreadable element shape is reported rather than skipped. Census over 22 blocks: 18 resolve to a spec props shape, 4 to a node arm, 3 to neither and carry a self-deleting exemption (objectui#8281). Three live violations are ledgered with the card that owns each resolution — `object-kanban.limit` (objectui#8172), `page:tabs.items[].key` (objectui#8278), `object-form.formType` (objectui#6152) — and the ledger ratchets both ways. Zero blocks have a required schema key with no control; that direction is reported on the card, not gated (objectui#7772 records it as a product decision). Part of objectui#8216 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
|
ACCEPT (#8216, measurement + gate) — reviewed on the tree at head Verified from the diff, not the report:
Routing decisions (the dev's two open questions): the card CLOSES when this lands — it is a tooling card, the tool exists and is ablation-proven, every ledgered name has its owning card and the ledger cannot lose one silently. The REQUIRED-direction gate is filed as its own Next: CI convergence → flip ready → post-flip guard → auto-merge SQUASH → queue confirmation → landing probe (the test file present on re-fetched Generated by Claude Code |
|
Armed: PR #8282 flipped ready at 2026-09-07T08:29:40Z on head Generated by Claude Code |
|
LANDED: merged at 2026-09-07T09:38:06Z as Generated by Claude Code |
Part of objectui#8216 — the census and the gate land here; three ledgered names and the exemption bucket keep their own cards, so this does not settle that card on its own.
What this is
A vitest gate in the package (
previews/__tests__/block-config-schema-parity-8216.test.ts), not a rootcheck:*script — theapps/console/src/__tests__/registry-inputs-spec-parity.test.tsprecedent, and the rootpackage.jsonscripts block is held by another in-flight PR.block-config.ts's own header states the rule this mechanises — "keep each fieldnamealigned with the property name the corresponding renderer reads" — and nothing checked it.check-designer-field-key-parity.mjsjudges the field / object / permission payloads and never reads this table. What coveredBLOCK_CONFIGwas a per-block prose pin inblock-config.test.ts, each written AFTER a defect was found by hand: objectui#3829, objectui#5212, objectstack#6888, objectui#7772.The census — measurement first, per the card
22 blocks. Both oracle tables are read whole and DERIVED; neither is hand-listed, so a block that gains or loses a schema changes the verdict with no edit here.
ComponentPropsMap[type], STRICT — refuses by name)page:*/record:*/element:text/element:image/element:number/element:button, plusobject-grid/object-form/object-metric/object-kanban@object-ui/types/zod,BaseSchema.passthrough()— declares, never refuses)object-grid,object-form,object-kanban,gridobject-grid,object-form,object-kanbanelement:definition-list,element:repeater,ai:inputJudged population: 19 blocks, 68 top-level controls plus the item controls of 4 array fields.
The node table is derived by reading the
typeliteral off every exported Zod object (104 arms resolved); the spec table has 42 rows.PageComponentTypehas 34 members andComponentPropsMapcarries 8 more that are not in it, which is why the two tables are read separately rather than joined.MISSING direction — 3 names, all ledgered
object-kanban.limitunrecognized_keysnaminglimitpage:tabs.items[].keyunrecognized_keysnamingkeyatitems.0object-form.formTypeEach takes a DIFFERENT correct resolution — one is upstream in
@objectstack/spec, one is a control rename plus two locale tables plus a nested read-door decision, one is apackages/typesmirror edit — so none is a rider on a tooling PR. That is the positioncheck-designer-field-key-parity.mjs's own header takes for the same reason. Two of the three were already owned by open cards before this run; the dedup that established that is recorded below.RETIRED direction — 0 names
No control writes a tombstoned key. Both tombstone mechanisms are exercised as instruments rather than assumed:
PageHeaderProps.icon(ADR-0087 D2, a listed key replaced byz.never()) andObjectKanbanSchema.groupField(retirementTombstone()), each paired with a live neighbour that must read back as NOT tombstoned.REQUIRED direction — measured, reported, NOT gated
The card asks for it and asks that it not be gated (objectui#7772's triage records "an inspector need not expose every declared key" as a product decision). Measured across all 25 (block, face) rows:
childrenis required onpage:tabs.items[]and onpage:accordion.items[]and has no item control in either. That key is the canvas's nesting slot, written by dragging a block into the tab or panel, not by the inspector. It is the one row a future REQUIRED gate would have to exempt.Non-vacuity for that measurement: 12 of the 25 rows DO carry required keys (
object-form@node: objectName/mode,record:related_list@spec: objectName/relationshipField, and ten more), so the zero is a reading rather than a broken probe.Ablation — the acceptance criterion
Run on the COMMITTED tree at
27e838489, mutatingblock-config.ts, one leg at a time, each restored withgit checkout HEAD -- ABSOLUTE_PATHunder atrap ... EXIT INT TERM. No build step is involved: the gate imports../block-configrelatively and the two oracle packages through the root vitest alias table, so nothing resolves through adist. Exit codes captured by redirect, never through a pipe.page:card.borderedrenamed toborderedTypopage:card::borderedTypo@spec [MISSING] not a declared key (schema declares 7 keys)page:headerregains the tombstonediconpage:header::icon@spec [RETIRED] declared but tombstoned — the parser refuses every value by nameobject-kanbanregainsgroupFieldobject-kanban::groupField@node [RETIRED] …plusobject-kanban::groupField@spec [MISSING] …Leg B is the one that proves the retired channel is not subsumed by the missing one:
iconis a LISTED key ofPageHeaderProps, so the missing-direction check stays green on it and only the tombstone judge can catch it.Restore verified per leg by BLOB HASH, not by an exit code:
git hash-objecton the file equalsHEAD:…block-config.ts=25163b0022b2f647cef99141d00f771b65babcc3after every leg, andgit diff HEADis empty. An empty hash reads as failure, not as "nothing to compare".Gates, with exit codes
pnpm exec vitest run packages/app-shell/src/views/metadata-admin/previews/pnpm --filter '@object-ui/app-shell^...' build(dependency closure first)pnpm --filter @object-ui/app-shell type-checkpnpm --filter @object-ui/app-shell lintno-explicit-anyin the new file, the same pattern the neighbouringblock-config.test.tscarries)node scripts/check-changeset-presence.mjsnode scripts/check-changeset-no-major.mjsnode scripts/check-governed-queue-guard.mjs --testboth changed pathspnpm check:control-bytespnpm check:unreferenced-sourcestype-checkreally does read the new file:packages/app-shell/tsconfig.test.jsonincludessrc/**/*.test.ts.pnpm lint:rootdeliberately ignorespackages/*/**, so the package's ownlinttarget is the one that covers this file and it is the one that was run.packages/app-shellis@object-ui/consoleplus two examples' dependency, soturbo ls --affectednames four packages; nothing imports a test file, so the edge is the dependency graph rather than this change.Note on the toolchain:
pnpm --filter X build --concurrency=2forwards the flag to the package script andtsupdies on it. The working spelling ispnpm --workspace-concurrency=2 --filter X build, before the filter.Findings filed from the census (unassigned,
findingonly)Deduped against all 452 open issues listed over REST plus a local grep, with
BLOCK_CONFIGas the control term that had to hit and did.page:tabsitems writekey;PageTabsPropsdeclaresvalueand the renderer reads onlyvalue, falling back to an index-derived id. The author's stable tab identifier lands under a key nothing reads, which is the objectui#2257 addressability problem returning through the designer.element:definition-listitems writelabel/value;DefinitionListRendererreadsterm/description. Every definition list built in the designer renders an empty term and a literal em-dash for every row, withitems.lengthnon-zero so the renderer's own empty state never appears. Found by reading, precisely because this block is one of the three the gate cannot see.ai:inputhas a curated panel and four translation entries while being absent from the palette, fromPageComponentType, fromComponentPropsMapand from every real renderer.BLOCK_CONFIGblocks resolve to no runtime-judgeable schema on either face, with the four routes laid out. Every exemption row in this PR cites it.Two candidate findings were NOT filed because the dedup found them already owned:
object-kanban.limitis objectui#8172 verbatim, andobject-form.formTypeis a named row in objectui#6152'sUnmirroredDeclaredtable (objectql.zod.ts#ObjectFormSchema).Deviations from the dispatch, stated
block-config.tsis NOT edited; all three names are ledgered instead. The dispatch allowed closing a small gap (three is small). None of the three is closable inside the declared file surface:limitneeds@objectstack/spec,formTypeneedspackages/types, andpage:tabs.items[].keyneedsmetadata-admin/i18n.ts(the fieldnameis the last segment of its translation key, so a rename moves an entry in both locale tables) plus a decision about documents already carrying the old spelling nested insideitems[], whichstripRetiredBlockPropsdoes not reach. Ledgering with a card each is this repo's own precedent for exactly that situation.check-changeset-presence.mjsdemanded one for asrc/file of a released package. It has EMPTY frontmatter — the explicit no-release declaration AGENTS.md calls a first-class pass. Noskip-changesetlabel was applied; in this repo that label is inert and the empty changeset is the mechanism.retirementTombstone(). Dropping either would make one of the two directions unmeasurable, so both are read and each violation says which face reported it.What this PR does not claim
PageComponent.propertiesisz.record(z.string(), z.unknown()), so a page document carrying a refused block prop still PARSES — the card measured that and it is still true.ComponentPropsMap[type]is reached by dispatching on the siblingtype, which is what the platform's component-props lint does and no schema can express. So a spec-face violation here means "the platform's component-props lint reports this key", not "the save returns 422".Base
f20e57d62; head27e838489. Sessionsession_01FhBNJcLRZLe8M87VcUgpKr.Generated by Claude Code