Uh oh!
There was an error while loading. Please reload this page.
feat(spec): close the 31 SDUI component-props shapes against unknown keys (#4001 batch A) - #8008
Conversation
…keys (#4001 batch A) `ComponentPropsMap` — the declared shape of every `page:*`, `record:*`, `element:*`, `nav:*` and `ai:*` node a page can carry — stripped unknown keys in silence. All 31 object sites in `ui/component.zod.ts` are `strictObject` now, including the two union arms (`RecordHighlightsField`'s object arm and `record:related_list`'s sort entry) and every nested block, since strictness does not recurse. #5068 already REPORTED these keys, by walking a strip-mode object and reconstructing what the parse would have dropped. Now the parse rejects the key itself — same rule id, same warning tier, and three things the reconstruction could not give: curated per-surface prescriptions, a rejection that holds for every caller rather than only inside the gate, and coverage of union arms. Five renderer-honoured props are DECLARED rather than rejected, on the rule this file has applied three times before (#5611/#5775/#6276 — the delivered, authorized shape is the contract): `page:header` `maxVisible`/`mobileMaxVisible`, `page:tabs.alwaysShowStrip`, `record:details` `inlineEdit`/`showHeader`. Each is read by objectui through `schema?.X ?? schema?.properties?.X` with its own comment inviting authors. All optional with no schema default — the defaults are the renderer's, and declaring them would turn an unset key into an authored one. Curated tables, each backed by a producer measured in the wild rather than guessed: a tab item's `key` → `value` (objectui's Studio designer publishes `key`; the renderer reads `it.value`), a header's `description` → `subtitle` (the rename its own ADR-0087 conversion performs — the one path that had no diagnostic at all), a container's `body` → `children`, and a wrong-layer family for keys that belong on the component NODE. `@objectstack/lint` gains one piece of wiring: zod 4 collapses union-arm failures into a single `invalid_union`, so a lone arm's `unrecognized_keys` is unpacked back onto the unknown-key rule id — and deliberately is not when two arms could both have been meant. Deliberately unchanged: the carrier is still `z.record(z.string(), z.unknown())` (direction B stays declined, unregistered `record:*` types are still skipped), the storage path still parses no props (#4463), and the gate is still warning level. Verified with direct build-artifact `.parse()` probes over the example corpus — `objectstack validate` never parses through `PageSchema` (#5000), so "the examples validate clean" would have been no evidence. 244 registered props bags across the three example apps' build artifacts, the same three from source, and the three published platform pages: zero undeclared keys, zero new refusals. The probe's negative control moved from 0/244 injections caught to 244/244. Filed out of scope: #7973 (objectui's Studio designer publishes three page component inputs no renderer reads). Part of #4001 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012NLJ8PWLdwLSyi84LHzrAx
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 2 package(s): 106 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also reference the affected code. These are read-only:
|
…r rendered it Found by the #3746-style corpus scan this batch owes: every `content/docs/**` and `skills/**` code block carrying a registered component type was parsed and its props judged against the now-closed `ComponentPropsMap`. One hit, in the "Complete Example" of the pages guide — an `os:check` block, so it is an example the docs gate compiles and an AI author copies. `RecordHighlightsProps` has never declared `actions`, and objectui's `record-highlights.tsx` reads `schema.fields` and nothing else. Under strip the key was dropped in silence, which is precisely why a doc could teach it for this long: the example parsed clean and rendered without the actions, and no layer disagreed. With the shape closed it is a rejection, so the doc had to be right before the batch could land. The actions move to `record:quick_actions`, which is the component that renders them (`actionNames`, per objectui's own designer config) and which the same page already lists two sections up. Scan after the fix: 392 mdx + 2299 markdown files, 8 registered-type props bags, 0 teaching a key these schemas reject. `check:skill-examples` — 209 prose examples type-check. Part of #4001 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012NLJ8PWLdwLSyi84LHzrAx
…ch-a-component-strict
…s-regen driver's second half `authorable-surface/ui.json` carries `merge=os-regen`, so a merge resolves it by taking a side and leaving it REGEN-PENDING rather than by textually splicing two generated files. That is the driver working as designed; the half that must follow is the regeneration, and it is not optional here — measured on the merged tree before pushing, `check:authorable-surface` fails: ❌ authorable-surface/ is out of date (1 key(s) not recorded). + ui/GlobalFilter:object The missing key is main's own, from #7892 (`GlobalFilterSchema` gains an optional `object` for i18n label resolution). Nothing of this branch's is involved: the regenerated diff is exactly that one line, and batch A's five declared keys were already recorded. Worth stating because the failure mode is quiet: `pnpm build` REWRITES this artifact, so a `check:generated` run that happens to follow a build reports all 13 green against the file the build just fixed. Only `check:authorable-surface` on its own, against the merged bytes, shows the drift — which is how this was caught before the merge queue caught it. Merged tree verified: spec 381 files / 10091 tests, lint 71 / 1932, check:generated 13/13, check:strictness-ledger current (438 sites / 149 strip / 9 authorable — unmoved by the merge). The ledger counts and liveness counts were regenerated too and came back byte-identical. Part of #4001 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012NLJ8PWLdwLSyi84LHzrAx
…ch-a-component-strict
…ged with batch B Second turn of the os-regen relay. `#7985` (batch B — the memory driver's five persistence sub-shapes) and `#7972` landed while this branch was in the queue, so the shared counts artifact met two batches that each decremented it correctly and independently. That is the exact arithmetic #5107 built this artifact to prevent: the rows do not overlap, git merges them without complaint, and the SUBTOTAL — which overlaps nothing — merges clean and wrong. The artifact carries `merge=os-regen` so the merge defers rather than splices, and the only correct resolution is to recompute from the merged tree. Regenerated, never hand-edited: still-open (strip) 149 → 144 (batch B's five) files carrying at least one 26 → 25 authorable — forced scope 9 → 4 data/ strip 107 → 102, data/ strict 57 → 62 total strict 283 → 288 Neither batch's own numbers moved; the combined ones did. `check:strictness-ledger` agrees with the merged tree in both directions (25 open files / 144 strip sites, no closed file still carrying a worklist row — batch B's `driver/memory.zod.ts` row left with their side of the prose). Merged tree verified: spec 382 files / 10109 tests, lint 71 / 1932, check:generated 13/13. Part of #4001 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012NLJ8PWLdwLSyi84LHzrAx
os-zhuang
commented
Aug 12, 2026
Queue ejection resolved — ready to re-queue (I have not re-queued it myself; landing is the spec lane's to drive). The queue removed this for
One trap worth leaving in the thread, because it makes a green run meaningless: Merged tree at Generated by Claude Code |
os-zhuang
commented
Aug 12, 2026
Re-queue receipt (spec seat outgoing session, per the queue-rebuild-collateral prescription): this PR was Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Part of #4001 — the campaign card stays open as the program anchor.
ui/component.zod.ts, all 31 object sites,strip→strict. It was the single largest remaining chunk of the campaign's authorable surface, and the third batch to touch this file: 批 17 measured it asno gate(nothing parsed these schemas at all), #5068 wired the parse, and this closes the shapes behind it.What actually changes for an author
#5068 already reported these keys. It did so by walking a strip-mode object and reconstructing what the parse would have dropped. Now the parse rejects the key itself. Same rule id (
component-props-unknown-key), same warning tier — and three things a reconstruction could not give:aliases/guidancelive on the shape, not in the walker).Per-schema decision table
Every one of the 31 sites is closed; the column that carries judgement is why the closure is safe there, and what curated table came with it.
emptyProps(type)— the 7 no-prop types (app:launcher,nav:menu,nav:breadcrumb,global:search,global:notifications,user:profile,element:divider)EmptyPropsconst: an empty shape has no candidate keys, so the distance fallback can say nothing and the surface name is the entire diagnostic. Seven types share the shape; none may share a name. Still onestrictObject(AST site. Corpus authors{}on 9element:dividernodes — clean.PageContainerProps(page:section/page:footer/page:sidebar)body→children. The renderers keep readingbodyas a back-compat fallback for stored documents (#5775) — objectui's to retire on its own schedule, never a second authorable spelling (PD #12). Five edits, so the suggester cannot reach it.PageHeaderPropsdescription→subtitle(see the ADR-0087 note below). DeclaresmaxVisible/mobileMaxVisible.PageTabsPropsalwaysShowStrip.PageTabsProps.items[]key→value. Two measured producers: objectui's Studio designer publisheskey(previews/block-config.ts), and #5776 recorded the showcase doing the same. The renderer reads neither —containers.tsx:566takesit.value, falling back totab-<idx>— so an authoredkeysilently yields tab tokens that move when the item list changes.PageCardPropsvisiblewas the header's named live specimen; the wrong-layer set answers it (below). Corpus already clean.RecordDetailsPropsinlineEdit/showHeader.RecordDetailsProps.sections[]{name,label,columns,fields}only.RecordRelatedListProps…sort[](union arm)direction→order(the same aliasview.zod.ts:1333already ships for its own sort rows),name→field(the divergencevalidate-page-field-bindings.ts'sfieldRefsFromexists to handle).…add,…add.pickerobjectName→object: both are real keys on the same component, one level apart.RecordHighlightsFieldobject armfield→name(this arm keys its fieldname; columns/sort/filter key theirsfield). Follows #5583'sChartGroupBySchemaprecedent for closing a union arm.RecordHighlightsProps,RecordActivityProps,RecordChatterProps,RecordPathProps(+stages[], aliasname→value),PageAccordionProps(+items[], guidance below),AIChatWindowProps,ai:suggestionElement*PropsSchema(text,number,image,metadata_viewer,button,filter,form,record_picker,text_input)lazySchema(() => strictObject(…))— the wrapper is unchanged, so the ADR-0089 D3atoJSONSchema/Proxy hazard the campaign flagged is not touched.Cross-cutting: every top-level props schema carries the
COMPONENT_LEVEL_GUIDANCEsets — a visibility pattern (visible,visibleWhen,visibleIf,hiddenWhen, …) answered with "move it up to the node's ownvisibleWhen, ADR-0089", and an enumerated node-key list (id,events,style,className,responsiveStyles,dataSource,responsive) answered with "this belongs on the component NODE". The carrier hoistspropertiesonto the node before rendering, which is exactly what makes the confusion durable: the inner spelling appears to work for a renderer read, and is parsed by nothing that decides anything.The five newly DECLARED keys — judgement, not mechanics
The dispatch asked for declare-vs-guidance per key, measured. The measurement was to enumerate by the renderer's read pattern (the #6276 lesson), not by any previous ruling's key list — a sweep of objectui@
d8d0d66:page:header.maxVisible/.mobileMaxVisiblecontainers.tsx:1358—schema?.maxVisible ?? schema?.properties?.maxVisible,?? 3/?? 1page:tabs.alwaysShowStripcontainers.tsx:637—schema?.properties?.alwaysShowStrip === trueproperties.alwaysShowStrip: true"record:details.inlineEdit/.showHeaderrecord-details.tsx:234/257—schema.inlineEdit ?? true,schema.showHeader ?? falseinlineEdit: false"This is the #5611 / #5775 / #6276 rule applied a fourth time (the delivered, authorized shape is the contract). Closing the shapes around them would have converted an invited affordance into a rejection.
All five are optional with no schema default, deliberately: 3 / 1 / false / true / false are the renderer's fallbacks, and a schema default would materialize them on every parsed component — turning "the author said nothing" into "the author asked for the default", which is a different fact and the one a later liveness audit reads. Same reasoning the record picker's
limitdocblock already records for its own 50.The divergence pointing the other way → #7973
The same sweep found three Studio-designer inputs no renderer reads:
page:accordion.title,page:accordion.items[].value(the renderer overwrites it withpanel-<index>), andpage:header.icon(retired here at #6946). Those are producer-side defects in the sibling repo, filed as #7973 — not keys to declare. The accordion item'svaluecarries aguidanceentry saying the renderer derives it, not a rename: one component over,page:tabs'svaluereally is read, and collapsing the two would be the wrong answer on both.Union arms — the Zod-4 collapse (constraint 4)
zod-issue-format.tsalready unpacked collapsedinvalid_unionarms for the human message (#5583), so the surface name and the rename did reach the author. What was missing was the routing: a strict arm's rejection was filed ascomponent-props-invalidwith the generic value hint, while the identical fact on a plain object wascomponent-props-unknown-key.validate-component-props.tsnow unpacks a lone arm'sunrecognized_keysback onto the unknown-key rule (unrecognizedKeysFromUnionArm). The one-arm condition is the correctness of it: an arm rejecting a key does not mean the author meant that arm —fields: ['status']is a perfectly goodrecord:highlightsentry through the string arm — so the rename is claimed only when every other arm rejected the value for a different reason and exactly one arm got far enough to judge keys. Both directions pinned invalidate-component-props.test.ts.Before → after, same input:
Probe evidence — #5000 means "validate clean" is empty evidence
objectstack validatenever parses throughPageSchema, so the corpus was judged by direct.parse()against the built@objectstack/specartifact, over: the three example apps'dist/objectstack.jsonbuild artifacts (whatobjectstack buildactually emits), the same three from source throughdefineStack, and the three published platform pages.packages/lint's ownwalkPageComponents)#5068gate findings on the corpus.parse()The two refusals are one pre-existing case, reported twice (artifact + source): the showcase's
element:record_pickerbinding its object through the siblingdataSourcerather than the flatobjectshorthand — which the gate explicitly suppresses (suppliedByDataSource). Not a new refusal, and unchanged by this PR.The negative control is the load-bearing row. Before the conversion the direct-parse half was blind on every one of the 12 registered types the corpus exercises — which is the defect this batch fixes, measured rather than asserted. (Its denominator was fixed mid-run: it initially counted nodes of a registered type that carry no
propertiesbag at all, readingelement:divideras 6/9 "partly blind" when three nodes simply had no bag.)The docs half of the cost evidence — and the one hit it found
#3746 established that a strictness batch owes a scan of what the docs teach, not only what the corpus authors. Every
content/docs/**/*.mdxandskills/**/*.mdcode block carrying a registered component type was parsed and its props judged against the closed schemas.It was not zero.
content/docs/ui/pages.mdx's "Complete Example" — anos:checkblock, so an example the docs gate compiles and an AI author copies — taughtrecord:highlights.properties.actions.RecordHighlightsPropshas never declared it and objectui'srecord-highlights.tsxreadsschema.fieldsand nothing else, so under strip it was dropped in silence: the example parsed clean, rendered without the actions, and no layer disagreed. That is exactly how a doc teaches a dead key for this long. Fixed inef3afdb9c— the actions move torecord:quick_actions, which is the component that renders them and which the same page already lists two sections up.After the fix: 392 mdx + 2299 markdown files, 8 registered-type props bags, 0 teaching a key these schemas reject.
check:skill-examples— 209 prose examples type-check.Reverse verification — four sabotages, predicted direction
PageCardPropstoz.objectcheck:strictness-ledgerdemands the worklist row backmaxVisiblecheck:authorable-surfacered — a deleted key must prove itselfkey→valuealiasGates
@objectstack/spec381 files / 10037 tests ✅ ·typecheck(src + scripts + test layer) ✅ ·@objectstack/lint71 / 1932 ✅ ·check:generated13/13 ✅ (check:docs,check:api-surface,check:authorable-surface,check:strictness-ledger,check:livenessamong them) ·check:skill-examples✅ ·check:merge-driver✅ ·check:nul-bytes✅ ·check:spec-parsed-alias✅ ·check:adr-anchors✅ ·check:i18n✅.Blast radius:
turbo run typecheck --filter='...@objectstack/spec'— 124 / 124 tasks ✅ (the example apps rebuild inside it). Consumer suitesmetadata/metadata-core/metadata-protocol/sdui-parser/platform-objects/cli— 60 / 60 tasks ✅.Generated artifacts were regenerated, never hand-edited:
gen:strictness-ledger(ui/ strip 37 → 6; global authorable strip 40 → 9;component.zod.ts31 → 0),gen:liveness-counts(no movement),gen:schema(authorable-surface: +5 keys, 0 deletions, so the.base.jsonanchor is untouched),gen:docs. The regeneratedcomponent.mdxdiff is exactly the five new rows — the #3746 trap-1 description swap did not happen, checked rather than assumed. The ledger's hand-written half: theui/worklist row is deleted (the file is closed) and the closure recorded on the file-level triage row.Deliberately NOT in this PR
The carrier stays
z.record(z.string(), z.unknown())— direction B remains declined by the 2026-08-05 ruling, unregisteredrecord:*-style types are still skipped, not rejected, andcomponent.test.ts's two carrier assertions stay green on purpose. The storage path still parses no props (#4463's fourth wall). The gate is still warning level. And per the dispatch:FieldWidgetPropsSchema,BaseNavItemSchema, and the pinned wire arms elsewhere are untouched.Changeset:
@objectstack/specminor,@objectstack/lintpatch.