feat(types): declare the nine objectql handler keys their renderers read (objectui#7804 objectql.ts slice) - #9541
Conversation
…ead (objectui#7804 objectql.ts slice)
`BaseSchema` is `.passthrough()`, so a key no arm declares is not refused — it
stops being judged and the value is KEPT, then reaches the renderer that reads
it. Nine keys across the four plain `export interface X extends BaseSchema`
faces in `packages/types/src/objectql.ts` sat in exactly that state:
ObjectFormSchema onCancel · onError · onOpenChange · onStepChange · onSuccess
ObjectGallerySchema onCardClick · onRowClick
ObjectGridSchema onNavigate
ObjectViewSchema onNavigate
Each is now a `handlerKeyRefusal(key, 'runtime-slot', label)` arm on its mirror
in `zod/objectql.zod.ts`, and the nine `KNOWN_UNDECLARED_READS` rows that waived
them are drained — 20 rows remain on the card.
The disposition was MEASURED PER KEY, not applied as a pattern. `'retired'`
publishes "no renderer reads this key", which is false for every row on that
ledger by construction, so the burden was on `'runtime-slot'` and each key was
carried by finding the path a host supplies it through. Three of the nine do NOT
share the group's supplier and are reported rather than smoothed over:
- ObjectFormSchema.onStepChange — no in-repo host fills it; `ObjectForm`
forwards it onto the wizard node and `WizardForm` calls it, so the channel
is wired end to end and only the supplier is missing.
- ObjectGallerySchema.onCardClick — the second arm of the
`props.onRowClick ?? props.onCardClick` read, declared on
`ObjectGalleryProps` with no in-repo supplier.
- ObjectGridSchema.onNavigate — no in-repo host builds an `object-grid` node
carrying it; the read is deliberate under the 2026-08-19 ruling on
objectui#5234 and `gridNonAuthorKeys.test.tsx` supplies it from a schema.
The two `ObjectGallerySchema` keys were declared on NEITHER face and reached the
renderer through `SchemaRenderer`'s props spread while `BaseSchema`'s index
signature admitted them as `any`, so they are declared on the TypeScript face
here too — a narrowing on both faces, in the shape `ObjectDataTableSchema.onRowClick`
already carries in this file.
`ObjectViewSchema`'s nested `form` and `table` slots are the sibling mirrors BY
REFERENCE and their declared types are built off `ObjectFormSlotKey` /
`ObjectGridSlotKey`, two unions that list exactly these handler keys, so the
refusals propagate one level down. Recorded in `KnownDrift` rather than worked
around: omitting the keys from the nested reference would keep accepting an
un-authorable function value there.
Clause-2 yes — adding an arm to a published mirror is a declaration change, and
this one is a NARROWING. The changeset is `minor` and spells the accept-set
change out; a `major` is not available in this repo's single fixed group.
Co-authored-by: Claude <noreply@anthropic.com>
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
…nterface
`objectql.ts` now carries TWO `onRowClick` declarations with OPPOSITE contracts:
`ObjectGallerySchema`'s two-parameter modifier-forwarding one, added by the
objectql.ts handler-key slice, and `ObjectDataTableSchema`'s one-parameter
`row: any`. They sit in opposite halves of this pin's ledger — one IN, one a
CONTROL — and `declParams` read the FIRST declaration in the file, so the
control silently started scoring against the IN site's declaration.
`declParams` takes an optional literal anchor and scopes the read to the text
after it; every site that needs one names its own `export interface X` line. A
missing anchor reads NULL rather than falling back to the whole file, so a
renamed interface reds here instead of quietly answering about a different
declaration. Three self-test legs pin all of that, including the
unanchored-reads-the-first behaviour the anchor exists to defeat.
The two `ObjectGallerySchema` members join IN_SITES, which is where this card's
own criterion puts them: `RelatedList` is already IN *because* it writes
`onRowClick` onto the `object-gallery` node, and until now that node type
declared neither key — so `BaseSchema`'s index signature typed both `any` and a
host writing the node learned nothing about the second parameter. That is this
card's defect ("Declaring one parameter hid the second on the ONE line a host
reads") one hop further out.
Co-authored-by: Claude <noreply@anthropic.com>
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Contract reviewServed-tier: ① Derived judgmentsThe nine rows are declared and drained. Verified on the branch:
⇒ ⭐ The disposition was measured per key, and three came out DIFFERENTLY from their group. ⭐⭐ Two premise refinements — both correct my dispatch, both measured:
⭐ The CI red was this branch's own, and the branch owned it. The slice made ② Semver level
③ Boundary flags⛔ None raised. The narrowing is the deliverable and the evidence is unusually direct: ⭐⭐ The accept set was read at RUNTIME off the built dist, with a control in the same pass — all nine keys REFUSED by name, code ⭐ The Census with lit controls firing on every corpus: 2603 tracked Ablation: two opposite reds out of one gate, each predicted in writing first; mutation proved on disk both ways (
Implemented-by: VERDICT: PASS ⛔ On the three ✅ LandingEVERY check green on
Generated by Claude Code |
Part of #7804 — the
objectql.tsslice. Nine of that card's ledger rows drainhere; 20 remain and are later slices, so this PR deliberately carries no
closing keyword. The PM seat ends the card when the last row is gone.
What changed
BaseSchemais.passthrough(), so a key no arm declares is not refused —it stops being judged and the value is KEPT, then reaches the renderer that
reads it. Nine keys across the four plain
export interface X extends BaseSchemafaces in
packages/types/src/objectql.tssat in exactly that state:ObjectFormSchemaonCancel·onError·onOpenChange·onStepChange·onSuccessObjectGallerySchemaonCardClick·onRowClickObjectGridSchemaonNavigateObjectViewSchemaonNavigateEach is now a
handlerKeyRefusal(key, 'runtime-slot', label)arm on its mirrorin
zod/objectql.zod.ts, beside the two (ObjectDataTableSchema.onRowClick,ObjectKanbanSchema.onCardClick/.onQuickAdd) that already carry the shape.⛔
ListViewSchema's five rows are held out and untouched: it is not aninterface, it is an intersection with
ListViewRuntimeProps, whose runtime halfalready declares two of the five and not the other three. Different shape,
different slice.
The deliverable: a MEASURED disposition per key, not a pattern applied nine times
'retired'publishes "no renderer reads this key, so nothing could ever runit" — flatly false for all nine, since this card's premise is that a registered
renderer reads them. So the burden was on
'runtime-slot', which asserts a hostreally supplies the function through the TypeScript interface / props. Found per
key, by the props path:
ObjectFormSchema.onSuccessonSuccess: schema.onSuccessforwarded onto every variant nodeobject-formnode in TypeScript (AppContent,RecordFormPage,ScreenView,FlowRunner,useActionModal,ObjectManager,FieldDesigner,MasterDetailForm,EmbeddableForm,plugin-view'sObjectView)runtime-slotObjectFormSchema.onCancelonCancel: schema.onCancelonSuccessruntime-slotObjectFormSchema.onOpenChangeonOpenChange: schema.onOpenChangeon the drawer/modal armAppContent(if (!open) closeRecordForm()),ObjectManager,FieldDesignerruntime-slotObjectFormSchema.onErroronError: schema.onErroron the master-detail armMasterDetailFormruntime-slotObjectFormSchema.onStepChangeonStepChange: schema.onStepChange, thenschema.onStepChange(step)inWizardFormruntime-slotObjectGallerySchema.onRowClickprops.onRowClick ?? props.onCardClickhanded touseNavigationOverlayListView'sbaseProps(onRowClick: navigation.handleClick) andRelatedList's mobile branch, each on theobject-gallerynode it buildsruntime-slotObjectGallerySchema.onCardClick??ObjectGalleryPropsas the alternative spellingruntime-slotObjectGridSchema.onNavigateonNavigate: schema.onNavigateintouseNavigationOverlaygridNonAuthorKeys.test.tsxsupplies it from a schema and asserts the callruntime-slotObjectViewSchema.onNavigateplugin-view'sObjectView, e.g.schema.onNavigate('new', 'edit')@object-ui/app-shell'sObjectView, which builds the node withonNavigate: (recordId, mode) => …runtime-slot⭐ Three of the nine made the per-key rule pay.
onStepChange,onCardClickand
ObjectGridSchema.onNavigatehave no host filling them anywhere in thisrepository. That is the third outcome the dispatch asked to be reported rather
than forced into the nearer box — and it is a missing SUPPLIER, not a missing
read: each key is still read and still invoked, so
'retired'would publish afalse sentence to every author who trips the refusal, while
'runtime-slot''sclaim is about the CHANNEL, which is declared and live in all three cases.
onNavigateis two different keys.ObjectGridSchema's takesaction?: string,ObjectViewSchema's takesmode: 'view' | 'edit'; differentrenderers, different suppliers. They were judged separately, not co-disposed.
Two things this slice changed beyond the nine arms, both measured
① The two
ObjectGallerySchemakeys are declared on the TypeScript face too.They were declared on NEITHER face: they reached the renderer through
SchemaRenderer's props spread (createElementspreading...componentProps)while
BaseSchema's index signature admitted them asany. Declaring them is anarrowing on that face as well, in the shape
ObjectDataTableSchema.onRowClickalready carries in this same file.
② The refusals propagate into
ObjectViewSchema's nestedformandtable.Those slots are the
object-form/object-gridmirrors BY REFERENCE, and thedeclaration types them off
ObjectFormSlotKey/ObjectGridSlotKey— two unionsthat list exactly these handler keys. So an authored
form: { onSuccess: … }onan
object-viewnode is refused as well. ⛔ Not worked around by omitting thekeys from the nested reference: that would keep accepting an un-authorable
function value one level down, which is the defect and not the fix. Recorded in
KnownDrift, which is where the type checker insisted it go.The breaking-change question — measured, with lit controls
This narrows a published accept set, so the census had to be a reading and not a
silence. The commands are in the PR's own evidence log; each zero carries a
control firing on the SAME corpus in the SAME pass.
.json/.md/.mdx/.yml/.yaml"objectName"24 ·"gallery"3 ·"titleField"13 ·"layout"8 ·"object-form"8apps/+examples/TypeScript sourcesobjectName41 ·gallery28 ·titleField3 ·layout56 ·object-form10examples/schema-catalogobjectName10 ·gallery18 ·titleField9 ·layout27 ·object-form5Every textual hit is accounted for and none is an authored document: the three
document-corpus hits are changeset PROSE from this card's own earlier slices
quoting the defect; every
apps/+examples/hit is a React prop on a JSXelement or a local component's own prop; the single schema-catalog hit is the
substring inside
GPUInitializationError. ⇒ the narrowing breaks nothingin-repo, and
minoris required rather than chosen (39 packages in one fixedgroup; objectui's major is pinned to
@objectstack's).Is
KNOWN_UNDECLARED_READSgenerated or hand-maintained?Hand-maintained, re-measured here rather than carried over: the constant is a
literal
new Map([...])inscripts/check-handler-key-read-sites.mjs, the filecarries no
@generated/DO NOT EDITmarker, no script writes it, there is nogen:*npm script in this repo at all, and the only other references to the nameare the gate's own read and its test importing the exported Map. So the nine rows
were deleted by hand, which is the sanctioned path.
Evidence
Directions predicted in writing before running. Every prediction held; the
one thing measurement changed was the SIZE of the ledger move, not its direction
(see "two things beyond the nine arms" above).
P1 — the INTERMEDIATE reading (arms declared, ledger rows NOT yet removed).
Predicted RED on
staleExemptions()naming exactly the nine. MeasuredEXIT=1,and the gate printed exactly those nine rows and no others — which is the proof
the arm edit reached exactly these keys.
P2 — the REVERSE leg (arms reverted, ledger left drained). Predicted the
opposite red out of the same gate. Measured
EXIT=1, all nine reported asundeclared reads with no row to exempt them. Mutation proven on disk both
ways — anchor count 13 to 4 and the on-disk blob equalling the branch-point
blob — restore proven by state (blob back to the HEAD blob and
git diff HEADempty), trap-guarded, absolute paths, an empty hash treated asFAILURE rather than as nothing-to-compare.
P3 — the FINAL reading. Predicted
EXIT=0. MeasuredEXIT=0, with20 exempted by ledger, down from 29 at the branch point.The ledger population was re-derived at the branch point, not quoted. The
Map itself reads 29 rows; the dead pattern the PM warned about (a line-anchored
double-quote) reads 0 in the same pass, which is why the derivation imported the
Map rather than grepping it.
The accept-set change, read at RUNTIME off the built
dist, with a control inthe same pass. All nine keys refused by name,
code: 'custom', on the key'sown path; and on each of the four nodes a still-undeclared control key was
ACCEPTED and its value KEPT in the parsed output — which is the exact state all
nine were in before this PR. The nested propagation was read the same way:
form.onSuccessandtable.onNavigaterefused at their own paths on anobject-viewnode, with the same control accepted beside them.The
.d.tsreading, where the RAW diff is NOT an instrument (union-memberand object-key order move between builds of identical source). Normalised
sorted-line comparison over
packages/types/dist, with a same-source controlbuild: build A and build B from identical source gave 28546 normalised lines
each and a diff of 0 lines, so the instrument can tell states apart only
because of source; the branch-point build gave 28422 lines, and its normalised
diff against A is 124 added, 0 removed — the two new
ObjectGallerySchemamembers and the doc blocks that carry each key's measuredchannel.
Suite.
packages/typesis green on its full suite (190 files, 4436 tests)and on
type-check. Three pins moved with the measurement rather than aroundit, each in the direction the ledger intends:
zod-mirror-parity's two headerfigures and its
KnownDrift/RuntimeOnlyDeclaredentries, thehandler-keys-json-refusal-6124length pins, andobject-view-spec-parity'sTS_ONLY_BACKLOG— which losesonNavigatealong with the sentence that said afunction key "CANNOT be declared in a JSON protocol schema". It can: as a named
refusal. Leaving it undeclared never kept the key out of an authored document.
Related
Clause-2
yes— adding an arm to a published mirror is a declaration change,and this one is a NARROWING. Carrying
needs:contract-reviewfrom the strokethat opened this PR.
🤖 Generated with Claude Code
Generated by Claude Code