Skip to content

feat(types): declare the 13 renderer-read keys that no shipped type declared - #6945

Merged
os-sam merged 3 commits into
mainfrom
claude/issue-6150-undeclared-but-consumed-keys
Aug 31, 2026
Merged

feat(types): declare the 13 renderer-read keys that no shipped type declared#6945
os-sam merged 3 commits into
mainfrom
claude/issue-6150-undeclared-but-consumed-keys

Conversation

@os-sam

Copy link
Copy Markdown
Collaborator

Fixes#6150

⚠️ Contract-review tier (needs:contract-review). Staying in draft; not marked ready, not enqueued, no auto-merge.

⚠️ Generics are written in words below, not in angle brackets — the body sanitizer eats anything tag-shaped, including inside backticks (the card itself says so).

Head this PR's evidence was measured on: 065b16b98. Base: 40c479af2.


⛔ Read this before the table: the accept set did NOT widen in the key dimension

The dispatch order asked me to establish this per schema rather than assume one answer, and to say so plainly if passthrough meant the before-state already accepted. It did. Measured, not assumed:

All 8 touched mirrors extend the zod BaseSchema, which ends .passthrough(), and .extend() carries that policy through. Read off the built mirrors, catchall is z.unknown() on all 8. So before this PR every one of the 13 keys already parsed green and already SURVIVED the parse — admitted unexamined, neither refused nor stripped.

So the PR does not claim an accept-set change it did not make. What it actually changes is two things:

  1. Declaration. The key becomes a declared member of the shipped TypeScript type. Today these reads compile only because BaseSchema ends with [key: string]: any (finding(types): BaseSchema's [key: string]: any leaves every component schema open, so a "declare the surface" fix can never reject a misspelled TOP-LEVEL key #5155), so schema.trigger on a type that never declared trigger resolves to any.
  2. Enforcement — and in the VALUE dimension this is a NARROWING, not a widening. For the 12 keys that gained a zod mirror entry, the value is now validated: { type: 'text', content: 42 } parsed green before and is refused at content now. That is a behaviour change for documents carrying a wrong-typed value under one of these 13 names, and it is the point — declared === enforced.

Because acceptance cannot tell "declared" from "admitted unexamined" under passthrough, membership is asserted on the mirror's own .shape, never on parse acceptance — the form object-grid-title-mirrored.test.ts established for #6639.


Per-key before / after — the table triage asked for

Probed by parsing real documents through the builtpackages/types/dist/zod/index.zod.js in two worktrees: 40c479af2 (before) and 065b16b98 (after). Control document per type = required keys only, carrying none of the 13.

keyin mirror .shapedoc carrying the keydoc carrying a WRONG-TYPED valuecontrol docundeclared-key control
TextSchema.contentfalse → trueaccepted+survives → accepted+survivesaccepted → refused at contentgreen → greenadmitted → admitted
CarouselSchema.optsfalse → trueaccepted+survives → accepted+survivesaccepted → refused at optsgreen → greenadmitted → admitted
CarouselSchema.orientationfalse → trueaccepted+survives → accepted+survivesaccepted → refused at orientationgreen → greenadmitted → admitted
CarouselSchema.itemClassNamefalse → trueaccepted+survives → accepted+survivesaccepted → refused at itemClassNamegreen → greenadmitted → admitted
FilterBuilderSchema.wrapperClassfalse → trueaccepted+survives → accepted+survivesaccepted → refused at wrapperClassgreen → greenadmitted → admitted
TreeViewSchema.nodesfalse → trueaccepted+survives → accepted+survivesaccepted → refused at nodesgreen → greenadmitted → admitted
TreeViewSchema.titlefalse → trueaccepted+survives → accepted+survivesaccepted → refused at titlegreen → greenadmitted → admitted
TreeViewSchema.onNodeClickfalse → false (deliberate)accepted+survives → accepted+survivesaccepted → accepted (no mirror, so no enforcement)green → greenadmitted → admitted
CheckboxSchema.requiredfalse → trueaccepted+survives → accepted+survivesaccepted → refused at requiredgreen → greenadmitted → admitted
FileUploadSchema.buttonTextfalse → trueaccepted+survives → accepted+survivesaccepted → refused at buttonTextgreen → greenadmitted → admitted
FileUploadSchema.wrapperClassfalse → trueaccepted+survives → accepted+survivesaccepted → refused at wrapperClassgreen → greenadmitted → admitted
HoverCardSchema.alignfalse → trueaccepted+survives → accepted+survivesaccepted → refused at aligngreen → greenadmitted → admitted
ContextMenuSchema.triggerfalse → trueaccepted+survives → accepted+survivesaccepted → refused at triggergreen → greenadmitted → admitted

"Widened only what I meant to", per schema: the rightmost column is that proof. On all 8 mirrors, a document carrying an undeclared key of the same wrong type is still admitted and still survives the parse — before AND after. The unknown-key policy of every touched mirror is exactly what it was. This is also pinned per key in the test file, so it cannot silently stop being true.

One extra measurement, because declaring nodes invites a wrong inference: a { type: 'tree-view', nodes: [...] } document with no data is REFUSED at data, before and after. data stays required on both faces. Declaring nodes records the read; it does not by itself make a nodes-only document legal. Relaxing data is an accept-set change and a separate ruling.


Premise re-derived, not inherited

Module resolution path, stated before measuring.packages/types/dist did not exist in the fresh worktree, so I built it (pnpm --filter @object-ui/types build, verdict command-exit 0) and measured through packages/types/dist/index.d.ts — the census's own path, derivePackageTypePaths() style — and independently through packages/types/src/index.ts. Declared-member sets for all 8 types were identical between dist and src, so no stale-build reading is in play. Members were resolved with the TypeScript compiler API (getPropertiesOfType), which sees inherited members and reports the string index signature separately, so [key: string]: any could not mask the answer.

Result: 13/13 still undeclared on base. After the change, re-measured through the rebuilt dist/index.d.ts: 13/13 declared, index signature still present on all 8 (so nothing about BaseSchema moved).

Every read re-verified at its cited site. All 13 reads are still present. One line number drifted; the read is the fact:

keycensus saidmeasured now
TextSchema.contentbasic/text.tsx:51,56:51,56 — unchanged
CarouselSchema.opts / orientation / itemClassNamecomplex/carousel.tsx:23 / 24 / 30unchanged
FilterBuilderSchema.wrapperClasscomplex/filter-builder.tsx:37unchanged
TreeViewSchema.nodes / onNodeClick / titledata-display/tree-view.tsx:105 / 98,99 / 115,117unchanged
CheckboxSchema.requiredform/checkbox.tsx:45,49unchanged
FileUploadSchema.buttonText / wrapperClassform/file-upload.tsx:123 / 78unchanged
HoverCardSchema.alignoverlay/hover-card.tsx:24unchanged
ContextMenuSchema.triggeroverlay/context-menu.tsx:64:95 — drifted, read intact

Nothing was dropped. No key's reader had been removed.

READ vs INVOKED.TreeViewSchema.onNodeClick is invokedif (schema.onNodeClick) then schema.onNodeClick(node), with node the clicked TreeNode and the return value discarded. Its declared type is therefore the call signature (node: TreeNode) => void, not a value shape, and it is pinned as such with an invariant equality assertion. The other 12 are value reads.


Three declarations that are not "declare what is read" on autopilot

Each says so in its own doc comment; all three are flagged for the contract reviewer.

  • CarouselSchema.opts is an OPEN record (string keys, unknown values), not the docs page's two-key shape. The renderer forwards the whole bag verbatim to embla (opts={schema.opts}), so every other embla option authored today reaches the library and works. Declaring the documented pair would refuse those documents — an accept-set narrowing on a published surface, which is a ruling, not a declaration. The card itself flagged this key as the one that "may want a narrower shape"; I declined to narrow it without a ruling.
  • ContextMenuSchema.trigger is OPTIONAL although the docs page shows it required. The renderer substitutes a placeholder node when it is absent, so every trigger-less document is legal today and declaring it required would refuse them.
  • TreeViewSchema.onNodeClick gets NO zod mirror. A function cannot appear in an authored JSON document, so it is a runtime slot; 121 declared-but-unmirrored keys across 16 schema pairs — the lane #6058's new UnmirroredDeclared ledger made visible #6152 ruled that class is never mirrored and is recorded in zod-mirror-parity.test.ts's RuntimeOnlyDeclared instead — the step-3 exception that file's own header spells out. This is the first pair to sit in RuntimeOnlyDeclared without also sitting in UnmirroredDeclared, so that file's two population counts move with it (6 entries / 23 keys to 7 / 24; the "no entry in either" population 142 to 141). ⛔ UnmirroredDeclared is shrink-only and was not touched.

Two of the 13 declare a second spelling for a slot that already has onecontent beside value, nodes beside data — because that is what the renderers read (schema.content || schema.value, boundData || schema.nodes || schema.data). AGENTS.md #0.1's "one strict contract beats N dialects" argues for retiring one of each pair; that is an ADR-0049 enforce-or-remove question and deliberately not decided here. Each doc comment names which spelling wins.


Ablation — direction predicted IN WRITING before the run

Predicted before anything touched disk (mutating the fact, not the assertion): delete the content shape entry from the TextSchema mirror, leaving the TS declaration in place.

  1. membership assertion → RED
  2. wrong-typed-value refusal → RED
  3. "accepts the declared value and it SURVIVES" → STAYS GREEN — the counter-intuitive half, and the whole reason membership is read off .shape: under passthrough an undeclaredcontent: 'hello' is still accepted and still survives, so a suite measuring only acceptance would read this ablation as fully green
  4. read-site assertion and both control assertions → GREEN
  5. the other 12 keys → GREEN
  6. verdict: exactly 2 failed | 75 passed (77)
  7. second instrument: zod-mirror-parity.test.ts's compile-time half reddens naming layout.zod.ts#TextSchema; its runtime census (5 tests) does not move, by that file's documented design

Observed, on 065b16b98, all seven as predicted:

MUTATION PROVEN ON DISK: anchor 1->0 and hash d264a81ccb08838670e9e596ef3d407e3625855a != HEAD blob a96fce18feff5bf04102b70adaafa5b2e82600a8
vitest exit = 1
Test Files 1 failed (1)
Tests 2 failed | 75 passed (77)
x TextSchema.content > is a member of the mirror shape (membership cannot be read off acceptance under passthrough)
x TextSchema.content > refuses a wrong-typed value AT the key - the enforcement mirroring adds
tsc -p tsconfig.test.json exit = 1
src/__tests__/zod-mirror-parity.test.ts(1240,14): error TS2322: Type '"layout.zod.ts#TextSchema"' is not assignable to type 'never'.
RESTORE PROVEN: git diff HEAD empty AND hash a96fce18feff5bf04102b70adaafa5b2e82600a8 == HEAD blob a96fce18feff5bf04102b70adaafa5b2e82600a8

Mechanics: absolute paths from git rev-parse --show-toplevel, restore via git checkout HEAD -- ABSOLUTE_PATH (never bare) under trap ... EXIT INT TERM, mutation proven by anchored count 1 to 0 and a git hash-object differing from the HEAD blob, restore proven both ways. Resolution path for the ablation: both instruments read SOURCE — the pin file imports '../zod/layout.zod' relatively and tsc -p tsconfig.test.json compiles src/, so dist is on neither path and no rebuild is part of it.


Gates — exit codes captured before any pipe, verdict lines quoted

gateresult
pnpm --filter @object-ui/types type-checkechoed tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json; lock verdict command-exit 0
root-form pnpm exec vitest run packages/types/ — BEFORE (40c479af2)Test Files 75 passed (75) / Tests 861 passed (861)
root-form pnpm exec vitest run packages/types/ — AFTER (065b16b98)Test Files 76 passed (76) / Tests 938 passed (938)+1 file, +77 tests, exactly the new pin file; nothing lost
zod-mirror-parity runtime halfTest Files 1 passed (1) / Tests 5 passed (5), PARITY_EXIT=0
zod-mirror-parity compile-time halfcarried by type-check above (it is a tsconfig.test.json assertion, not a vitest case)
pnpm exec eslint . — plain form, whole repo, at 065b16b984023 files linted, 0 errors, 11516 warnings (11518 before; the 2 removed were mine). All 13 touched/added files: 0 errors; the added test file and the parity file: 0 warnings
node scripts/check-changeset-presence.mjsexit 0 — "12 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)"
downstream consumer type-check, pnpm --filter @object-ui/components type-checkcommand-exit 0 — the renderers now type-check against the declared types instead of any, resolved through the freshly built dist (packages/components/tsconfig.json overrides the root paths, so it reads the built .d.ts, not source)

Heavy runs went through the container's shared verify lock; the --filter vitest forms AGENTS.md guard-refuses were not used.

The changeset is minor (⛔ not major) and states the widening in words, including the value-dimension narrowing.


A 14th key exists — reported, NOT folded in

The re-derivation ran the opposite way round from the census (enumerate every schema.KEYNAME read in each of the 8 renderers, subtract the declared members) and found 4 more genuinely-read undeclared keys the docs-driven census could not see, plus one declared-but-dead key. Per the dispatch order they are reported, not folded in: filed as #6938.

  • CheckboxSchema.wrapperClass (form/checkbox.tsx:33) — the same key as two of the 13, on a third type, undeclared only because the checkbox docs page is a six-line summary
  • ContextMenuSchema.triggerClassName (:87), contentClassName (:88), modal (:91)
  • ContextMenuSchema.children is declared required on both faces and read by nothing — the renderer renders schema.trigger, never schema.children

That sweep covered only these 8 types, so it is a floor, not a census.


Open questions for contract review

  1. CarouselSchema.opts shape. Open record (what shipped here, preserves every working document) vs the docs' { loop, align } pair (stronger authoring guard, refuses authored embla options that work today). Recommendation: keep it open in this PR and rule on it against real authored data, because narrowing is the irreversible direction.
  2. The two alias pairs (content/value, nodes/data). This PR declares both spellings because both are read. Recommendation: a follow-up ADR-0049 card retires one of each, rather than leaving two dialects declared forever.
  3. TreeViewSchema.data is required while nodes is the spelling the renderer prefers. A nodes-only document is still refused. Recommendation: a separate card, since relaxing a required key is an accept-set change of its own.

Generated by Claude Code

The undeclared-but-consumed census (objectui#6150) found 68 documented keys
that are not declared members of their shipped type; 13 were shown to be
genuinely READ by the renderer. Those 13 are declared here on both faces —
the TypeScript interface and, for 12 of them, the hand-written zod mirror.
Key membership is not widened: every touched mirror extends the
`.passthrough()` `BaseSchema`, so all 13 already parsed green and already
survived the parse, admitted unexamined. What changes is declaration (the key
becomes a member of the shipped type) and, for the 12 mirrored keys, value
enforcement — which in the value dimension is a narrowing.
`TreeViewSchema.onNodeClick` is INVOKED, not read as a value, so it gets no
mirror: objectui#6152 routes that class to `RuntimeOnlyDeclared` in
`zod-mirror-parity.test.ts`, and this is the first pair to sit there without
also sitting in `UnmirroredDeclared`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
`SchemaNode` itself admits `null | undefined`, so wrapping the property in
`NonNullable` stripped those limbs out of the union and compared a different
type — `tsc -p tsconfig.test.json` read `Type 'false' does not satisfy the
constraint 'true'`. Read raw the two sides are equal, and the guard still
bites: an undeclared `trigger` resolves to `any` through `BaseSchema`'s index
signature, and `Equal< any, … >` is false.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
…r `any`
`pnpm exec eslint .` flagged two `no-explicit-any` warnings introduced by the
`Case.mirror` handle. The structural type it needs is small and writable, so
write it: `data` is the parsed document, `error` is the issue list the refusal
assertions read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3179.6 KB3222.7 KB
Main entry chunk (gzip)143.6 KB350 KB
Entry fileindex-RnBefW7y.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)12.46KB4.71KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.09KB116.42KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)175.69KB48.80KB
fields (index.js)243.65KB61.63KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)247.26KB63.17KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.08KB54.61KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.05KB8.37KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.79KB21.10KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)76.75KB25.49KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-sam
os-sam marked this pull request as ready for review August 31, 2026 03:11
@os-sam
os-sam added this pull request to the merge queueAug 31, 2026
@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Aug 31, 2026
@os-sam
os-sam added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 2c45966Aug 31, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6150-undeclared-but-consumed-keys branch August 31, 2026 03:46
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(types): declare the 13 renderer-read keys that no shipped type declared by os-sam · Pull Request #6945 · objectstack-ai/objectui · GitHub
Skip to content

feat(types): declare the 13 renderer-read keys that no shipped type declared - #6945

Merged
os-sam merged 3 commits into
mainfrom
claude/issue-6150-undeclared-but-consumed-keys
Aug 31, 2026
Merged

feat(types): declare the 13 renderer-read keys that no shipped type declared#6945
os-sam merged 3 commits into
mainfrom
claude/issue-6150-undeclared-but-consumed-keys

Conversation

@os-sam

Copy link
Copy Markdown
Collaborator

Fixes#6150

⚠️ Contract-review tier (needs:contract-review). Staying in draft; not marked ready, not enqueued, no auto-merge.

⚠️ Generics are written in words below, not in angle brackets — the body sanitizer eats anything tag-shaped, including inside backticks (the card itself says so).

Head this PR's evidence was measured on: 065b16b98. Base: 40c479af2.


⛔ Read this before the table: the accept set did NOT widen in the key dimension

The dispatch order asked me to establish this per schema rather than assume one answer, and to say so plainly if passthrough meant the before-state already accepted. It did. Measured, not assumed:

All 8 touched mirrors extend the zod BaseSchema, which ends .passthrough(), and .extend() carries that policy through. Read off the built mirrors, catchall is z.unknown() on all 8. So before this PR every one of the 13 keys already parsed green and already SURVIVED the parse — admitted unexamined, neither refused nor stripped.

So the PR does not claim an accept-set change it did not make. What it actually changes is two things:

  1. Declaration. The key becomes a declared member of the shipped TypeScript type. Today these reads compile only because BaseSchema ends with [key: string]: any (finding(types): BaseSchema's [key: string]: any leaves every component schema open, so a "declare the surface" fix can never reject a misspelled TOP-LEVEL key #5155), so schema.trigger on a type that never declared trigger resolves to any.
  2. Enforcement — and in the VALUE dimension this is a NARROWING, not a widening. For the 12 keys that gained a zod mirror entry, the value is now validated: { type: 'text', content: 42 } parsed green before and is refused at content now. That is a behaviour change for documents carrying a wrong-typed value under one of these 13 names, and it is the point — declared === enforced.

Because acceptance cannot tell "declared" from "admitted unexamined" under passthrough, membership is asserted on the mirror's own .shape, never on parse acceptance — the form object-grid-title-mirrored.test.ts established for #6639.


Per-key before / after — the table triage asked for

Probed by parsing real documents through the builtpackages/types/dist/zod/index.zod.js in two worktrees: 40c479af2 (before) and 065b16b98 (after). Control document per type = required keys only, carrying none of the 13.

keyin mirror .shapedoc carrying the keydoc carrying a WRONG-TYPED valuecontrol docundeclared-key control
TextSchema.contentfalse → trueaccepted+survives → accepted+survivesaccepted → refused at contentgreen → greenadmitted → admitted
CarouselSchema.optsfalse → trueaccepted+survives → accepted+survivesaccepted → refused at optsgreen → greenadmitted → admitted
CarouselSchema.orientationfalse → trueaccepted+survives → accepted+survivesaccepted → refused at orientationgreen → greenadmitted → admitted
CarouselSchema.itemClassNamefalse → trueaccepted+survives → accepted+survivesaccepted → refused at itemClassNamegreen → greenadmitted → admitted
FilterBuilderSchema.wrapperClassfalse → trueaccepted+survives → accepted+survivesaccepted → refused at wrapperClassgreen → greenadmitted → admitted
TreeViewSchema.nodesfalse → trueaccepted+survives → accepted+survivesaccepted → refused at nodesgreen → greenadmitted → admitted
TreeViewSchema.titlefalse → trueaccepted+survives → accepted+survivesaccepted → refused at titlegreen → greenadmitted → admitted
TreeViewSchema.onNodeClickfalse → false (deliberate)accepted+survives → accepted+survivesaccepted → accepted (no mirror, so no enforcement)green → greenadmitted → admitted
CheckboxSchema.requiredfalse → trueaccepted+survives → accepted+survivesaccepted → refused at requiredgreen → greenadmitted → admitted
FileUploadSchema.buttonTextfalse → trueaccepted+survives → accepted+survivesaccepted → refused at buttonTextgreen → greenadmitted → admitted
FileUploadSchema.wrapperClassfalse → trueaccepted+survives → accepted+survivesaccepted → refused at wrapperClassgreen → greenadmitted → admitted
HoverCardSchema.alignfalse → trueaccepted+survives → accepted+survivesaccepted → refused at aligngreen → greenadmitted → admitted
ContextMenuSchema.triggerfalse → trueaccepted+survives → accepted+survivesaccepted → refused at triggergreen → greenadmitted → admitted

"Widened only what I meant to", per schema: the rightmost column is that proof. On all 8 mirrors, a document carrying an undeclared key of the same wrong type is still admitted and still survives the parse — before AND after. The unknown-key policy of every touched mirror is exactly what it was. This is also pinned per key in the test file, so it cannot silently stop being true.

One extra measurement, because declaring nodes invites a wrong inference: a { type: 'tree-view', nodes: [...] } document with no data is REFUSED at data, before and after. data stays required on both faces. Declaring nodes records the read; it does not by itself make a nodes-only document legal. Relaxing data is an accept-set change and a separate ruling.


Premise re-derived, not inherited

Module resolution path, stated before measuring.packages/types/dist did not exist in the fresh worktree, so I built it (pnpm --filter @object-ui/types build, verdict command-exit 0) and measured through packages/types/dist/index.d.ts — the census's own path, derivePackageTypePaths() style — and independently through packages/types/src/index.ts. Declared-member sets for all 8 types were identical between dist and src, so no stale-build reading is in play. Members were resolved with the TypeScript compiler API (getPropertiesOfType), which sees inherited members and reports the string index signature separately, so [key: string]: any could not mask the answer.

Result: 13/13 still undeclared on base. After the change, re-measured through the rebuilt dist/index.d.ts: 13/13 declared, index signature still present on all 8 (so nothing about BaseSchema moved).

Every read re-verified at its cited site. All 13 reads are still present. One line number drifted; the read is the fact:

keycensus saidmeasured now
TextSchema.contentbasic/text.tsx:51,56:51,56 — unchanged
CarouselSchema.opts / orientation / itemClassNamecomplex/carousel.tsx:23 / 24 / 30unchanged
FilterBuilderSchema.wrapperClasscomplex/filter-builder.tsx:37unchanged
TreeViewSchema.nodes / onNodeClick / titledata-display/tree-view.tsx:105 / 98,99 / 115,117unchanged
CheckboxSchema.requiredform/checkbox.tsx:45,49unchanged
FileUploadSchema.buttonText / wrapperClassform/file-upload.tsx:123 / 78unchanged
HoverCardSchema.alignoverlay/hover-card.tsx:24unchanged
ContextMenuSchema.triggeroverlay/context-menu.tsx:64:95 — drifted, read intact

Nothing was dropped. No key's reader had been removed.

READ vs INVOKED.TreeViewSchema.onNodeClick is invokedif (schema.onNodeClick) then schema.onNodeClick(node), with node the clicked TreeNode and the return value discarded. Its declared type is therefore the call signature (node: TreeNode) => void, not a value shape, and it is pinned as such with an invariant equality assertion. The other 12 are value reads.


Three declarations that are not "declare what is read" on autopilot

Each says so in its own doc comment; all three are flagged for the contract reviewer.

  • CarouselSchema.opts is an OPEN record (string keys, unknown values), not the docs page's two-key shape. The renderer forwards the whole bag verbatim to embla (opts={schema.opts}), so every other embla option authored today reaches the library and works. Declaring the documented pair would refuse those documents — an accept-set narrowing on a published surface, which is a ruling, not a declaration. The card itself flagged this key as the one that "may want a narrower shape"; I declined to narrow it without a ruling.
  • ContextMenuSchema.trigger is OPTIONAL although the docs page shows it required. The renderer substitutes a placeholder node when it is absent, so every trigger-less document is legal today and declaring it required would refuse them.
  • TreeViewSchema.onNodeClick gets NO zod mirror. A function cannot appear in an authored JSON document, so it is a runtime slot; 121 declared-but-unmirrored keys across 16 schema pairs — the lane #6058's new UnmirroredDeclared ledger made visible #6152 ruled that class is never mirrored and is recorded in zod-mirror-parity.test.ts's RuntimeOnlyDeclared instead — the step-3 exception that file's own header spells out. This is the first pair to sit in RuntimeOnlyDeclared without also sitting in UnmirroredDeclared, so that file's two population counts move with it (6 entries / 23 keys to 7 / 24; the "no entry in either" population 142 to 141). ⛔ UnmirroredDeclared is shrink-only and was not touched.

Two of the 13 declare a second spelling for a slot that already has onecontent beside value, nodes beside data — because that is what the renderers read (schema.content || schema.value, boundData || schema.nodes || schema.data). AGENTS.md #0.1's "one strict contract beats N dialects" argues for retiring one of each pair; that is an ADR-0049 enforce-or-remove question and deliberately not decided here. Each doc comment names which spelling wins.


Ablation — direction predicted IN WRITING before the run

Predicted before anything touched disk (mutating the fact, not the assertion): delete the content shape entry from the TextSchema mirror, leaving the TS declaration in place.

  1. membership assertion → RED
  2. wrong-typed-value refusal → RED
  3. "accepts the declared value and it SURVIVES" → STAYS GREEN — the counter-intuitive half, and the whole reason membership is read off .shape: under passthrough an undeclaredcontent: 'hello' is still accepted and still survives, so a suite measuring only acceptance would read this ablation as fully green
  4. read-site assertion and both control assertions → GREEN
  5. the other 12 keys → GREEN
  6. verdict: exactly 2 failed | 75 passed (77)
  7. second instrument: zod-mirror-parity.test.ts's compile-time half reddens naming layout.zod.ts#TextSchema; its runtime census (5 tests) does not move, by that file's documented design

Observed, on 065b16b98, all seven as predicted:

MUTATION PROVEN ON DISK: anchor 1->0 and hash d264a81ccb08838670e9e596ef3d407e3625855a != HEAD blob a96fce18feff5bf04102b70adaafa5b2e82600a8
vitest exit = 1
Test Files 1 failed (1)
Tests 2 failed | 75 passed (77)
x TextSchema.content > is a member of the mirror shape (membership cannot be read off acceptance under passthrough)
x TextSchema.content > refuses a wrong-typed value AT the key - the enforcement mirroring adds
tsc -p tsconfig.test.json exit = 1
src/__tests__/zod-mirror-parity.test.ts(1240,14): error TS2322: Type '"layout.zod.ts#TextSchema"' is not assignable to type 'never'.
RESTORE PROVEN: git diff HEAD empty AND hash a96fce18feff5bf04102b70adaafa5b2e82600a8 == HEAD blob a96fce18feff5bf04102b70adaafa5b2e82600a8

Mechanics: absolute paths from git rev-parse --show-toplevel, restore via git checkout HEAD -- ABSOLUTE_PATH (never bare) under trap ... EXIT INT TERM, mutation proven by anchored count 1 to 0 and a git hash-object differing from the HEAD blob, restore proven both ways. Resolution path for the ablation: both instruments read SOURCE — the pin file imports '../zod/layout.zod' relatively and tsc -p tsconfig.test.json compiles src/, so dist is on neither path and no rebuild is part of it.


Gates — exit codes captured before any pipe, verdict lines quoted

gateresult
pnpm --filter @object-ui/types type-checkechoed tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json; lock verdict command-exit 0
root-form pnpm exec vitest run packages/types/ — BEFORE (40c479af2)Test Files 75 passed (75) / Tests 861 passed (861)
root-form pnpm exec vitest run packages/types/ — AFTER (065b16b98)Test Files 76 passed (76) / Tests 938 passed (938)+1 file, +77 tests, exactly the new pin file; nothing lost
zod-mirror-parity runtime halfTest Files 1 passed (1) / Tests 5 passed (5), PARITY_EXIT=0
zod-mirror-parity compile-time halfcarried by type-check above (it is a tsconfig.test.json assertion, not a vitest case)
pnpm exec eslint . — plain form, whole repo, at 065b16b984023 files linted, 0 errors, 11516 warnings (11518 before; the 2 removed were mine). All 13 touched/added files: 0 errors; the added test file and the parity file: 0 warnings
node scripts/check-changeset-presence.mjsexit 0 — "12 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)"
downstream consumer type-check, pnpm --filter @object-ui/components type-checkcommand-exit 0 — the renderers now type-check against the declared types instead of any, resolved through the freshly built dist (packages/components/tsconfig.json overrides the root paths, so it reads the built .d.ts, not source)

Heavy runs went through the container's shared verify lock; the --filter vitest forms AGENTS.md guard-refuses were not used.

The changeset is minor (⛔ not major) and states the widening in words, including the value-dimension narrowing.


A 14th key exists — reported, NOT folded in

The re-derivation ran the opposite way round from the census (enumerate every schema.KEYNAME read in each of the 8 renderers, subtract the declared members) and found 4 more genuinely-read undeclared keys the docs-driven census could not see, plus one declared-but-dead key. Per the dispatch order they are reported, not folded in: filed as #6938.

  • CheckboxSchema.wrapperClass (form/checkbox.tsx:33) — the same key as two of the 13, on a third type, undeclared only because the checkbox docs page is a six-line summary
  • ContextMenuSchema.triggerClassName (:87), contentClassName (:88), modal (:91)
  • ContextMenuSchema.children is declared required on both faces and read by nothing — the renderer renders schema.trigger, never schema.children

That sweep covered only these 8 types, so it is a floor, not a census.


Open questions for contract review

  1. CarouselSchema.opts shape. Open record (what shipped here, preserves every working document) vs the docs' { loop, align } pair (stronger authoring guard, refuses authored embla options that work today). Recommendation: keep it open in this PR and rule on it against real authored data, because narrowing is the irreversible direction.
  2. The two alias pairs (content/value, nodes/data). This PR declares both spellings because both are read. Recommendation: a follow-up ADR-0049 card retires one of each, rather than leaving two dialects declared forever.
  3. TreeViewSchema.data is required while nodes is the spelling the renderer prefers. A nodes-only document is still refused. Recommendation: a separate card, since relaxing a required key is an accept-set change of its own.

Generated by Claude Code

The undeclared-but-consumed census (objectui#6150) found 68 documented keys
that are not declared members of their shipped type; 13 were shown to be
genuinely READ by the renderer. Those 13 are declared here on both faces —
the TypeScript interface and, for 12 of them, the hand-written zod mirror.
Key membership is not widened: every touched mirror extends the
`.passthrough()` `BaseSchema`, so all 13 already parsed green and already
survived the parse, admitted unexamined. What changes is declaration (the key
becomes a member of the shipped type) and, for the 12 mirrored keys, value
enforcement — which in the value dimension is a narrowing.
`TreeViewSchema.onNodeClick` is INVOKED, not read as a value, so it gets no
mirror: objectui#6152 routes that class to `RuntimeOnlyDeclared` in
`zod-mirror-parity.test.ts`, and this is the first pair to sit there without
also sitting in `UnmirroredDeclared`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
`SchemaNode` itself admits `null | undefined`, so wrapping the property in
`NonNullable` stripped those limbs out of the union and compared a different
type — `tsc -p tsconfig.test.json` read `Type 'false' does not satisfy the
constraint 'true'`. Read raw the two sides are equal, and the guard still
bites: an undeclared `trigger` resolves to `any` through `BaseSchema`'s index
signature, and `Equal< any, … >` is false.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
…r `any`
`pnpm exec eslint .` flagged two `no-explicit-any` warnings introduced by the
`Case.mirror` handle. The structural type it needs is small and writable, so
write it: `data` is the parsed document, `error` is the issue list the refusal
assertions read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3179.6 KB3222.7 KB
Main entry chunk (gzip)143.6 KB350 KB
Entry fileindex-RnBefW7y.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)12.46KB4.71KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.09KB116.42KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)175.69KB48.80KB
fields (index.js)243.65KB61.63KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)247.26KB63.17KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.08KB54.61KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.05KB8.37KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.79KB21.10KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)76.75KB25.49KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-sam
os-sam marked this pull request as ready for review August 31, 2026 03:11
@os-sam
os-sam added this pull request to the merge queueAug 31, 2026
@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Aug 31, 2026
@os-sam
os-sam added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 2c45966Aug 31, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6150-undeclared-but-consumed-keys branch August 31, 2026 03:46
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(types): declare the 13 renderer-read keys that no shipped type declared by os-sam · Pull Request #6945 · objectstack-ai/objectui · GitHub
Skip to content

feat(types): declare the 13 renderer-read keys that no shipped type declared - #6945

Merged
os-sam merged 3 commits into
mainfrom
claude/issue-6150-undeclared-but-consumed-keys
Aug 31, 2026
Merged

feat(types): declare the 13 renderer-read keys that no shipped type declared#6945
os-sam merged 3 commits into
mainfrom
claude/issue-6150-undeclared-but-consumed-keys

Conversation

@os-sam

Copy link
Copy Markdown
Collaborator

Fixes#6150

⚠️ Contract-review tier (needs:contract-review). Staying in draft; not marked ready, not enqueued, no auto-merge.

⚠️ Generics are written in words below, not in angle brackets — the body sanitizer eats anything tag-shaped, including inside backticks (the card itself says so).

Head this PR's evidence was measured on: 065b16b98. Base: 40c479af2.


⛔ Read this before the table: the accept set did NOT widen in the key dimension

The dispatch order asked me to establish this per schema rather than assume one answer, and to say so plainly if passthrough meant the before-state already accepted. It did. Measured, not assumed:

All 8 touched mirrors extend the zod BaseSchema, which ends .passthrough(), and .extend() carries that policy through. Read off the built mirrors, catchall is z.unknown() on all 8. So before this PR every one of the 13 keys already parsed green and already SURVIVED the parse — admitted unexamined, neither refused nor stripped.

So the PR does not claim an accept-set change it did not make. What it actually changes is two things:

  1. Declaration. The key becomes a declared member of the shipped TypeScript type. Today these reads compile only because BaseSchema ends with [key: string]: any (finding(types): BaseSchema's [key: string]: any leaves every component schema open, so a "declare the surface" fix can never reject a misspelled TOP-LEVEL key #5155), so schema.trigger on a type that never declared trigger resolves to any.
  2. Enforcement — and in the VALUE dimension this is a NARROWING, not a widening. For the 12 keys that gained a zod mirror entry, the value is now validated: { type: 'text', content: 42 } parsed green before and is refused at content now. That is a behaviour change for documents carrying a wrong-typed value under one of these 13 names, and it is the point — declared === enforced.

Because acceptance cannot tell "declared" from "admitted unexamined" under passthrough, membership is asserted on the mirror's own .shape, never on parse acceptance — the form object-grid-title-mirrored.test.ts established for #6639.


Per-key before / after — the table triage asked for

Probed by parsing real documents through the builtpackages/types/dist/zod/index.zod.js in two worktrees: 40c479af2 (before) and 065b16b98 (after). Control document per type = required keys only, carrying none of the 13.

keyin mirror .shapedoc carrying the keydoc carrying a WRONG-TYPED valuecontrol docundeclared-key control
TextSchema.contentfalse → trueaccepted+survives → accepted+survivesaccepted → refused at contentgreen → greenadmitted → admitted
CarouselSchema.optsfalse → trueaccepted+survives → accepted+survivesaccepted → refused at optsgreen → greenadmitted → admitted
CarouselSchema.orientationfalse → trueaccepted+survives → accepted+survivesaccepted → refused at orientationgreen → greenadmitted → admitted
CarouselSchema.itemClassNamefalse → trueaccepted+survives → accepted+survivesaccepted → refused at itemClassNamegreen → greenadmitted → admitted
FilterBuilderSchema.wrapperClassfalse → trueaccepted+survives → accepted+survivesaccepted → refused at wrapperClassgreen → greenadmitted → admitted
TreeViewSchema.nodesfalse → trueaccepted+survives → accepted+survivesaccepted → refused at nodesgreen → greenadmitted → admitted
TreeViewSchema.titlefalse → trueaccepted+survives → accepted+survivesaccepted → refused at titlegreen → greenadmitted → admitted
TreeViewSchema.onNodeClickfalse → false (deliberate)accepted+survives → accepted+survivesaccepted → accepted (no mirror, so no enforcement)green → greenadmitted → admitted
CheckboxSchema.requiredfalse → trueaccepted+survives → accepted+survivesaccepted → refused at requiredgreen → greenadmitted → admitted
FileUploadSchema.buttonTextfalse → trueaccepted+survives → accepted+survivesaccepted → refused at buttonTextgreen → greenadmitted → admitted
FileUploadSchema.wrapperClassfalse → trueaccepted+survives → accepted+survivesaccepted → refused at wrapperClassgreen → greenadmitted → admitted
HoverCardSchema.alignfalse → trueaccepted+survives → accepted+survivesaccepted → refused at aligngreen → greenadmitted → admitted
ContextMenuSchema.triggerfalse → trueaccepted+survives → accepted+survivesaccepted → refused at triggergreen → greenadmitted → admitted

"Widened only what I meant to", per schema: the rightmost column is that proof. On all 8 mirrors, a document carrying an undeclared key of the same wrong type is still admitted and still survives the parse — before AND after. The unknown-key policy of every touched mirror is exactly what it was. This is also pinned per key in the test file, so it cannot silently stop being true.

One extra measurement, because declaring nodes invites a wrong inference: a { type: 'tree-view', nodes: [...] } document with no data is REFUSED at data, before and after. data stays required on both faces. Declaring nodes records the read; it does not by itself make a nodes-only document legal. Relaxing data is an accept-set change and a separate ruling.


Premise re-derived, not inherited

Module resolution path, stated before measuring.packages/types/dist did not exist in the fresh worktree, so I built it (pnpm --filter @object-ui/types build, verdict command-exit 0) and measured through packages/types/dist/index.d.ts — the census's own path, derivePackageTypePaths() style — and independently through packages/types/src/index.ts. Declared-member sets for all 8 types were identical between dist and src, so no stale-build reading is in play. Members were resolved with the TypeScript compiler API (getPropertiesOfType), which sees inherited members and reports the string index signature separately, so [key: string]: any could not mask the answer.

Result: 13/13 still undeclared on base. After the change, re-measured through the rebuilt dist/index.d.ts: 13/13 declared, index signature still present on all 8 (so nothing about BaseSchema moved).

Every read re-verified at its cited site. All 13 reads are still present. One line number drifted; the read is the fact:

keycensus saidmeasured now
TextSchema.contentbasic/text.tsx:51,56:51,56 — unchanged
CarouselSchema.opts / orientation / itemClassNamecomplex/carousel.tsx:23 / 24 / 30unchanged
FilterBuilderSchema.wrapperClasscomplex/filter-builder.tsx:37unchanged
TreeViewSchema.nodes / onNodeClick / titledata-display/tree-view.tsx:105 / 98,99 / 115,117unchanged
CheckboxSchema.requiredform/checkbox.tsx:45,49unchanged
FileUploadSchema.buttonText / wrapperClassform/file-upload.tsx:123 / 78unchanged
HoverCardSchema.alignoverlay/hover-card.tsx:24unchanged
ContextMenuSchema.triggeroverlay/context-menu.tsx:64:95 — drifted, read intact

Nothing was dropped. No key's reader had been removed.

READ vs INVOKED.TreeViewSchema.onNodeClick is invokedif (schema.onNodeClick) then schema.onNodeClick(node), with node the clicked TreeNode and the return value discarded. Its declared type is therefore the call signature (node: TreeNode) => void, not a value shape, and it is pinned as such with an invariant equality assertion. The other 12 are value reads.


Three declarations that are not "declare what is read" on autopilot

Each says so in its own doc comment; all three are flagged for the contract reviewer.

  • CarouselSchema.opts is an OPEN record (string keys, unknown values), not the docs page's two-key shape. The renderer forwards the whole bag verbatim to embla (opts={schema.opts}), so every other embla option authored today reaches the library and works. Declaring the documented pair would refuse those documents — an accept-set narrowing on a published surface, which is a ruling, not a declaration. The card itself flagged this key as the one that "may want a narrower shape"; I declined to narrow it without a ruling.
  • ContextMenuSchema.trigger is OPTIONAL although the docs page shows it required. The renderer substitutes a placeholder node when it is absent, so every trigger-less document is legal today and declaring it required would refuse them.
  • TreeViewSchema.onNodeClick gets NO zod mirror. A function cannot appear in an authored JSON document, so it is a runtime slot; 121 declared-but-unmirrored keys across 16 schema pairs — the lane #6058's new UnmirroredDeclared ledger made visible #6152 ruled that class is never mirrored and is recorded in zod-mirror-parity.test.ts's RuntimeOnlyDeclared instead — the step-3 exception that file's own header spells out. This is the first pair to sit in RuntimeOnlyDeclared without also sitting in UnmirroredDeclared, so that file's two population counts move with it (6 entries / 23 keys to 7 / 24; the "no entry in either" population 142 to 141). ⛔ UnmirroredDeclared is shrink-only and was not touched.

Two of the 13 declare a second spelling for a slot that already has onecontent beside value, nodes beside data — because that is what the renderers read (schema.content || schema.value, boundData || schema.nodes || schema.data). AGENTS.md #0.1's "one strict contract beats N dialects" argues for retiring one of each pair; that is an ADR-0049 enforce-or-remove question and deliberately not decided here. Each doc comment names which spelling wins.


Ablation — direction predicted IN WRITING before the run

Predicted before anything touched disk (mutating the fact, not the assertion): delete the content shape entry from the TextSchema mirror, leaving the TS declaration in place.

  1. membership assertion → RED
  2. wrong-typed-value refusal → RED
  3. "accepts the declared value and it SURVIVES" → STAYS GREEN — the counter-intuitive half, and the whole reason membership is read off .shape: under passthrough an undeclaredcontent: 'hello' is still accepted and still survives, so a suite measuring only acceptance would read this ablation as fully green
  4. read-site assertion and both control assertions → GREEN
  5. the other 12 keys → GREEN
  6. verdict: exactly 2 failed | 75 passed (77)
  7. second instrument: zod-mirror-parity.test.ts's compile-time half reddens naming layout.zod.ts#TextSchema; its runtime census (5 tests) does not move, by that file's documented design

Observed, on 065b16b98, all seven as predicted:

MUTATION PROVEN ON DISK: anchor 1->0 and hash d264a81ccb08838670e9e596ef3d407e3625855a != HEAD blob a96fce18feff5bf04102b70adaafa5b2e82600a8
vitest exit = 1
Test Files 1 failed (1)
Tests 2 failed | 75 passed (77)
x TextSchema.content > is a member of the mirror shape (membership cannot be read off acceptance under passthrough)
x TextSchema.content > refuses a wrong-typed value AT the key - the enforcement mirroring adds
tsc -p tsconfig.test.json exit = 1
src/__tests__/zod-mirror-parity.test.ts(1240,14): error TS2322: Type '"layout.zod.ts#TextSchema"' is not assignable to type 'never'.
RESTORE PROVEN: git diff HEAD empty AND hash a96fce18feff5bf04102b70adaafa5b2e82600a8 == HEAD blob a96fce18feff5bf04102b70adaafa5b2e82600a8

Mechanics: absolute paths from git rev-parse --show-toplevel, restore via git checkout HEAD -- ABSOLUTE_PATH (never bare) under trap ... EXIT INT TERM, mutation proven by anchored count 1 to 0 and a git hash-object differing from the HEAD blob, restore proven both ways. Resolution path for the ablation: both instruments read SOURCE — the pin file imports '../zod/layout.zod' relatively and tsc -p tsconfig.test.json compiles src/, so dist is on neither path and no rebuild is part of it.


Gates — exit codes captured before any pipe, verdict lines quoted

gateresult
pnpm --filter @object-ui/types type-checkechoed tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json; lock verdict command-exit 0
root-form pnpm exec vitest run packages/types/ — BEFORE (40c479af2)Test Files 75 passed (75) / Tests 861 passed (861)
root-form pnpm exec vitest run packages/types/ — AFTER (065b16b98)Test Files 76 passed (76) / Tests 938 passed (938)+1 file, +77 tests, exactly the new pin file; nothing lost
zod-mirror-parity runtime halfTest Files 1 passed (1) / Tests 5 passed (5), PARITY_EXIT=0
zod-mirror-parity compile-time halfcarried by type-check above (it is a tsconfig.test.json assertion, not a vitest case)
pnpm exec eslint . — plain form, whole repo, at 065b16b984023 files linted, 0 errors, 11516 warnings (11518 before; the 2 removed were mine). All 13 touched/added files: 0 errors; the added test file and the parity file: 0 warnings
node scripts/check-changeset-presence.mjsexit 0 — "12 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)"
downstream consumer type-check, pnpm --filter @object-ui/components type-checkcommand-exit 0 — the renderers now type-check against the declared types instead of any, resolved through the freshly built dist (packages/components/tsconfig.json overrides the root paths, so it reads the built .d.ts, not source)

Heavy runs went through the container's shared verify lock; the --filter vitest forms AGENTS.md guard-refuses were not used.

The changeset is minor (⛔ not major) and states the widening in words, including the value-dimension narrowing.


A 14th key exists — reported, NOT folded in

The re-derivation ran the opposite way round from the census (enumerate every schema.KEYNAME read in each of the 8 renderers, subtract the declared members) and found 4 more genuinely-read undeclared keys the docs-driven census could not see, plus one declared-but-dead key. Per the dispatch order they are reported, not folded in: filed as #6938.

  • CheckboxSchema.wrapperClass (form/checkbox.tsx:33) — the same key as two of the 13, on a third type, undeclared only because the checkbox docs page is a six-line summary
  • ContextMenuSchema.triggerClassName (:87), contentClassName (:88), modal (:91)
  • ContextMenuSchema.children is declared required on both faces and read by nothing — the renderer renders schema.trigger, never schema.children

That sweep covered only these 8 types, so it is a floor, not a census.


Open questions for contract review

  1. CarouselSchema.opts shape. Open record (what shipped here, preserves every working document) vs the docs' { loop, align } pair (stronger authoring guard, refuses authored embla options that work today). Recommendation: keep it open in this PR and rule on it against real authored data, because narrowing is the irreversible direction.
  2. The two alias pairs (content/value, nodes/data). This PR declares both spellings because both are read. Recommendation: a follow-up ADR-0049 card retires one of each, rather than leaving two dialects declared forever.
  3. TreeViewSchema.data is required while nodes is the spelling the renderer prefers. A nodes-only document is still refused. Recommendation: a separate card, since relaxing a required key is an accept-set change of its own.

Generated by Claude Code

The undeclared-but-consumed census (objectui#6150) found 68 documented keys
that are not declared members of their shipped type; 13 were shown to be
genuinely READ by the renderer. Those 13 are declared here on both faces —
the TypeScript interface and, for 12 of them, the hand-written zod mirror.
Key membership is not widened: every touched mirror extends the
`.passthrough()` `BaseSchema`, so all 13 already parsed green and already
survived the parse, admitted unexamined. What changes is declaration (the key
becomes a member of the shipped type) and, for the 12 mirrored keys, value
enforcement — which in the value dimension is a narrowing.
`TreeViewSchema.onNodeClick` is INVOKED, not read as a value, so it gets no
mirror: objectui#6152 routes that class to `RuntimeOnlyDeclared` in
`zod-mirror-parity.test.ts`, and this is the first pair to sit there without
also sitting in `UnmirroredDeclared`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
`SchemaNode` itself admits `null | undefined`, so wrapping the property in
`NonNullable` stripped those limbs out of the union and compared a different
type — `tsc -p tsconfig.test.json` read `Type 'false' does not satisfy the
constraint 'true'`. Read raw the two sides are equal, and the guard still
bites: an undeclared `trigger` resolves to `any` through `BaseSchema`'s index
signature, and `Equal< any, … >` is false.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
…r `any`
`pnpm exec eslint .` flagged two `no-explicit-any` warnings introduced by the
`Case.mirror` handle. The structural type it needs is small and writable, so
write it: `data` is the parsed document, `error` is the issue list the refusal
assertions read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3179.6 KB3222.7 KB
Main entry chunk (gzip)143.6 KB350 KB
Entry fileindex-RnBefW7y.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)12.46KB4.71KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.09KB116.42KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)175.69KB48.80KB
fields (index.js)243.65KB61.63KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)247.26KB63.17KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.08KB54.61KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.05KB8.37KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.79KB21.10KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)76.75KB25.49KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-sam
os-sam marked this pull request as ready for review August 31, 2026 03:11
@os-sam
os-sam added this pull request to the merge queueAug 31, 2026
@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Aug 31, 2026
@os-sam
os-sam added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 2c45966Aug 31, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6150-undeclared-but-consumed-keys branch August 31, 2026 03:46
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(types): declare the 13 renderer-read keys that no shipped type declared by os-sam · Pull Request #6945 · objectstack-ai/objectui · GitHub
Skip to content

feat(types): declare the 13 renderer-read keys that no shipped type declared - #6945

Merged
os-sam merged 3 commits into
mainfrom
claude/issue-6150-undeclared-but-consumed-keys
Aug 31, 2026
Merged

feat(types): declare the 13 renderer-read keys that no shipped type declared#6945
os-sam merged 3 commits into
mainfrom
claude/issue-6150-undeclared-but-consumed-keys

Conversation

@os-sam

Copy link
Copy Markdown
Collaborator

Fixes#6150

⚠️ Contract-review tier (needs:contract-review). Staying in draft; not marked ready, not enqueued, no auto-merge.

⚠️ Generics are written in words below, not in angle brackets — the body sanitizer eats anything tag-shaped, including inside backticks (the card itself says so).

Head this PR's evidence was measured on: 065b16b98. Base: 40c479af2.


⛔ Read this before the table: the accept set did NOT widen in the key dimension

The dispatch order asked me to establish this per schema rather than assume one answer, and to say so plainly if passthrough meant the before-state already accepted. It did. Measured, not assumed:

All 8 touched mirrors extend the zod BaseSchema, which ends .passthrough(), and .extend() carries that policy through. Read off the built mirrors, catchall is z.unknown() on all 8. So before this PR every one of the 13 keys already parsed green and already SURVIVED the parse — admitted unexamined, neither refused nor stripped.

So the PR does not claim an accept-set change it did not make. What it actually changes is two things:

  1. Declaration. The key becomes a declared member of the shipped TypeScript type. Today these reads compile only because BaseSchema ends with [key: string]: any (finding(types): BaseSchema's [key: string]: any leaves every component schema open, so a "declare the surface" fix can never reject a misspelled TOP-LEVEL key #5155), so schema.trigger on a type that never declared trigger resolves to any.
  2. Enforcement — and in the VALUE dimension this is a NARROWING, not a widening. For the 12 keys that gained a zod mirror entry, the value is now validated: { type: 'text', content: 42 } parsed green before and is refused at content now. That is a behaviour change for documents carrying a wrong-typed value under one of these 13 names, and it is the point — declared === enforced.

Because acceptance cannot tell "declared" from "admitted unexamined" under passthrough, membership is asserted on the mirror's own .shape, never on parse acceptance — the form object-grid-title-mirrored.test.ts established for #6639.


Per-key before / after — the table triage asked for

Probed by parsing real documents through the builtpackages/types/dist/zod/index.zod.js in two worktrees: 40c479af2 (before) and 065b16b98 (after). Control document per type = required keys only, carrying none of the 13.

keyin mirror .shapedoc carrying the keydoc carrying a WRONG-TYPED valuecontrol docundeclared-key control
TextSchema.contentfalse → trueaccepted+survives → accepted+survivesaccepted → refused at contentgreen → greenadmitted → admitted
CarouselSchema.optsfalse → trueaccepted+survives → accepted+survivesaccepted → refused at optsgreen → greenadmitted → admitted
CarouselSchema.orientationfalse → trueaccepted+survives → accepted+survivesaccepted → refused at orientationgreen → greenadmitted → admitted
CarouselSchema.itemClassNamefalse → trueaccepted+survives → accepted+survivesaccepted → refused at itemClassNamegreen → greenadmitted → admitted
FilterBuilderSchema.wrapperClassfalse → trueaccepted+survives → accepted+survivesaccepted → refused at wrapperClassgreen → greenadmitted → admitted
TreeViewSchema.nodesfalse → trueaccepted+survives → accepted+survivesaccepted → refused at nodesgreen → greenadmitted → admitted
TreeViewSchema.titlefalse → trueaccepted+survives → accepted+survivesaccepted → refused at titlegreen → greenadmitted → admitted
TreeViewSchema.onNodeClickfalse → false (deliberate)accepted+survives → accepted+survivesaccepted → accepted (no mirror, so no enforcement)green → greenadmitted → admitted
CheckboxSchema.requiredfalse → trueaccepted+survives → accepted+survivesaccepted → refused at requiredgreen → greenadmitted → admitted
FileUploadSchema.buttonTextfalse → trueaccepted+survives → accepted+survivesaccepted → refused at buttonTextgreen → greenadmitted → admitted
FileUploadSchema.wrapperClassfalse → trueaccepted+survives → accepted+survivesaccepted → refused at wrapperClassgreen → greenadmitted → admitted
HoverCardSchema.alignfalse → trueaccepted+survives → accepted+survivesaccepted → refused at aligngreen → greenadmitted → admitted
ContextMenuSchema.triggerfalse → trueaccepted+survives → accepted+survivesaccepted → refused at triggergreen → greenadmitted → admitted

"Widened only what I meant to", per schema: the rightmost column is that proof. On all 8 mirrors, a document carrying an undeclared key of the same wrong type is still admitted and still survives the parse — before AND after. The unknown-key policy of every touched mirror is exactly what it was. This is also pinned per key in the test file, so it cannot silently stop being true.

One extra measurement, because declaring nodes invites a wrong inference: a { type: 'tree-view', nodes: [...] } document with no data is REFUSED at data, before and after. data stays required on both faces. Declaring nodes records the read; it does not by itself make a nodes-only document legal. Relaxing data is an accept-set change and a separate ruling.


Premise re-derived, not inherited

Module resolution path, stated before measuring.packages/types/dist did not exist in the fresh worktree, so I built it (pnpm --filter @object-ui/types build, verdict command-exit 0) and measured through packages/types/dist/index.d.ts — the census's own path, derivePackageTypePaths() style — and independently through packages/types/src/index.ts. Declared-member sets for all 8 types were identical between dist and src, so no stale-build reading is in play. Members were resolved with the TypeScript compiler API (getPropertiesOfType), which sees inherited members and reports the string index signature separately, so [key: string]: any could not mask the answer.

Result: 13/13 still undeclared on base. After the change, re-measured through the rebuilt dist/index.d.ts: 13/13 declared, index signature still present on all 8 (so nothing about BaseSchema moved).

Every read re-verified at its cited site. All 13 reads are still present. One line number drifted; the read is the fact:

keycensus saidmeasured now
TextSchema.contentbasic/text.tsx:51,56:51,56 — unchanged
CarouselSchema.opts / orientation / itemClassNamecomplex/carousel.tsx:23 / 24 / 30unchanged
FilterBuilderSchema.wrapperClasscomplex/filter-builder.tsx:37unchanged
TreeViewSchema.nodes / onNodeClick / titledata-display/tree-view.tsx:105 / 98,99 / 115,117unchanged
CheckboxSchema.requiredform/checkbox.tsx:45,49unchanged
FileUploadSchema.buttonText / wrapperClassform/file-upload.tsx:123 / 78unchanged
HoverCardSchema.alignoverlay/hover-card.tsx:24unchanged
ContextMenuSchema.triggeroverlay/context-menu.tsx:64:95 — drifted, read intact

Nothing was dropped. No key's reader had been removed.

READ vs INVOKED.TreeViewSchema.onNodeClick is invokedif (schema.onNodeClick) then schema.onNodeClick(node), with node the clicked TreeNode and the return value discarded. Its declared type is therefore the call signature (node: TreeNode) => void, not a value shape, and it is pinned as such with an invariant equality assertion. The other 12 are value reads.


Three declarations that are not "declare what is read" on autopilot

Each says so in its own doc comment; all three are flagged for the contract reviewer.

  • CarouselSchema.opts is an OPEN record (string keys, unknown values), not the docs page's two-key shape. The renderer forwards the whole bag verbatim to embla (opts={schema.opts}), so every other embla option authored today reaches the library and works. Declaring the documented pair would refuse those documents — an accept-set narrowing on a published surface, which is a ruling, not a declaration. The card itself flagged this key as the one that "may want a narrower shape"; I declined to narrow it without a ruling.
  • ContextMenuSchema.trigger is OPTIONAL although the docs page shows it required. The renderer substitutes a placeholder node when it is absent, so every trigger-less document is legal today and declaring it required would refuse them.
  • TreeViewSchema.onNodeClick gets NO zod mirror. A function cannot appear in an authored JSON document, so it is a runtime slot; 121 declared-but-unmirrored keys across 16 schema pairs — the lane #6058's new UnmirroredDeclared ledger made visible #6152 ruled that class is never mirrored and is recorded in zod-mirror-parity.test.ts's RuntimeOnlyDeclared instead — the step-3 exception that file's own header spells out. This is the first pair to sit in RuntimeOnlyDeclared without also sitting in UnmirroredDeclared, so that file's two population counts move with it (6 entries / 23 keys to 7 / 24; the "no entry in either" population 142 to 141). ⛔ UnmirroredDeclared is shrink-only and was not touched.

Two of the 13 declare a second spelling for a slot that already has onecontent beside value, nodes beside data — because that is what the renderers read (schema.content || schema.value, boundData || schema.nodes || schema.data). AGENTS.md #0.1's "one strict contract beats N dialects" argues for retiring one of each pair; that is an ADR-0049 enforce-or-remove question and deliberately not decided here. Each doc comment names which spelling wins.


Ablation — direction predicted IN WRITING before the run

Predicted before anything touched disk (mutating the fact, not the assertion): delete the content shape entry from the TextSchema mirror, leaving the TS declaration in place.

  1. membership assertion → RED
  2. wrong-typed-value refusal → RED
  3. "accepts the declared value and it SURVIVES" → STAYS GREEN — the counter-intuitive half, and the whole reason membership is read off .shape: under passthrough an undeclaredcontent: 'hello' is still accepted and still survives, so a suite measuring only acceptance would read this ablation as fully green
  4. read-site assertion and both control assertions → GREEN
  5. the other 12 keys → GREEN
  6. verdict: exactly 2 failed | 75 passed (77)
  7. second instrument: zod-mirror-parity.test.ts's compile-time half reddens naming layout.zod.ts#TextSchema; its runtime census (5 tests) does not move, by that file's documented design

Observed, on 065b16b98, all seven as predicted:

MUTATION PROVEN ON DISK: anchor 1->0 and hash d264a81ccb08838670e9e596ef3d407e3625855a != HEAD blob a96fce18feff5bf04102b70adaafa5b2e82600a8
vitest exit = 1
Test Files 1 failed (1)
Tests 2 failed | 75 passed (77)
x TextSchema.content > is a member of the mirror shape (membership cannot be read off acceptance under passthrough)
x TextSchema.content > refuses a wrong-typed value AT the key - the enforcement mirroring adds
tsc -p tsconfig.test.json exit = 1
src/__tests__/zod-mirror-parity.test.ts(1240,14): error TS2322: Type '"layout.zod.ts#TextSchema"' is not assignable to type 'never'.
RESTORE PROVEN: git diff HEAD empty AND hash a96fce18feff5bf04102b70adaafa5b2e82600a8 == HEAD blob a96fce18feff5bf04102b70adaafa5b2e82600a8

Mechanics: absolute paths from git rev-parse --show-toplevel, restore via git checkout HEAD -- ABSOLUTE_PATH (never bare) under trap ... EXIT INT TERM, mutation proven by anchored count 1 to 0 and a git hash-object differing from the HEAD blob, restore proven both ways. Resolution path for the ablation: both instruments read SOURCE — the pin file imports '../zod/layout.zod' relatively and tsc -p tsconfig.test.json compiles src/, so dist is on neither path and no rebuild is part of it.


Gates — exit codes captured before any pipe, verdict lines quoted

gateresult
pnpm --filter @object-ui/types type-checkechoed tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json; lock verdict command-exit 0
root-form pnpm exec vitest run packages/types/ — BEFORE (40c479af2)Test Files 75 passed (75) / Tests 861 passed (861)
root-form pnpm exec vitest run packages/types/ — AFTER (065b16b98)Test Files 76 passed (76) / Tests 938 passed (938)+1 file, +77 tests, exactly the new pin file; nothing lost
zod-mirror-parity runtime halfTest Files 1 passed (1) / Tests 5 passed (5), PARITY_EXIT=0
zod-mirror-parity compile-time halfcarried by type-check above (it is a tsconfig.test.json assertion, not a vitest case)
pnpm exec eslint . — plain form, whole repo, at 065b16b984023 files linted, 0 errors, 11516 warnings (11518 before; the 2 removed were mine). All 13 touched/added files: 0 errors; the added test file and the parity file: 0 warnings
node scripts/check-changeset-presence.mjsexit 0 — "12 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)"
downstream consumer type-check, pnpm --filter @object-ui/components type-checkcommand-exit 0 — the renderers now type-check against the declared types instead of any, resolved through the freshly built dist (packages/components/tsconfig.json overrides the root paths, so it reads the built .d.ts, not source)

Heavy runs went through the container's shared verify lock; the --filter vitest forms AGENTS.md guard-refuses were not used.

The changeset is minor (⛔ not major) and states the widening in words, including the value-dimension narrowing.


A 14th key exists — reported, NOT folded in

The re-derivation ran the opposite way round from the census (enumerate every schema.KEYNAME read in each of the 8 renderers, subtract the declared members) and found 4 more genuinely-read undeclared keys the docs-driven census could not see, plus one declared-but-dead key. Per the dispatch order they are reported, not folded in: filed as #6938.

  • CheckboxSchema.wrapperClass (form/checkbox.tsx:33) — the same key as two of the 13, on a third type, undeclared only because the checkbox docs page is a six-line summary
  • ContextMenuSchema.triggerClassName (:87), contentClassName (:88), modal (:91)
  • ContextMenuSchema.children is declared required on both faces and read by nothing — the renderer renders schema.trigger, never schema.children

That sweep covered only these 8 types, so it is a floor, not a census.


Open questions for contract review

  1. CarouselSchema.opts shape. Open record (what shipped here, preserves every working document) vs the docs' { loop, align } pair (stronger authoring guard, refuses authored embla options that work today). Recommendation: keep it open in this PR and rule on it against real authored data, because narrowing is the irreversible direction.
  2. The two alias pairs (content/value, nodes/data). This PR declares both spellings because both are read. Recommendation: a follow-up ADR-0049 card retires one of each, rather than leaving two dialects declared forever.
  3. TreeViewSchema.data is required while nodes is the spelling the renderer prefers. A nodes-only document is still refused. Recommendation: a separate card, since relaxing a required key is an accept-set change of its own.

Generated by Claude Code

The undeclared-but-consumed census (objectui#6150) found 68 documented keys
that are not declared members of their shipped type; 13 were shown to be
genuinely READ by the renderer. Those 13 are declared here on both faces —
the TypeScript interface and, for 12 of them, the hand-written zod mirror.
Key membership is not widened: every touched mirror extends the
`.passthrough()` `BaseSchema`, so all 13 already parsed green and already
survived the parse, admitted unexamined. What changes is declaration (the key
becomes a member of the shipped type) and, for the 12 mirrored keys, value
enforcement — which in the value dimension is a narrowing.
`TreeViewSchema.onNodeClick` is INVOKED, not read as a value, so it gets no
mirror: objectui#6152 routes that class to `RuntimeOnlyDeclared` in
`zod-mirror-parity.test.ts`, and this is the first pair to sit there without
also sitting in `UnmirroredDeclared`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
`SchemaNode` itself admits `null | undefined`, so wrapping the property in
`NonNullable` stripped those limbs out of the union and compared a different
type — `tsc -p tsconfig.test.json` read `Type 'false' does not satisfy the
constraint 'true'`. Read raw the two sides are equal, and the guard still
bites: an undeclared `trigger` resolves to `any` through `BaseSchema`'s index
signature, and `Equal< any, … >` is false.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
…r `any`
`pnpm exec eslint .` flagged two `no-explicit-any` warnings introduced by the
`Case.mirror` handle. The structural type it needs is small and writable, so
write it: `data` is the parsed document, `error` is the issue list the refusal
assertions read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3179.6 KB3222.7 KB
Main entry chunk (gzip)143.6 KB350 KB
Entry fileindex-RnBefW7y.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)12.46KB4.71KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.09KB116.42KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)175.69KB48.80KB
fields (index.js)243.65KB61.63KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)247.26KB63.17KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.08KB54.61KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.05KB8.37KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.79KB21.10KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)76.75KB25.49KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-sam
os-sam marked this pull request as ready for review August 31, 2026 03:11
@os-sam
os-sam added this pull request to the merge queueAug 31, 2026
@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Aug 31, 2026
@os-sam
os-sam added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 2c45966Aug 31, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6150-undeclared-but-consumed-keys branch August 31, 2026 03:46
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(types): declare the 13 renderer-read keys that no shipped type declared by os-sam · Pull Request #6945 · objectstack-ai/objectui · GitHub
Skip to content

feat(types): declare the 13 renderer-read keys that no shipped type declared - #6945

Merged
os-sam merged 3 commits into
mainfrom
claude/issue-6150-undeclared-but-consumed-keys
Aug 31, 2026
Merged

feat(types): declare the 13 renderer-read keys that no shipped type declared#6945
os-sam merged 3 commits into
mainfrom
claude/issue-6150-undeclared-but-consumed-keys

Conversation

@os-sam

Copy link
Copy Markdown
Collaborator

Fixes#6150

⚠️ Contract-review tier (needs:contract-review). Staying in draft; not marked ready, not enqueued, no auto-merge.

⚠️ Generics are written in words below, not in angle brackets — the body sanitizer eats anything tag-shaped, including inside backticks (the card itself says so).

Head this PR's evidence was measured on: 065b16b98. Base: 40c479af2.


⛔ Read this before the table: the accept set did NOT widen in the key dimension

The dispatch order asked me to establish this per schema rather than assume one answer, and to say so plainly if passthrough meant the before-state already accepted. It did. Measured, not assumed:

All 8 touched mirrors extend the zod BaseSchema, which ends .passthrough(), and .extend() carries that policy through. Read off the built mirrors, catchall is z.unknown() on all 8. So before this PR every one of the 13 keys already parsed green and already SURVIVED the parse — admitted unexamined, neither refused nor stripped.

So the PR does not claim an accept-set change it did not make. What it actually changes is two things:

  1. Declaration. The key becomes a declared member of the shipped TypeScript type. Today these reads compile only because BaseSchema ends with [key: string]: any (finding(types): BaseSchema's [key: string]: any leaves every component schema open, so a "declare the surface" fix can never reject a misspelled TOP-LEVEL key #5155), so schema.trigger on a type that never declared trigger resolves to any.
  2. Enforcement — and in the VALUE dimension this is a NARROWING, not a widening. For the 12 keys that gained a zod mirror entry, the value is now validated: { type: 'text', content: 42 } parsed green before and is refused at content now. That is a behaviour change for documents carrying a wrong-typed value under one of these 13 names, and it is the point — declared === enforced.

Because acceptance cannot tell "declared" from "admitted unexamined" under passthrough, membership is asserted on the mirror's own .shape, never on parse acceptance — the form object-grid-title-mirrored.test.ts established for #6639.


Per-key before / after — the table triage asked for

Probed by parsing real documents through the builtpackages/types/dist/zod/index.zod.js in two worktrees: 40c479af2 (before) and 065b16b98 (after). Control document per type = required keys only, carrying none of the 13.

keyin mirror .shapedoc carrying the keydoc carrying a WRONG-TYPED valuecontrol docundeclared-key control
TextSchema.contentfalse → trueaccepted+survives → accepted+survivesaccepted → refused at contentgreen → greenadmitted → admitted
CarouselSchema.optsfalse → trueaccepted+survives → accepted+survivesaccepted → refused at optsgreen → greenadmitted → admitted
CarouselSchema.orientationfalse → trueaccepted+survives → accepted+survivesaccepted → refused at orientationgreen → greenadmitted → admitted
CarouselSchema.itemClassNamefalse → trueaccepted+survives → accepted+survivesaccepted → refused at itemClassNamegreen → greenadmitted → admitted
FilterBuilderSchema.wrapperClassfalse → trueaccepted+survives → accepted+survivesaccepted → refused at wrapperClassgreen → greenadmitted → admitted
TreeViewSchema.nodesfalse → trueaccepted+survives → accepted+survivesaccepted → refused at nodesgreen → greenadmitted → admitted
TreeViewSchema.titlefalse → trueaccepted+survives → accepted+survivesaccepted → refused at titlegreen → greenadmitted → admitted
TreeViewSchema.onNodeClickfalse → false (deliberate)accepted+survives → accepted+survivesaccepted → accepted (no mirror, so no enforcement)green → greenadmitted → admitted
CheckboxSchema.requiredfalse → trueaccepted+survives → accepted+survivesaccepted → refused at requiredgreen → greenadmitted → admitted
FileUploadSchema.buttonTextfalse → trueaccepted+survives → accepted+survivesaccepted → refused at buttonTextgreen → greenadmitted → admitted
FileUploadSchema.wrapperClassfalse → trueaccepted+survives → accepted+survivesaccepted → refused at wrapperClassgreen → greenadmitted → admitted
HoverCardSchema.alignfalse → trueaccepted+survives → accepted+survivesaccepted → refused at aligngreen → greenadmitted → admitted
ContextMenuSchema.triggerfalse → trueaccepted+survives → accepted+survivesaccepted → refused at triggergreen → greenadmitted → admitted

"Widened only what I meant to", per schema: the rightmost column is that proof. On all 8 mirrors, a document carrying an undeclared key of the same wrong type is still admitted and still survives the parse — before AND after. The unknown-key policy of every touched mirror is exactly what it was. This is also pinned per key in the test file, so it cannot silently stop being true.

One extra measurement, because declaring nodes invites a wrong inference: a { type: 'tree-view', nodes: [...] } document with no data is REFUSED at data, before and after. data stays required on both faces. Declaring nodes records the read; it does not by itself make a nodes-only document legal. Relaxing data is an accept-set change and a separate ruling.


Premise re-derived, not inherited

Module resolution path, stated before measuring.packages/types/dist did not exist in the fresh worktree, so I built it (pnpm --filter @object-ui/types build, verdict command-exit 0) and measured through packages/types/dist/index.d.ts — the census's own path, derivePackageTypePaths() style — and independently through packages/types/src/index.ts. Declared-member sets for all 8 types were identical between dist and src, so no stale-build reading is in play. Members were resolved with the TypeScript compiler API (getPropertiesOfType), which sees inherited members and reports the string index signature separately, so [key: string]: any could not mask the answer.

Result: 13/13 still undeclared on base. After the change, re-measured through the rebuilt dist/index.d.ts: 13/13 declared, index signature still present on all 8 (so nothing about BaseSchema moved).

Every read re-verified at its cited site. All 13 reads are still present. One line number drifted; the read is the fact:

keycensus saidmeasured now
TextSchema.contentbasic/text.tsx:51,56:51,56 — unchanged
CarouselSchema.opts / orientation / itemClassNamecomplex/carousel.tsx:23 / 24 / 30unchanged
FilterBuilderSchema.wrapperClasscomplex/filter-builder.tsx:37unchanged
TreeViewSchema.nodes / onNodeClick / titledata-display/tree-view.tsx:105 / 98,99 / 115,117unchanged
CheckboxSchema.requiredform/checkbox.tsx:45,49unchanged
FileUploadSchema.buttonText / wrapperClassform/file-upload.tsx:123 / 78unchanged
HoverCardSchema.alignoverlay/hover-card.tsx:24unchanged
ContextMenuSchema.triggeroverlay/context-menu.tsx:64:95 — drifted, read intact

Nothing was dropped. No key's reader had been removed.

READ vs INVOKED.TreeViewSchema.onNodeClick is invokedif (schema.onNodeClick) then schema.onNodeClick(node), with node the clicked TreeNode and the return value discarded. Its declared type is therefore the call signature (node: TreeNode) => void, not a value shape, and it is pinned as such with an invariant equality assertion. The other 12 are value reads.


Three declarations that are not "declare what is read" on autopilot

Each says so in its own doc comment; all three are flagged for the contract reviewer.

  • CarouselSchema.opts is an OPEN record (string keys, unknown values), not the docs page's two-key shape. The renderer forwards the whole bag verbatim to embla (opts={schema.opts}), so every other embla option authored today reaches the library and works. Declaring the documented pair would refuse those documents — an accept-set narrowing on a published surface, which is a ruling, not a declaration. The card itself flagged this key as the one that "may want a narrower shape"; I declined to narrow it without a ruling.
  • ContextMenuSchema.trigger is OPTIONAL although the docs page shows it required. The renderer substitutes a placeholder node when it is absent, so every trigger-less document is legal today and declaring it required would refuse them.
  • TreeViewSchema.onNodeClick gets NO zod mirror. A function cannot appear in an authored JSON document, so it is a runtime slot; 121 declared-but-unmirrored keys across 16 schema pairs — the lane #6058's new UnmirroredDeclared ledger made visible #6152 ruled that class is never mirrored and is recorded in zod-mirror-parity.test.ts's RuntimeOnlyDeclared instead — the step-3 exception that file's own header spells out. This is the first pair to sit in RuntimeOnlyDeclared without also sitting in UnmirroredDeclared, so that file's two population counts move with it (6 entries / 23 keys to 7 / 24; the "no entry in either" population 142 to 141). ⛔ UnmirroredDeclared is shrink-only and was not touched.

Two of the 13 declare a second spelling for a slot that already has onecontent beside value, nodes beside data — because that is what the renderers read (schema.content || schema.value, boundData || schema.nodes || schema.data). AGENTS.md #0.1's "one strict contract beats N dialects" argues for retiring one of each pair; that is an ADR-0049 enforce-or-remove question and deliberately not decided here. Each doc comment names which spelling wins.


Ablation — direction predicted IN WRITING before the run

Predicted before anything touched disk (mutating the fact, not the assertion): delete the content shape entry from the TextSchema mirror, leaving the TS declaration in place.

  1. membership assertion → RED
  2. wrong-typed-value refusal → RED
  3. "accepts the declared value and it SURVIVES" → STAYS GREEN — the counter-intuitive half, and the whole reason membership is read off .shape: under passthrough an undeclaredcontent: 'hello' is still accepted and still survives, so a suite measuring only acceptance would read this ablation as fully green
  4. read-site assertion and both control assertions → GREEN
  5. the other 12 keys → GREEN
  6. verdict: exactly 2 failed | 75 passed (77)
  7. second instrument: zod-mirror-parity.test.ts's compile-time half reddens naming layout.zod.ts#TextSchema; its runtime census (5 tests) does not move, by that file's documented design

Observed, on 065b16b98, all seven as predicted:

MUTATION PROVEN ON DISK: anchor 1->0 and hash d264a81ccb08838670e9e596ef3d407e3625855a != HEAD blob a96fce18feff5bf04102b70adaafa5b2e82600a8
vitest exit = 1
Test Files 1 failed (1)
Tests 2 failed | 75 passed (77)
x TextSchema.content > is a member of the mirror shape (membership cannot be read off acceptance under passthrough)
x TextSchema.content > refuses a wrong-typed value AT the key - the enforcement mirroring adds
tsc -p tsconfig.test.json exit = 1
src/__tests__/zod-mirror-parity.test.ts(1240,14): error TS2322: Type '"layout.zod.ts#TextSchema"' is not assignable to type 'never'.
RESTORE PROVEN: git diff HEAD empty AND hash a96fce18feff5bf04102b70adaafa5b2e82600a8 == HEAD blob a96fce18feff5bf04102b70adaafa5b2e82600a8

Mechanics: absolute paths from git rev-parse --show-toplevel, restore via git checkout HEAD -- ABSOLUTE_PATH (never bare) under trap ... EXIT INT TERM, mutation proven by anchored count 1 to 0 and a git hash-object differing from the HEAD blob, restore proven both ways. Resolution path for the ablation: both instruments read SOURCE — the pin file imports '../zod/layout.zod' relatively and tsc -p tsconfig.test.json compiles src/, so dist is on neither path and no rebuild is part of it.


Gates — exit codes captured before any pipe, verdict lines quoted

gateresult
pnpm --filter @object-ui/types type-checkechoed tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json; lock verdict command-exit 0
root-form pnpm exec vitest run packages/types/ — BEFORE (40c479af2)Test Files 75 passed (75) / Tests 861 passed (861)
root-form pnpm exec vitest run packages/types/ — AFTER (065b16b98)Test Files 76 passed (76) / Tests 938 passed (938)+1 file, +77 tests, exactly the new pin file; nothing lost
zod-mirror-parity runtime halfTest Files 1 passed (1) / Tests 5 passed (5), PARITY_EXIT=0
zod-mirror-parity compile-time halfcarried by type-check above (it is a tsconfig.test.json assertion, not a vitest case)
pnpm exec eslint . — plain form, whole repo, at 065b16b984023 files linted, 0 errors, 11516 warnings (11518 before; the 2 removed were mine). All 13 touched/added files: 0 errors; the added test file and the parity file: 0 warnings
node scripts/check-changeset-presence.mjsexit 0 — "12 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)"
downstream consumer type-check, pnpm --filter @object-ui/components type-checkcommand-exit 0 — the renderers now type-check against the declared types instead of any, resolved through the freshly built dist (packages/components/tsconfig.json overrides the root paths, so it reads the built .d.ts, not source)

Heavy runs went through the container's shared verify lock; the --filter vitest forms AGENTS.md guard-refuses were not used.

The changeset is minor (⛔ not major) and states the widening in words, including the value-dimension narrowing.


A 14th key exists — reported, NOT folded in

The re-derivation ran the opposite way round from the census (enumerate every schema.KEYNAME read in each of the 8 renderers, subtract the declared members) and found 4 more genuinely-read undeclared keys the docs-driven census could not see, plus one declared-but-dead key. Per the dispatch order they are reported, not folded in: filed as #6938.

  • CheckboxSchema.wrapperClass (form/checkbox.tsx:33) — the same key as two of the 13, on a third type, undeclared only because the checkbox docs page is a six-line summary
  • ContextMenuSchema.triggerClassName (:87), contentClassName (:88), modal (:91)
  • ContextMenuSchema.children is declared required on both faces and read by nothing — the renderer renders schema.trigger, never schema.children

That sweep covered only these 8 types, so it is a floor, not a census.


Open questions for contract review

  1. CarouselSchema.opts shape. Open record (what shipped here, preserves every working document) vs the docs' { loop, align } pair (stronger authoring guard, refuses authored embla options that work today). Recommendation: keep it open in this PR and rule on it against real authored data, because narrowing is the irreversible direction.
  2. The two alias pairs (content/value, nodes/data). This PR declares both spellings because both are read. Recommendation: a follow-up ADR-0049 card retires one of each, rather than leaving two dialects declared forever.
  3. TreeViewSchema.data is required while nodes is the spelling the renderer prefers. A nodes-only document is still refused. Recommendation: a separate card, since relaxing a required key is an accept-set change of its own.

Generated by Claude Code

The undeclared-but-consumed census (objectui#6150) found 68 documented keys
that are not declared members of their shipped type; 13 were shown to be
genuinely READ by the renderer. Those 13 are declared here on both faces —
the TypeScript interface and, for 12 of them, the hand-written zod mirror.
Key membership is not widened: every touched mirror extends the
`.passthrough()` `BaseSchema`, so all 13 already parsed green and already
survived the parse, admitted unexamined. What changes is declaration (the key
becomes a member of the shipped type) and, for the 12 mirrored keys, value
enforcement — which in the value dimension is a narrowing.
`TreeViewSchema.onNodeClick` is INVOKED, not read as a value, so it gets no
mirror: objectui#6152 routes that class to `RuntimeOnlyDeclared` in
`zod-mirror-parity.test.ts`, and this is the first pair to sit there without
also sitting in `UnmirroredDeclared`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
`SchemaNode` itself admits `null | undefined`, so wrapping the property in
`NonNullable` stripped those limbs out of the union and compared a different
type — `tsc -p tsconfig.test.json` read `Type 'false' does not satisfy the
constraint 'true'`. Read raw the two sides are equal, and the guard still
bites: an undeclared `trigger` resolves to `any` through `BaseSchema`'s index
signature, and `Equal< any, … >` is false.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
…r `any`
`pnpm exec eslint .` flagged two `no-explicit-any` warnings introduced by the
`Case.mirror` handle. The structural type it needs is small and writable, so
write it: `data` is the parsed document, `error` is the issue list the refusal
assertions read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3179.6 KB3222.7 KB
Main entry chunk (gzip)143.6 KB350 KB
Entry fileindex-RnBefW7y.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)12.46KB4.71KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.09KB116.42KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)175.69KB48.80KB
fields (index.js)243.65KB61.63KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)247.26KB63.17KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.08KB54.61KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.05KB8.37KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.79KB21.10KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)76.75KB25.49KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-sam
os-sam marked this pull request as ready for review August 31, 2026 03:11
@os-sam
os-sam added this pull request to the merge queueAug 31, 2026
@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Aug 31, 2026
@os-sam
os-sam added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 2c45966Aug 31, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6150-undeclared-but-consumed-keys branch August 31, 2026 03:46
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(types): declare the 13 renderer-read keys that no shipped type declared by os-sam · Pull Request #6945 · objectstack-ai/objectui · GitHub
Skip to content

feat(types): declare the 13 renderer-read keys that no shipped type declared - #6945

Merged
os-sam merged 3 commits into
mainfrom
claude/issue-6150-undeclared-but-consumed-keys
Aug 31, 2026
Merged

feat(types): declare the 13 renderer-read keys that no shipped type declared#6945
os-sam merged 3 commits into
mainfrom
claude/issue-6150-undeclared-but-consumed-keys

Conversation

@os-sam

Copy link
Copy Markdown
Collaborator

Fixes#6150

⚠️ Contract-review tier (needs:contract-review). Staying in draft; not marked ready, not enqueued, no auto-merge.

⚠️ Generics are written in words below, not in angle brackets — the body sanitizer eats anything tag-shaped, including inside backticks (the card itself says so).

Head this PR's evidence was measured on: 065b16b98. Base: 40c479af2.


⛔ Read this before the table: the accept set did NOT widen in the key dimension

The dispatch order asked me to establish this per schema rather than assume one answer, and to say so plainly if passthrough meant the before-state already accepted. It did. Measured, not assumed:

All 8 touched mirrors extend the zod BaseSchema, which ends .passthrough(), and .extend() carries that policy through. Read off the built mirrors, catchall is z.unknown() on all 8. So before this PR every one of the 13 keys already parsed green and already SURVIVED the parse — admitted unexamined, neither refused nor stripped.

So the PR does not claim an accept-set change it did not make. What it actually changes is two things:

  1. Declaration. The key becomes a declared member of the shipped TypeScript type. Today these reads compile only because BaseSchema ends with [key: string]: any (finding(types): BaseSchema's [key: string]: any leaves every component schema open, so a "declare the surface" fix can never reject a misspelled TOP-LEVEL key #5155), so schema.trigger on a type that never declared trigger resolves to any.
  2. Enforcement — and in the VALUE dimension this is a NARROWING, not a widening. For the 12 keys that gained a zod mirror entry, the value is now validated: { type: 'text', content: 42 } parsed green before and is refused at content now. That is a behaviour change for documents carrying a wrong-typed value under one of these 13 names, and it is the point — declared === enforced.

Because acceptance cannot tell "declared" from "admitted unexamined" under passthrough, membership is asserted on the mirror's own .shape, never on parse acceptance — the form object-grid-title-mirrored.test.ts established for #6639.


Per-key before / after — the table triage asked for

Probed by parsing real documents through the builtpackages/types/dist/zod/index.zod.js in two worktrees: 40c479af2 (before) and 065b16b98 (after). Control document per type = required keys only, carrying none of the 13.

keyin mirror .shapedoc carrying the keydoc carrying a WRONG-TYPED valuecontrol docundeclared-key control
TextSchema.contentfalse → trueaccepted+survives → accepted+survivesaccepted → refused at contentgreen → greenadmitted → admitted
CarouselSchema.optsfalse → trueaccepted+survives → accepted+survivesaccepted → refused at optsgreen → greenadmitted → admitted
CarouselSchema.orientationfalse → trueaccepted+survives → accepted+survivesaccepted → refused at orientationgreen → greenadmitted → admitted
CarouselSchema.itemClassNamefalse → trueaccepted+survives → accepted+survivesaccepted → refused at itemClassNamegreen → greenadmitted → admitted
FilterBuilderSchema.wrapperClassfalse → trueaccepted+survives → accepted+survivesaccepted → refused at wrapperClassgreen → greenadmitted → admitted
TreeViewSchema.nodesfalse → trueaccepted+survives → accepted+survivesaccepted → refused at nodesgreen → greenadmitted → admitted
TreeViewSchema.titlefalse → trueaccepted+survives → accepted+survivesaccepted → refused at titlegreen → greenadmitted → admitted
TreeViewSchema.onNodeClickfalse → false (deliberate)accepted+survives → accepted+survivesaccepted → accepted (no mirror, so no enforcement)green → greenadmitted → admitted
CheckboxSchema.requiredfalse → trueaccepted+survives → accepted+survivesaccepted → refused at requiredgreen → greenadmitted → admitted
FileUploadSchema.buttonTextfalse → trueaccepted+survives → accepted+survivesaccepted → refused at buttonTextgreen → greenadmitted → admitted
FileUploadSchema.wrapperClassfalse → trueaccepted+survives → accepted+survivesaccepted → refused at wrapperClassgreen → greenadmitted → admitted
HoverCardSchema.alignfalse → trueaccepted+survives → accepted+survivesaccepted → refused at aligngreen → greenadmitted → admitted
ContextMenuSchema.triggerfalse → trueaccepted+survives → accepted+survivesaccepted → refused at triggergreen → greenadmitted → admitted

"Widened only what I meant to", per schema: the rightmost column is that proof. On all 8 mirrors, a document carrying an undeclared key of the same wrong type is still admitted and still survives the parse — before AND after. The unknown-key policy of every touched mirror is exactly what it was. This is also pinned per key in the test file, so it cannot silently stop being true.

One extra measurement, because declaring nodes invites a wrong inference: a { type: 'tree-view', nodes: [...] } document with no data is REFUSED at data, before and after. data stays required on both faces. Declaring nodes records the read; it does not by itself make a nodes-only document legal. Relaxing data is an accept-set change and a separate ruling.


Premise re-derived, not inherited

Module resolution path, stated before measuring.packages/types/dist did not exist in the fresh worktree, so I built it (pnpm --filter @object-ui/types build, verdict command-exit 0) and measured through packages/types/dist/index.d.ts — the census's own path, derivePackageTypePaths() style — and independently through packages/types/src/index.ts. Declared-member sets for all 8 types were identical between dist and src, so no stale-build reading is in play. Members were resolved with the TypeScript compiler API (getPropertiesOfType), which sees inherited members and reports the string index signature separately, so [key: string]: any could not mask the answer.

Result: 13/13 still undeclared on base. After the change, re-measured through the rebuilt dist/index.d.ts: 13/13 declared, index signature still present on all 8 (so nothing about BaseSchema moved).

Every read re-verified at its cited site. All 13 reads are still present. One line number drifted; the read is the fact:

keycensus saidmeasured now
TextSchema.contentbasic/text.tsx:51,56:51,56 — unchanged
CarouselSchema.opts / orientation / itemClassNamecomplex/carousel.tsx:23 / 24 / 30unchanged
FilterBuilderSchema.wrapperClasscomplex/filter-builder.tsx:37unchanged
TreeViewSchema.nodes / onNodeClick / titledata-display/tree-view.tsx:105 / 98,99 / 115,117unchanged
CheckboxSchema.requiredform/checkbox.tsx:45,49unchanged
FileUploadSchema.buttonText / wrapperClassform/file-upload.tsx:123 / 78unchanged
HoverCardSchema.alignoverlay/hover-card.tsx:24unchanged
ContextMenuSchema.triggeroverlay/context-menu.tsx:64:95 — drifted, read intact

Nothing was dropped. No key's reader had been removed.

READ vs INVOKED.TreeViewSchema.onNodeClick is invokedif (schema.onNodeClick) then schema.onNodeClick(node), with node the clicked TreeNode and the return value discarded. Its declared type is therefore the call signature (node: TreeNode) => void, not a value shape, and it is pinned as such with an invariant equality assertion. The other 12 are value reads.


Three declarations that are not "declare what is read" on autopilot

Each says so in its own doc comment; all three are flagged for the contract reviewer.

  • CarouselSchema.opts is an OPEN record (string keys, unknown values), not the docs page's two-key shape. The renderer forwards the whole bag verbatim to embla (opts={schema.opts}), so every other embla option authored today reaches the library and works. Declaring the documented pair would refuse those documents — an accept-set narrowing on a published surface, which is a ruling, not a declaration. The card itself flagged this key as the one that "may want a narrower shape"; I declined to narrow it without a ruling.
  • ContextMenuSchema.trigger is OPTIONAL although the docs page shows it required. The renderer substitutes a placeholder node when it is absent, so every trigger-less document is legal today and declaring it required would refuse them.
  • TreeViewSchema.onNodeClick gets NO zod mirror. A function cannot appear in an authored JSON document, so it is a runtime slot; 121 declared-but-unmirrored keys across 16 schema pairs — the lane #6058's new UnmirroredDeclared ledger made visible #6152 ruled that class is never mirrored and is recorded in zod-mirror-parity.test.ts's RuntimeOnlyDeclared instead — the step-3 exception that file's own header spells out. This is the first pair to sit in RuntimeOnlyDeclared without also sitting in UnmirroredDeclared, so that file's two population counts move with it (6 entries / 23 keys to 7 / 24; the "no entry in either" population 142 to 141). ⛔ UnmirroredDeclared is shrink-only and was not touched.

Two of the 13 declare a second spelling for a slot that already has onecontent beside value, nodes beside data — because that is what the renderers read (schema.content || schema.value, boundData || schema.nodes || schema.data). AGENTS.md #0.1's "one strict contract beats N dialects" argues for retiring one of each pair; that is an ADR-0049 enforce-or-remove question and deliberately not decided here. Each doc comment names which spelling wins.


Ablation — direction predicted IN WRITING before the run

Predicted before anything touched disk (mutating the fact, not the assertion): delete the content shape entry from the TextSchema mirror, leaving the TS declaration in place.

  1. membership assertion → RED
  2. wrong-typed-value refusal → RED
  3. "accepts the declared value and it SURVIVES" → STAYS GREEN — the counter-intuitive half, and the whole reason membership is read off .shape: under passthrough an undeclaredcontent: 'hello' is still accepted and still survives, so a suite measuring only acceptance would read this ablation as fully green
  4. read-site assertion and both control assertions → GREEN
  5. the other 12 keys → GREEN
  6. verdict: exactly 2 failed | 75 passed (77)
  7. second instrument: zod-mirror-parity.test.ts's compile-time half reddens naming layout.zod.ts#TextSchema; its runtime census (5 tests) does not move, by that file's documented design

Observed, on 065b16b98, all seven as predicted:

MUTATION PROVEN ON DISK: anchor 1->0 and hash d264a81ccb08838670e9e596ef3d407e3625855a != HEAD blob a96fce18feff5bf04102b70adaafa5b2e82600a8
vitest exit = 1
Test Files 1 failed (1)
Tests 2 failed | 75 passed (77)
x TextSchema.content > is a member of the mirror shape (membership cannot be read off acceptance under passthrough)
x TextSchema.content > refuses a wrong-typed value AT the key - the enforcement mirroring adds
tsc -p tsconfig.test.json exit = 1
src/__tests__/zod-mirror-parity.test.ts(1240,14): error TS2322: Type '"layout.zod.ts#TextSchema"' is not assignable to type 'never'.
RESTORE PROVEN: git diff HEAD empty AND hash a96fce18feff5bf04102b70adaafa5b2e82600a8 == HEAD blob a96fce18feff5bf04102b70adaafa5b2e82600a8

Mechanics: absolute paths from git rev-parse --show-toplevel, restore via git checkout HEAD -- ABSOLUTE_PATH (never bare) under trap ... EXIT INT TERM, mutation proven by anchored count 1 to 0 and a git hash-object differing from the HEAD blob, restore proven both ways. Resolution path for the ablation: both instruments read SOURCE — the pin file imports '../zod/layout.zod' relatively and tsc -p tsconfig.test.json compiles src/, so dist is on neither path and no rebuild is part of it.


Gates — exit codes captured before any pipe, verdict lines quoted

gateresult
pnpm --filter @object-ui/types type-checkechoed tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json; lock verdict command-exit 0
root-form pnpm exec vitest run packages/types/ — BEFORE (40c479af2)Test Files 75 passed (75) / Tests 861 passed (861)
root-form pnpm exec vitest run packages/types/ — AFTER (065b16b98)Test Files 76 passed (76) / Tests 938 passed (938)+1 file, +77 tests, exactly the new pin file; nothing lost
zod-mirror-parity runtime halfTest Files 1 passed (1) / Tests 5 passed (5), PARITY_EXIT=0
zod-mirror-parity compile-time halfcarried by type-check above (it is a tsconfig.test.json assertion, not a vitest case)
pnpm exec eslint . — plain form, whole repo, at 065b16b984023 files linted, 0 errors, 11516 warnings (11518 before; the 2 removed were mine). All 13 touched/added files: 0 errors; the added test file and the parity file: 0 warnings
node scripts/check-changeset-presence.mjsexit 0 — "12 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)"
downstream consumer type-check, pnpm --filter @object-ui/components type-checkcommand-exit 0 — the renderers now type-check against the declared types instead of any, resolved through the freshly built dist (packages/components/tsconfig.json overrides the root paths, so it reads the built .d.ts, not source)

Heavy runs went through the container's shared verify lock; the --filter vitest forms AGENTS.md guard-refuses were not used.

The changeset is minor (⛔ not major) and states the widening in words, including the value-dimension narrowing.


A 14th key exists — reported, NOT folded in

The re-derivation ran the opposite way round from the census (enumerate every schema.KEYNAME read in each of the 8 renderers, subtract the declared members) and found 4 more genuinely-read undeclared keys the docs-driven census could not see, plus one declared-but-dead key. Per the dispatch order they are reported, not folded in: filed as #6938.

  • CheckboxSchema.wrapperClass (form/checkbox.tsx:33) — the same key as two of the 13, on a third type, undeclared only because the checkbox docs page is a six-line summary
  • ContextMenuSchema.triggerClassName (:87), contentClassName (:88), modal (:91)
  • ContextMenuSchema.children is declared required on both faces and read by nothing — the renderer renders schema.trigger, never schema.children

That sweep covered only these 8 types, so it is a floor, not a census.


Open questions for contract review

  1. CarouselSchema.opts shape. Open record (what shipped here, preserves every working document) vs the docs' { loop, align } pair (stronger authoring guard, refuses authored embla options that work today). Recommendation: keep it open in this PR and rule on it against real authored data, because narrowing is the irreversible direction.
  2. The two alias pairs (content/value, nodes/data). This PR declares both spellings because both are read. Recommendation: a follow-up ADR-0049 card retires one of each, rather than leaving two dialects declared forever.
  3. TreeViewSchema.data is required while nodes is the spelling the renderer prefers. A nodes-only document is still refused. Recommendation: a separate card, since relaxing a required key is an accept-set change of its own.

Generated by Claude Code

The undeclared-but-consumed census (objectui#6150) found 68 documented keys
that are not declared members of their shipped type; 13 were shown to be
genuinely READ by the renderer. Those 13 are declared here on both faces —
the TypeScript interface and, for 12 of them, the hand-written zod mirror.
Key membership is not widened: every touched mirror extends the
`.passthrough()` `BaseSchema`, so all 13 already parsed green and already
survived the parse, admitted unexamined. What changes is declaration (the key
becomes a member of the shipped type) and, for the 12 mirrored keys, value
enforcement — which in the value dimension is a narrowing.
`TreeViewSchema.onNodeClick` is INVOKED, not read as a value, so it gets no
mirror: objectui#6152 routes that class to `RuntimeOnlyDeclared` in
`zod-mirror-parity.test.ts`, and this is the first pair to sit there without
also sitting in `UnmirroredDeclared`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
`SchemaNode` itself admits `null | undefined`, so wrapping the property in
`NonNullable` stripped those limbs out of the union and compared a different
type — `tsc -p tsconfig.test.json` read `Type 'false' does not satisfy the
constraint 'true'`. Read raw the two sides are equal, and the guard still
bites: an undeclared `trigger` resolves to `any` through `BaseSchema`'s index
signature, and `Equal< any, … >` is false.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
…r `any`
`pnpm exec eslint .` flagged two `no-explicit-any` warnings introduced by the
`Case.mirror` handle. The structural type it needs is small and writable, so
write it: `data` is the parsed document, `error` is the issue list the refusal
assertions read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3179.6 KB3222.7 KB
Main entry chunk (gzip)143.6 KB350 KB
Entry fileindex-RnBefW7y.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)12.46KB4.71KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.09KB116.42KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)175.69KB48.80KB
fields (index.js)243.65KB61.63KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)247.26KB63.17KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.08KB54.61KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.05KB8.37KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.79KB21.10KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)76.75KB25.49KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-sam
os-sam marked this pull request as ready for review August 31, 2026 03:11
@os-sam
os-sam added this pull request to the merge queueAug 31, 2026
@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Aug 31, 2026
@os-sam
os-sam added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 2c45966Aug 31, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6150-undeclared-but-consumed-keys branch August 31, 2026 03:46
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(types): declare the 13 renderer-read keys that no shipped type declared by os-sam · Pull Request #6945 · objectstack-ai/objectui · GitHub
Skip to content

feat(types): declare the 13 renderer-read keys that no shipped type declared - #6945

Merged
os-sam merged 3 commits into
mainfrom
claude/issue-6150-undeclared-but-consumed-keys
Aug 31, 2026
Merged

feat(types): declare the 13 renderer-read keys that no shipped type declared#6945
os-sam merged 3 commits into
mainfrom
claude/issue-6150-undeclared-but-consumed-keys

Conversation

@os-sam

Copy link
Copy Markdown
Collaborator

Fixes#6150

⚠️ Contract-review tier (needs:contract-review). Staying in draft; not marked ready, not enqueued, no auto-merge.

⚠️ Generics are written in words below, not in angle brackets — the body sanitizer eats anything tag-shaped, including inside backticks (the card itself says so).

Head this PR's evidence was measured on: 065b16b98. Base: 40c479af2.


⛔ Read this before the table: the accept set did NOT widen in the key dimension

The dispatch order asked me to establish this per schema rather than assume one answer, and to say so plainly if passthrough meant the before-state already accepted. It did. Measured, not assumed:

All 8 touched mirrors extend the zod BaseSchema, which ends .passthrough(), and .extend() carries that policy through. Read off the built mirrors, catchall is z.unknown() on all 8. So before this PR every one of the 13 keys already parsed green and already SURVIVED the parse — admitted unexamined, neither refused nor stripped.

So the PR does not claim an accept-set change it did not make. What it actually changes is two things:

  1. Declaration. The key becomes a declared member of the shipped TypeScript type. Today these reads compile only because BaseSchema ends with [key: string]: any (finding(types): BaseSchema's [key: string]: any leaves every component schema open, so a "declare the surface" fix can never reject a misspelled TOP-LEVEL key #5155), so schema.trigger on a type that never declared trigger resolves to any.
  2. Enforcement — and in the VALUE dimension this is a NARROWING, not a widening. For the 12 keys that gained a zod mirror entry, the value is now validated: { type: 'text', content: 42 } parsed green before and is refused at content now. That is a behaviour change for documents carrying a wrong-typed value under one of these 13 names, and it is the point — declared === enforced.

Because acceptance cannot tell "declared" from "admitted unexamined" under passthrough, membership is asserted on the mirror's own .shape, never on parse acceptance — the form object-grid-title-mirrored.test.ts established for #6639.


Per-key before / after — the table triage asked for

Probed by parsing real documents through the builtpackages/types/dist/zod/index.zod.js in two worktrees: 40c479af2 (before) and 065b16b98 (after). Control document per type = required keys only, carrying none of the 13.

keyin mirror .shapedoc carrying the keydoc carrying a WRONG-TYPED valuecontrol docundeclared-key control
TextSchema.contentfalse → trueaccepted+survives → accepted+survivesaccepted → refused at contentgreen → greenadmitted → admitted
CarouselSchema.optsfalse → trueaccepted+survives → accepted+survivesaccepted → refused at optsgreen → greenadmitted → admitted
CarouselSchema.orientationfalse → trueaccepted+survives → accepted+survivesaccepted → refused at orientationgreen → greenadmitted → admitted
CarouselSchema.itemClassNamefalse → trueaccepted+survives → accepted+survivesaccepted → refused at itemClassNamegreen → greenadmitted → admitted
FilterBuilderSchema.wrapperClassfalse → trueaccepted+survives → accepted+survivesaccepted → refused at wrapperClassgreen → greenadmitted → admitted
TreeViewSchema.nodesfalse → trueaccepted+survives → accepted+survivesaccepted → refused at nodesgreen → greenadmitted → admitted
TreeViewSchema.titlefalse → trueaccepted+survives → accepted+survivesaccepted → refused at titlegreen → greenadmitted → admitted
TreeViewSchema.onNodeClickfalse → false (deliberate)accepted+survives → accepted+survivesaccepted → accepted (no mirror, so no enforcement)green → greenadmitted → admitted
CheckboxSchema.requiredfalse → trueaccepted+survives → accepted+survivesaccepted → refused at requiredgreen → greenadmitted → admitted
FileUploadSchema.buttonTextfalse → trueaccepted+survives → accepted+survivesaccepted → refused at buttonTextgreen → greenadmitted → admitted
FileUploadSchema.wrapperClassfalse → trueaccepted+survives → accepted+survivesaccepted → refused at wrapperClassgreen → greenadmitted → admitted
HoverCardSchema.alignfalse → trueaccepted+survives → accepted+survivesaccepted → refused at aligngreen → greenadmitted → admitted
ContextMenuSchema.triggerfalse → trueaccepted+survives → accepted+survivesaccepted → refused at triggergreen → greenadmitted → admitted

"Widened only what I meant to", per schema: the rightmost column is that proof. On all 8 mirrors, a document carrying an undeclared key of the same wrong type is still admitted and still survives the parse — before AND after. The unknown-key policy of every touched mirror is exactly what it was. This is also pinned per key in the test file, so it cannot silently stop being true.

One extra measurement, because declaring nodes invites a wrong inference: a { type: 'tree-view', nodes: [...] } document with no data is REFUSED at data, before and after. data stays required on both faces. Declaring nodes records the read; it does not by itself make a nodes-only document legal. Relaxing data is an accept-set change and a separate ruling.


Premise re-derived, not inherited

Module resolution path, stated before measuring.packages/types/dist did not exist in the fresh worktree, so I built it (pnpm --filter @object-ui/types build, verdict command-exit 0) and measured through packages/types/dist/index.d.ts — the census's own path, derivePackageTypePaths() style — and independently through packages/types/src/index.ts. Declared-member sets for all 8 types were identical between dist and src, so no stale-build reading is in play. Members were resolved with the TypeScript compiler API (getPropertiesOfType), which sees inherited members and reports the string index signature separately, so [key: string]: any could not mask the answer.

Result: 13/13 still undeclared on base. After the change, re-measured through the rebuilt dist/index.d.ts: 13/13 declared, index signature still present on all 8 (so nothing about BaseSchema moved).

Every read re-verified at its cited site. All 13 reads are still present. One line number drifted; the read is the fact:

keycensus saidmeasured now
TextSchema.contentbasic/text.tsx:51,56:51,56 — unchanged
CarouselSchema.opts / orientation / itemClassNamecomplex/carousel.tsx:23 / 24 / 30unchanged
FilterBuilderSchema.wrapperClasscomplex/filter-builder.tsx:37unchanged
TreeViewSchema.nodes / onNodeClick / titledata-display/tree-view.tsx:105 / 98,99 / 115,117unchanged
CheckboxSchema.requiredform/checkbox.tsx:45,49unchanged
FileUploadSchema.buttonText / wrapperClassform/file-upload.tsx:123 / 78unchanged
HoverCardSchema.alignoverlay/hover-card.tsx:24unchanged
ContextMenuSchema.triggeroverlay/context-menu.tsx:64:95 — drifted, read intact

Nothing was dropped. No key's reader had been removed.

READ vs INVOKED.TreeViewSchema.onNodeClick is invokedif (schema.onNodeClick) then schema.onNodeClick(node), with node the clicked TreeNode and the return value discarded. Its declared type is therefore the call signature (node: TreeNode) => void, not a value shape, and it is pinned as such with an invariant equality assertion. The other 12 are value reads.


Three declarations that are not "declare what is read" on autopilot

Each says so in its own doc comment; all three are flagged for the contract reviewer.

  • CarouselSchema.opts is an OPEN record (string keys, unknown values), not the docs page's two-key shape. The renderer forwards the whole bag verbatim to embla (opts={schema.opts}), so every other embla option authored today reaches the library and works. Declaring the documented pair would refuse those documents — an accept-set narrowing on a published surface, which is a ruling, not a declaration. The card itself flagged this key as the one that "may want a narrower shape"; I declined to narrow it without a ruling.
  • ContextMenuSchema.trigger is OPTIONAL although the docs page shows it required. The renderer substitutes a placeholder node when it is absent, so every trigger-less document is legal today and declaring it required would refuse them.
  • TreeViewSchema.onNodeClick gets NO zod mirror. A function cannot appear in an authored JSON document, so it is a runtime slot; 121 declared-but-unmirrored keys across 16 schema pairs — the lane #6058's new UnmirroredDeclared ledger made visible #6152 ruled that class is never mirrored and is recorded in zod-mirror-parity.test.ts's RuntimeOnlyDeclared instead — the step-3 exception that file's own header spells out. This is the first pair to sit in RuntimeOnlyDeclared without also sitting in UnmirroredDeclared, so that file's two population counts move with it (6 entries / 23 keys to 7 / 24; the "no entry in either" population 142 to 141). ⛔ UnmirroredDeclared is shrink-only and was not touched.

Two of the 13 declare a second spelling for a slot that already has onecontent beside value, nodes beside data — because that is what the renderers read (schema.content || schema.value, boundData || schema.nodes || schema.data). AGENTS.md #0.1's "one strict contract beats N dialects" argues for retiring one of each pair; that is an ADR-0049 enforce-or-remove question and deliberately not decided here. Each doc comment names which spelling wins.


Ablation — direction predicted IN WRITING before the run

Predicted before anything touched disk (mutating the fact, not the assertion): delete the content shape entry from the TextSchema mirror, leaving the TS declaration in place.

  1. membership assertion → RED
  2. wrong-typed-value refusal → RED
  3. "accepts the declared value and it SURVIVES" → STAYS GREEN — the counter-intuitive half, and the whole reason membership is read off .shape: under passthrough an undeclaredcontent: 'hello' is still accepted and still survives, so a suite measuring only acceptance would read this ablation as fully green
  4. read-site assertion and both control assertions → GREEN
  5. the other 12 keys → GREEN
  6. verdict: exactly 2 failed | 75 passed (77)
  7. second instrument: zod-mirror-parity.test.ts's compile-time half reddens naming layout.zod.ts#TextSchema; its runtime census (5 tests) does not move, by that file's documented design

Observed, on 065b16b98, all seven as predicted:

MUTATION PROVEN ON DISK: anchor 1->0 and hash d264a81ccb08838670e9e596ef3d407e3625855a != HEAD blob a96fce18feff5bf04102b70adaafa5b2e82600a8
vitest exit = 1
Test Files 1 failed (1)
Tests 2 failed | 75 passed (77)
x TextSchema.content > is a member of the mirror shape (membership cannot be read off acceptance under passthrough)
x TextSchema.content > refuses a wrong-typed value AT the key - the enforcement mirroring adds
tsc -p tsconfig.test.json exit = 1
src/__tests__/zod-mirror-parity.test.ts(1240,14): error TS2322: Type '"layout.zod.ts#TextSchema"' is not assignable to type 'never'.
RESTORE PROVEN: git diff HEAD empty AND hash a96fce18feff5bf04102b70adaafa5b2e82600a8 == HEAD blob a96fce18feff5bf04102b70adaafa5b2e82600a8

Mechanics: absolute paths from git rev-parse --show-toplevel, restore via git checkout HEAD -- ABSOLUTE_PATH (never bare) under trap ... EXIT INT TERM, mutation proven by anchored count 1 to 0 and a git hash-object differing from the HEAD blob, restore proven both ways. Resolution path for the ablation: both instruments read SOURCE — the pin file imports '../zod/layout.zod' relatively and tsc -p tsconfig.test.json compiles src/, so dist is on neither path and no rebuild is part of it.


Gates — exit codes captured before any pipe, verdict lines quoted

gateresult
pnpm --filter @object-ui/types type-checkechoed tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json; lock verdict command-exit 0
root-form pnpm exec vitest run packages/types/ — BEFORE (40c479af2)Test Files 75 passed (75) / Tests 861 passed (861)
root-form pnpm exec vitest run packages/types/ — AFTER (065b16b98)Test Files 76 passed (76) / Tests 938 passed (938)+1 file, +77 tests, exactly the new pin file; nothing lost
zod-mirror-parity runtime halfTest Files 1 passed (1) / Tests 5 passed (5), PARITY_EXIT=0
zod-mirror-parity compile-time halfcarried by type-check above (it is a tsconfig.test.json assertion, not a vitest case)
pnpm exec eslint . — plain form, whole repo, at 065b16b984023 files linted, 0 errors, 11516 warnings (11518 before; the 2 removed were mine). All 13 touched/added files: 0 errors; the added test file and the parity file: 0 warnings
node scripts/check-changeset-presence.mjsexit 0 — "12 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)"
downstream consumer type-check, pnpm --filter @object-ui/components type-checkcommand-exit 0 — the renderers now type-check against the declared types instead of any, resolved through the freshly built dist (packages/components/tsconfig.json overrides the root paths, so it reads the built .d.ts, not source)

Heavy runs went through the container's shared verify lock; the --filter vitest forms AGENTS.md guard-refuses were not used.

The changeset is minor (⛔ not major) and states the widening in words, including the value-dimension narrowing.


A 14th key exists — reported, NOT folded in

The re-derivation ran the opposite way round from the census (enumerate every schema.KEYNAME read in each of the 8 renderers, subtract the declared members) and found 4 more genuinely-read undeclared keys the docs-driven census could not see, plus one declared-but-dead key. Per the dispatch order they are reported, not folded in: filed as #6938.

  • CheckboxSchema.wrapperClass (form/checkbox.tsx:33) — the same key as two of the 13, on a third type, undeclared only because the checkbox docs page is a six-line summary
  • ContextMenuSchema.triggerClassName (:87), contentClassName (:88), modal (:91)
  • ContextMenuSchema.children is declared required on both faces and read by nothing — the renderer renders schema.trigger, never schema.children

That sweep covered only these 8 types, so it is a floor, not a census.


Open questions for contract review

  1. CarouselSchema.opts shape. Open record (what shipped here, preserves every working document) vs the docs' { loop, align } pair (stronger authoring guard, refuses authored embla options that work today). Recommendation: keep it open in this PR and rule on it against real authored data, because narrowing is the irreversible direction.
  2. The two alias pairs (content/value, nodes/data). This PR declares both spellings because both are read. Recommendation: a follow-up ADR-0049 card retires one of each, rather than leaving two dialects declared forever.
  3. TreeViewSchema.data is required while nodes is the spelling the renderer prefers. A nodes-only document is still refused. Recommendation: a separate card, since relaxing a required key is an accept-set change of its own.

Generated by Claude Code

The undeclared-but-consumed census (objectui#6150) found 68 documented keys
that are not declared members of their shipped type; 13 were shown to be
genuinely READ by the renderer. Those 13 are declared here on both faces —
the TypeScript interface and, for 12 of them, the hand-written zod mirror.
Key membership is not widened: every touched mirror extends the
`.passthrough()` `BaseSchema`, so all 13 already parsed green and already
survived the parse, admitted unexamined. What changes is declaration (the key
becomes a member of the shipped type) and, for the 12 mirrored keys, value
enforcement — which in the value dimension is a narrowing.
`TreeViewSchema.onNodeClick` is INVOKED, not read as a value, so it gets no
mirror: objectui#6152 routes that class to `RuntimeOnlyDeclared` in
`zod-mirror-parity.test.ts`, and this is the first pair to sit there without
also sitting in `UnmirroredDeclared`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
`SchemaNode` itself admits `null | undefined`, so wrapping the property in
`NonNullable` stripped those limbs out of the union and compared a different
type — `tsc -p tsconfig.test.json` read `Type 'false' does not satisfy the
constraint 'true'`. Read raw the two sides are equal, and the guard still
bites: an undeclared `trigger` resolves to `any` through `BaseSchema`'s index
signature, and `Equal< any, … >` is false.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
…r `any`
`pnpm exec eslint .` flagged two `no-explicit-any` warnings introduced by the
`Case.mirror` handle. The structural type it needs is small and writable, so
write it: `data` is the parsed document, `error` is the issue list the refusal
assertions read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3179.6 KB3222.7 KB
Main entry chunk (gzip)143.6 KB350 KB
Entry fileindex-RnBefW7y.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)12.46KB4.71KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.09KB116.42KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)175.69KB48.80KB
fields (index.js)243.65KB61.63KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)247.26KB63.17KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.08KB54.61KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.05KB8.37KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.79KB21.10KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)76.75KB25.49KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-sam
os-sam marked this pull request as ready for review August 31, 2026 03:11
@os-sam
os-sam added this pull request to the merge queueAug 31, 2026
@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Aug 31, 2026
@os-sam
os-sam added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 2c45966Aug 31, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6150-undeclared-but-consumed-keys branch August 31, 2026 03:46
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(types): declare the 13 renderer-read keys that no shipped type declared by os-sam · Pull Request #6945 · objectstack-ai/objectui · GitHub
Skip to content

feat(types): declare the 13 renderer-read keys that no shipped type declared - #6945

Merged
os-sam merged 3 commits into
mainfrom
claude/issue-6150-undeclared-but-consumed-keys
Aug 31, 2026
Merged

feat(types): declare the 13 renderer-read keys that no shipped type declared#6945
os-sam merged 3 commits into
mainfrom
claude/issue-6150-undeclared-but-consumed-keys

Conversation

@os-sam

Copy link
Copy Markdown
Collaborator

Fixes#6150

⚠️ Contract-review tier (needs:contract-review). Staying in draft; not marked ready, not enqueued, no auto-merge.

⚠️ Generics are written in words below, not in angle brackets — the body sanitizer eats anything tag-shaped, including inside backticks (the card itself says so).

Head this PR's evidence was measured on: 065b16b98. Base: 40c479af2.


⛔ Read this before the table: the accept set did NOT widen in the key dimension

The dispatch order asked me to establish this per schema rather than assume one answer, and to say so plainly if passthrough meant the before-state already accepted. It did. Measured, not assumed:

All 8 touched mirrors extend the zod BaseSchema, which ends .passthrough(), and .extend() carries that policy through. Read off the built mirrors, catchall is z.unknown() on all 8. So before this PR every one of the 13 keys already parsed green and already SURVIVED the parse — admitted unexamined, neither refused nor stripped.

So the PR does not claim an accept-set change it did not make. What it actually changes is two things:

  1. Declaration. The key becomes a declared member of the shipped TypeScript type. Today these reads compile only because BaseSchema ends with [key: string]: any (finding(types): BaseSchema's [key: string]: any leaves every component schema open, so a "declare the surface" fix can never reject a misspelled TOP-LEVEL key #5155), so schema.trigger on a type that never declared trigger resolves to any.
  2. Enforcement — and in the VALUE dimension this is a NARROWING, not a widening. For the 12 keys that gained a zod mirror entry, the value is now validated: { type: 'text', content: 42 } parsed green before and is refused at content now. That is a behaviour change for documents carrying a wrong-typed value under one of these 13 names, and it is the point — declared === enforced.

Because acceptance cannot tell "declared" from "admitted unexamined" under passthrough, membership is asserted on the mirror's own .shape, never on parse acceptance — the form object-grid-title-mirrored.test.ts established for #6639.


Per-key before / after — the table triage asked for

Probed by parsing real documents through the builtpackages/types/dist/zod/index.zod.js in two worktrees: 40c479af2 (before) and 065b16b98 (after). Control document per type = required keys only, carrying none of the 13.

keyin mirror .shapedoc carrying the keydoc carrying a WRONG-TYPED valuecontrol docundeclared-key control
TextSchema.contentfalse → trueaccepted+survives → accepted+survivesaccepted → refused at contentgreen → greenadmitted → admitted
CarouselSchema.optsfalse → trueaccepted+survives → accepted+survivesaccepted → refused at optsgreen → greenadmitted → admitted
CarouselSchema.orientationfalse → trueaccepted+survives → accepted+survivesaccepted → refused at orientationgreen → greenadmitted → admitted
CarouselSchema.itemClassNamefalse → trueaccepted+survives → accepted+survivesaccepted → refused at itemClassNamegreen → greenadmitted → admitted
FilterBuilderSchema.wrapperClassfalse → trueaccepted+survives → accepted+survivesaccepted → refused at wrapperClassgreen → greenadmitted → admitted
TreeViewSchema.nodesfalse → trueaccepted+survives → accepted+survivesaccepted → refused at nodesgreen → greenadmitted → admitted
TreeViewSchema.titlefalse → trueaccepted+survives → accepted+survivesaccepted → refused at titlegreen → greenadmitted → admitted
TreeViewSchema.onNodeClickfalse → false (deliberate)accepted+survives → accepted+survivesaccepted → accepted (no mirror, so no enforcement)green → greenadmitted → admitted
CheckboxSchema.requiredfalse → trueaccepted+survives → accepted+survivesaccepted → refused at requiredgreen → greenadmitted → admitted
FileUploadSchema.buttonTextfalse → trueaccepted+survives → accepted+survivesaccepted → refused at buttonTextgreen → greenadmitted → admitted
FileUploadSchema.wrapperClassfalse → trueaccepted+survives → accepted+survivesaccepted → refused at wrapperClassgreen → greenadmitted → admitted
HoverCardSchema.alignfalse → trueaccepted+survives → accepted+survivesaccepted → refused at aligngreen → greenadmitted → admitted
ContextMenuSchema.triggerfalse → trueaccepted+survives → accepted+survivesaccepted → refused at triggergreen → greenadmitted → admitted

"Widened only what I meant to", per schema: the rightmost column is that proof. On all 8 mirrors, a document carrying an undeclared key of the same wrong type is still admitted and still survives the parse — before AND after. The unknown-key policy of every touched mirror is exactly what it was. This is also pinned per key in the test file, so it cannot silently stop being true.

One extra measurement, because declaring nodes invites a wrong inference: a { type: 'tree-view', nodes: [...] } document with no data is REFUSED at data, before and after. data stays required on both faces. Declaring nodes records the read; it does not by itself make a nodes-only document legal. Relaxing data is an accept-set change and a separate ruling.


Premise re-derived, not inherited

Module resolution path, stated before measuring.packages/types/dist did not exist in the fresh worktree, so I built it (pnpm --filter @object-ui/types build, verdict command-exit 0) and measured through packages/types/dist/index.d.ts — the census's own path, derivePackageTypePaths() style — and independently through packages/types/src/index.ts. Declared-member sets for all 8 types were identical between dist and src, so no stale-build reading is in play. Members were resolved with the TypeScript compiler API (getPropertiesOfType), which sees inherited members and reports the string index signature separately, so [key: string]: any could not mask the answer.

Result: 13/13 still undeclared on base. After the change, re-measured through the rebuilt dist/index.d.ts: 13/13 declared, index signature still present on all 8 (so nothing about BaseSchema moved).

Every read re-verified at its cited site. All 13 reads are still present. One line number drifted; the read is the fact:

keycensus saidmeasured now
TextSchema.contentbasic/text.tsx:51,56:51,56 — unchanged
CarouselSchema.opts / orientation / itemClassNamecomplex/carousel.tsx:23 / 24 / 30unchanged
FilterBuilderSchema.wrapperClasscomplex/filter-builder.tsx:37unchanged
TreeViewSchema.nodes / onNodeClick / titledata-display/tree-view.tsx:105 / 98,99 / 115,117unchanged
CheckboxSchema.requiredform/checkbox.tsx:45,49unchanged
FileUploadSchema.buttonText / wrapperClassform/file-upload.tsx:123 / 78unchanged
HoverCardSchema.alignoverlay/hover-card.tsx:24unchanged
ContextMenuSchema.triggeroverlay/context-menu.tsx:64:95 — drifted, read intact

Nothing was dropped. No key's reader had been removed.

READ vs INVOKED.TreeViewSchema.onNodeClick is invokedif (schema.onNodeClick) then schema.onNodeClick(node), with node the clicked TreeNode and the return value discarded. Its declared type is therefore the call signature (node: TreeNode) => void, not a value shape, and it is pinned as such with an invariant equality assertion. The other 12 are value reads.


Three declarations that are not "declare what is read" on autopilot

Each says so in its own doc comment; all three are flagged for the contract reviewer.

  • CarouselSchema.opts is an OPEN record (string keys, unknown values), not the docs page's two-key shape. The renderer forwards the whole bag verbatim to embla (opts={schema.opts}), so every other embla option authored today reaches the library and works. Declaring the documented pair would refuse those documents — an accept-set narrowing on a published surface, which is a ruling, not a declaration. The card itself flagged this key as the one that "may want a narrower shape"; I declined to narrow it without a ruling.
  • ContextMenuSchema.trigger is OPTIONAL although the docs page shows it required. The renderer substitutes a placeholder node when it is absent, so every trigger-less document is legal today and declaring it required would refuse them.
  • TreeViewSchema.onNodeClick gets NO zod mirror. A function cannot appear in an authored JSON document, so it is a runtime slot; 121 declared-but-unmirrored keys across 16 schema pairs — the lane #6058's new UnmirroredDeclared ledger made visible #6152 ruled that class is never mirrored and is recorded in zod-mirror-parity.test.ts's RuntimeOnlyDeclared instead — the step-3 exception that file's own header spells out. This is the first pair to sit in RuntimeOnlyDeclared without also sitting in UnmirroredDeclared, so that file's two population counts move with it (6 entries / 23 keys to 7 / 24; the "no entry in either" population 142 to 141). ⛔ UnmirroredDeclared is shrink-only and was not touched.

Two of the 13 declare a second spelling for a slot that already has onecontent beside value, nodes beside data — because that is what the renderers read (schema.content || schema.value, boundData || schema.nodes || schema.data). AGENTS.md #0.1's "one strict contract beats N dialects" argues for retiring one of each pair; that is an ADR-0049 enforce-or-remove question and deliberately not decided here. Each doc comment names which spelling wins.


Ablation — direction predicted IN WRITING before the run

Predicted before anything touched disk (mutating the fact, not the assertion): delete the content shape entry from the TextSchema mirror, leaving the TS declaration in place.

  1. membership assertion → RED
  2. wrong-typed-value refusal → RED
  3. "accepts the declared value and it SURVIVES" → STAYS GREEN — the counter-intuitive half, and the whole reason membership is read off .shape: under passthrough an undeclaredcontent: 'hello' is still accepted and still survives, so a suite measuring only acceptance would read this ablation as fully green
  4. read-site assertion and both control assertions → GREEN
  5. the other 12 keys → GREEN
  6. verdict: exactly 2 failed | 75 passed (77)
  7. second instrument: zod-mirror-parity.test.ts's compile-time half reddens naming layout.zod.ts#TextSchema; its runtime census (5 tests) does not move, by that file's documented design

Observed, on 065b16b98, all seven as predicted:

MUTATION PROVEN ON DISK: anchor 1->0 and hash d264a81ccb08838670e9e596ef3d407e3625855a != HEAD blob a96fce18feff5bf04102b70adaafa5b2e82600a8
vitest exit = 1
Test Files 1 failed (1)
Tests 2 failed | 75 passed (77)
x TextSchema.content > is a member of the mirror shape (membership cannot be read off acceptance under passthrough)
x TextSchema.content > refuses a wrong-typed value AT the key - the enforcement mirroring adds
tsc -p tsconfig.test.json exit = 1
src/__tests__/zod-mirror-parity.test.ts(1240,14): error TS2322: Type '"layout.zod.ts#TextSchema"' is not assignable to type 'never'.
RESTORE PROVEN: git diff HEAD empty AND hash a96fce18feff5bf04102b70adaafa5b2e82600a8 == HEAD blob a96fce18feff5bf04102b70adaafa5b2e82600a8

Mechanics: absolute paths from git rev-parse --show-toplevel, restore via git checkout HEAD -- ABSOLUTE_PATH (never bare) under trap ... EXIT INT TERM, mutation proven by anchored count 1 to 0 and a git hash-object differing from the HEAD blob, restore proven both ways. Resolution path for the ablation: both instruments read SOURCE — the pin file imports '../zod/layout.zod' relatively and tsc -p tsconfig.test.json compiles src/, so dist is on neither path and no rebuild is part of it.


Gates — exit codes captured before any pipe, verdict lines quoted

gateresult
pnpm --filter @object-ui/types type-checkechoed tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json; lock verdict command-exit 0
root-form pnpm exec vitest run packages/types/ — BEFORE (40c479af2)Test Files 75 passed (75) / Tests 861 passed (861)
root-form pnpm exec vitest run packages/types/ — AFTER (065b16b98)Test Files 76 passed (76) / Tests 938 passed (938)+1 file, +77 tests, exactly the new pin file; nothing lost
zod-mirror-parity runtime halfTest Files 1 passed (1) / Tests 5 passed (5), PARITY_EXIT=0
zod-mirror-parity compile-time halfcarried by type-check above (it is a tsconfig.test.json assertion, not a vitest case)
pnpm exec eslint . — plain form, whole repo, at 065b16b984023 files linted, 0 errors, 11516 warnings (11518 before; the 2 removed were mine). All 13 touched/added files: 0 errors; the added test file and the parity file: 0 warnings
node scripts/check-changeset-presence.mjsexit 0 — "12 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)"
downstream consumer type-check, pnpm --filter @object-ui/components type-checkcommand-exit 0 — the renderers now type-check against the declared types instead of any, resolved through the freshly built dist (packages/components/tsconfig.json overrides the root paths, so it reads the built .d.ts, not source)

Heavy runs went through the container's shared verify lock; the --filter vitest forms AGENTS.md guard-refuses were not used.

The changeset is minor (⛔ not major) and states the widening in words, including the value-dimension narrowing.


A 14th key exists — reported, NOT folded in

The re-derivation ran the opposite way round from the census (enumerate every schema.KEYNAME read in each of the 8 renderers, subtract the declared members) and found 4 more genuinely-read undeclared keys the docs-driven census could not see, plus one declared-but-dead key. Per the dispatch order they are reported, not folded in: filed as #6938.

  • CheckboxSchema.wrapperClass (form/checkbox.tsx:33) — the same key as two of the 13, on a third type, undeclared only because the checkbox docs page is a six-line summary
  • ContextMenuSchema.triggerClassName (:87), contentClassName (:88), modal (:91)
  • ContextMenuSchema.children is declared required on both faces and read by nothing — the renderer renders schema.trigger, never schema.children

That sweep covered only these 8 types, so it is a floor, not a census.


Open questions for contract review

  1. CarouselSchema.opts shape. Open record (what shipped here, preserves every working document) vs the docs' { loop, align } pair (stronger authoring guard, refuses authored embla options that work today). Recommendation: keep it open in this PR and rule on it against real authored data, because narrowing is the irreversible direction.
  2. The two alias pairs (content/value, nodes/data). This PR declares both spellings because both are read. Recommendation: a follow-up ADR-0049 card retires one of each, rather than leaving two dialects declared forever.
  3. TreeViewSchema.data is required while nodes is the spelling the renderer prefers. A nodes-only document is still refused. Recommendation: a separate card, since relaxing a required key is an accept-set change of its own.

Generated by Claude Code

The undeclared-but-consumed census (objectui#6150) found 68 documented keys
that are not declared members of their shipped type; 13 were shown to be
genuinely READ by the renderer. Those 13 are declared here on both faces —
the TypeScript interface and, for 12 of them, the hand-written zod mirror.
Key membership is not widened: every touched mirror extends the
`.passthrough()` `BaseSchema`, so all 13 already parsed green and already
survived the parse, admitted unexamined. What changes is declaration (the key
becomes a member of the shipped type) and, for the 12 mirrored keys, value
enforcement — which in the value dimension is a narrowing.
`TreeViewSchema.onNodeClick` is INVOKED, not read as a value, so it gets no
mirror: objectui#6152 routes that class to `RuntimeOnlyDeclared` in
`zod-mirror-parity.test.ts`, and this is the first pair to sit there without
also sitting in `UnmirroredDeclared`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
`SchemaNode` itself admits `null | undefined`, so wrapping the property in
`NonNullable` stripped those limbs out of the union and compared a different
type — `tsc -p tsconfig.test.json` read `Type 'false' does not satisfy the
constraint 'true'`. Read raw the two sides are equal, and the guard still
bites: an undeclared `trigger` resolves to `any` through `BaseSchema`'s index
signature, and `Equal< any, … >` is false.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
…r `any`
`pnpm exec eslint .` flagged two `no-explicit-any` warnings introduced by the
`Case.mirror` handle. The structural type it needs is small and writable, so
write it: `data` is the parsed document, `error` is the issue list the refusal
assertions read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3179.6 KB3222.7 KB
Main entry chunk (gzip)143.6 KB350 KB
Entry fileindex-RnBefW7y.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)12.46KB4.71KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.09KB116.42KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)175.69KB48.80KB
fields (index.js)243.65KB61.63KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)247.26KB63.17KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.08KB54.61KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.05KB8.37KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.79KB21.10KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)76.75KB25.49KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-sam
os-sam marked this pull request as ready for review August 31, 2026 03:11
@os-sam
os-sam added this pull request to the merge queueAug 31, 2026
@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Aug 31, 2026
@os-sam
os-sam added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 2c45966Aug 31, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6150-undeclared-but-consumed-keys branch August 31, 2026 03:46
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants

@os-sam@claude