feat(spec)!: four object-form members take the shape the form reads; fields, sections held (#21464, stage 3) - #21590
Conversation
…ss and mobile typed; fields, sections held (#21464, stage 3) Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
…d rationale fragment (#21464, stage 3) Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
…ference page and the ui strictness counts (#21464, stage 3) Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
…ed (#21464, stage 3) Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 138 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 6f3804b641d6f397d8ede83ca405acc62f456fa9 && git checkout 6f3804b641d6f397d8ede83ca405acc62f456fa9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 901e7cf13abd61afb4990ebc1e3b9cd36cf9b33d ee6a556ad808339baf43d3fa18eced7444157df4 && git checkout -B drift-repro 901e7cf13abd61afb4990ebc1e3b9cd36cf9b33d && git merge --no-ff ee6a556ad808339baf43d3fa18eced7444157df4
node scripts/docs-audit/affected-docs.mjs --json 901e7cf13abd61afb4990ebc1e3b9cd36cf9b33d |
Contract reviewServed-tier: Isolated contract reviewer for PR #21590 (card #21464, stage 3 / S-form), written 2026-10-03T13:48Z. Inputs: the card body and its fifteen comments (triage Gate verdicts on this head, at 13:45Z: 34 check-runs — 31 success · 0 failure · 1 in progress ( ① Derived judgments1. 2. 3. 4. 5. The four held members — RIGHT: each byte-identical to 6. 7. The enumeration pin — RIGHT. Four 8. The kit — RIGHT. D3 entry ② Semver levelLevel, arm and text RIGHT. ③ Boundary flags
Implemented-by: VERDICT: PASS Four members of Generated by Claude Code |
ACCEPT — PR #21590 @
|
Part of #21464
Clause-②: yes (narrowing)
What this does
Stage 3 (S-form) of the
ComponentPropsMapz.unknown()close-out, per triage5961300594, the seat answers5963787404(staging A) and5966636964(the writer test: a writer is a value the renderer draws), and the claim5967737487. Of the form family's ninez.unknown()members, four are typed. Four are held because the form draws a value each typed shape would refuse, and one (customFields) is re-recorded with the objectui-held contracts, because its entries are objectui's runtime form field, which the spec has not declared. Read points are at the.objectui-shapin89cad75d55, underpackages/plugin-form/src/unless named.object-form·contentLayoutz.unknown()'simple' | 'tabbed', the measured shapeModalForm.tsx:854tests=== 'tabbed'(declared'simple' | 'tabbed'at:151); the only readerobject-form·submitBehaviorz.unknown()FormViewSchema.shape.submitBehavior, by referenceObjectForm.tsx:1312-1370andWizardForm.tsx:1005-1065switch onkind, readurl/delayMs/title/message;submitRedirect.ts:202judges a redirecturlby parsing a form view through this same schemaobject-form·navigateOnSuccessz.unknown()z.string(), the measured shapesuccessBehavior.ts:118-131(template.replace), fromObjectForm.tsx:1373andWizardForm.tsx:1071object-form·mobilez.unknown(){ stickyActions?, stepper?, stepperMinFields?, stepperFieldsPerStep?, fullscreenLongText? }(module-privateObjectFormMobileSchema), the measured shapeObjectForm.tsx:1857-1942(flat arm only)object-form·fieldsz.array(z.unknown())ObjectForm.tsx:961-981andflatFields.ts:71-79draw a{ name }entry by that nameobject-form·sectionsz.array(z.unknown())sectionFields.ts:367-369draws an inline runtime field{ name, type, … }inside a section as it stands ("shape 3")object-form·customFieldsz.unknown()object-form→objectui-heldcustomFieldsMerge.ts:59-108matches members bynameand draws them wholeobject-master-detail-form·sections,fieldsz.array(z.unknown())MasterDetailForm.tsx:1692-1693hands both to the parentobject-formverbatimThe four typed members carry no default and no transform, so
ObjectFormPropsstays input-equals-output.ObjectFormPropscarries the four types instead ofunknown.The census (A1), whole
A writer is a page-component node: an object literal naming the type, a literal annotated with the block's type, a
schema={{…}}onObjectForm/MasterDetailForm, a call into a local helper that builds the node (each call site counted, the helper's parameters bound to that call's arguments and defaults), or a direct parse through the row. Values resolve through same-file constants and single-expression local helpers. The control isobjectNameon the same nodes. The instrument is a TypeScript-AST walk over every.ts,.tsx,.js,.jsx,.mjs,.json,.md,.mdxand.yamlfile, with fenced code in the documents parsed too.object-formnodesobject-master-detail-formnodesobjectNamee909aa0a23(examples/,packages/withpackages/apps/,content/,skills/,apps/)89cad75d55(whole tree)Re-checked at the final merge base
cc645f2385: the.objectui-shapin is unchanged, and the commitsmainbrought sincee909aa0a23add or remove no line naming either block (object-form,object-master-detail-form,ObjectForm,MasterDetailForm) orsubmitBehavior,navigateOnSuccess,contentLayoutorcustomFields(0 hits in 2947 moved lines), so the census stands.Every renderer path that reads each member, and the keys it reads from an entry:
contentLayoutModalForm.tsx:854only=== 'tabbed'submitBehaviorObjectForm.tsx:1312-1370,:1926-1928;WizardForm.tsx:1005-1065;submitRedirect.ts:202kind;url,delayMs;title,messageFormViewSchema.submitBehaviornavigateOnSuccesssuccessBehavior.ts:118-131viaObjectForm.tsx:1373,:1412-1429;WizardForm.tsx:1071,:1085-1092mobileObjectForm.tsx:1857-1942stickyActions,stepper,stepperMinFields,stepperFieldsPerStep,fullscreenLongText; presence (data-mobile-form)fields(held)ObjectForm.tsx:961-981(pool),:323(field security),:387(master-detail hand-off),:1665-1683(section intersection);flatFields.ts:71-79(drawer / modal,DrawerForm.tsx:473,:877,ModalForm.tsx:562,:1037);sectionFields.ts:203(the warning)namestring[]z.array(z.string())sections(held)ObjectForm.tsx:244-314(group,sectionGroups.ts:139),:288-298,:324-327,:364-367,:386,:406-608(tabbed / wizard / split / drawer / modal maps),:1511-1742(simple);sectionFields.ts:319-485;submitTarget.ts:101-135;TabbedForm,WizardForm,SplitForm,DrawerForm,ModalFormname,label,description,columns,fields,group,pane,collapsible,collapsed,visibleWhen; field entry: a string, a spec field keyed byfield(and its 26 override keys), or a runtime field keyed byname, drawn wholeFormViewSchema.sectionsFormViewSchema.shape.sectionscustomFields(objectui-held)ObjectForm.tsx:755,:1180,:1625,:1675;customFieldsMerge.ts:59-108;sectionFields.ts(findCustomFieldMember);DrawerForm.tsx:433,:465,:480;ModalForm.tsx:523,:555,:569;SplitForm.tsx:323;TabbedForm.tsx:424;WizardForm.tsx:688;submitTarget.ts:131-135;index.tsx:215name, then the whole runtime fieldname)fields(held)MasterDetailForm.tsx:1693into the parent form (read as the form's)z.array(z.string())sections(held)MasterDetailForm.tsx:1692into the parent formFormViewSchema.shape.sectionsThe objectui values were parsed through the built row on this branch (typed members) and through the candidate shape (held members). The not-static values are run-time hand-offs (
ObjectView.tsx:2585,AppContent.tsx:1152,RecordFormPage.tsx:368,useActionModal.tsx:254,ViewPreview.tsx:258,StudioDesignSurface.tsx:4139,EmbeddableForm.tsx:562,MasterDetailForm.tsx:1682, the drawer / modal / form master-detail routes, the designer'sObjectManager/FieldDesigner), test-loop variables, and helper results the instrument does not evaluate.Writer parse results (A3)
Typed members — no refused value is one the form draws.
submitBehavior: 4 static values are refused, each{ kind: 'redirect', url: '//example.com/thanks' }(ObjectForm.submitRedirect.test.tsx:264,:276;WizardForm.submitRedirect.test.tsx:204,:220), and each test asserts the form refuses it and navigates nowhere. Of the 13 not-static values, read by hand: 9 are relative redirects that parse (submitRedirect.injectedNavigation.test.tsx:164,:176,:193,:225,:232,:275,:281,:296,:303), and 4 are redirect fixtures the form refuses (the same-origin absolute${window.location.origin}/thanksatObjectForm.submitRedirect.test.tsx:233,WizardForm.submitRedirect.test.tsx:179,submitRedirect.injectedNavigation.test.tsx:208, and//example.com/thanksat:309). All 3 objectstack values (the showcase's new-project wizard and its two copies in the lint and spec tests) parse.contentLayout,navigateOnSuccess,mobile: every value parses.Held members — the candidate shape refuses values the form draws.
fields(candidatez.array(z.string())), 8 refused. Drawn — working writers: objectui's published page-builder guide (skills/objectui/guides/page-builder.md:263, anos:checkexample:fields: [{ name, label, type, required }, …]), the field-security payload pin (fieldSecurityPayload.test.tsx:214,:222,:230,{ name, label }entries on all three containers), the system-managed payload pin (systemManagedPayload.test.tsx:209), and the{ name }row objectui pins as behaviour (__tests__/objectFormFieldsMembers-8071.test.tsx:165-167, "recorded as drift, not a second contract"). Probes:{ field: 'note' }and{ field: 'sent_at' }(objectFormFieldsMembers-8071.test.tsx:132,:183), which the form warns about and skips.fields, 2 refused:[{ name: 'note' }, 'status'](__tests__/topLevelFieldsWarnCoverage-8847.test.tsx:254-257, "{ name }is tolerated as the same member as the bare name") is drawn;{ field: 'note' }(:104) is a probe of the warning.sections(candidateFormViewSchema.shape.sections), 15 refused. Drawn — working writers: objectui's README wizard example with inline runtime fields (packages/plugin-form/README.md:764), and its submit-target pins that assert that shape renders and submits (submitTargetRefusal.test.tsx:345, and:374, where the inline field is drawn and only the submit is refused for the bare name beside it). Probes:submitTargetRefusal.test.tsx:422(the simple arm resolves zero fields from inline sections), 8className/gridClassNamesections (__tests__/sectionStyleKeysRetired-13626.test.tsx, the retired reads), a section with neitherfieldsnorgroupand agroupbeside alabel/collapsible(__tests__/formSectionGroupReference-7051.test.tsx:270,:307, rendered as nothing and as ignored-and-reported), and a blank view-levelvisibleWhen(wizardVisibleWhenFault-8069.test.tsx:134, diagnosed).sections: no measured value is refused; held with the form's member because it is the same read (A4).Under the triage caveat ("a narrowing that would refuse a measured writer is reported, not shipped silently") and the seat's test, this list is the report: the four held members are not narrowed, and each stays in the enumeration pin's ledger as
held-for-decisionwith the read it rests on. The renderer side is the seat's to card.A2, per member
submitBehavior→FormViewSchema.shape.submitBehavior(the discriminated union onkind, its four strict arms and the redirecturlrule). It states the member and accepts every drawn value.contentLayout('simple' | 'tabbed'),navigateOnSuccess(z.string()),mobile(the five members objectui's ownObjectFormSchema.mobiledeclares; the two counts positive integers — the read clampsstepperFieldsPerStepbelow 1 to 1, and no writer authors a count below 1).fields(no by-reference schema; the renderer's declared typestring[]refuses the{ name }entry it draws), formsections(FormViewSchema.sectionsstates every section key the form reads but refuses the shape-3 field entry it draws), master-detailfieldsandsections.customFields— its entries are objectui's runtime form field (FormField, identity keyname, about forty members drawn whole). The spec declares no such field, and its ownFormFieldSchemais keyed byfield, which the merge never matches. Typing it means declaring that contract in the spec first, which is theobjectui-heldstage's definition, so its ledger line moves there (theobjectui-heldtext now namesFormField). See the open question in the dev report.A4: one read, two rows
MasterDetailFormbuilds its parent form withsections: schema.sections, fields: schema.fields(MasterDetailForm.tsx:1692-1693), so each master-detail member is read by exactly the form's read. Both rows take one disposition per member: held. The master-detailfieldshold also rests on its own drawn{ name }writer; the master-detailsectionshold rests on the shared read — typing it alone would give one read two accept sets.The pin (A5)
component-props-unknown-members.pin.test.ts: fourstaged('object-form', …)lines leave (contentLayout,submitBehavior,navigateOnSuccess,mobile); formfields[]andsections[]and master-detailsections[]/fields[]becomeheld-for-decision, each with its read and writers in the comment;customFieldsbecomesobjectui-held. Theobject-formstage leavesSTAGESwith no line using it. Theheld-for-decisiontext now reads "a typed shape exists (by reference, or the renderer's own declared type)", because formfieldshas no by-reference schema.component-form-family-typed-members.pin.test.ts, the companion pin in stage 2's pattern: §1 every shape a measured writer authors parses byte-identical (16 cases, plus absence); §2 17 refusals by code and path, theheading→titleprescription, and a lit control; §3submitBehavior's def identity with the form view's, and the measured vocabularies ofcontentLayoutandmobile; §4 the D3 registration.The rest of the kit (A6)
ui-object-form-members-typed(D3 only, no conversion, reason recorded) and its step-18 rationale fragment at order 67 (one more than the highest, 66).dropped-refinements.baseline.jsongainsui/ObjectFormProps→submitBehavior.options[1].url: the form view's redirecturlrefinement reaches this second published schema by reference and does not project into JSON Schema; the build refused until the line was added, as finding(spec):ComponentPropsMap['object-grid']keepsresizableColumnsas a live alias ofresizable, and types six membersObjectGridreads with fixed shapes asz.unknown()(rowHeight: 42passes every door) #21445's by-referencebulkActionDefsdid.check:generated --fix(only what it proved stale): the step-18 semantic region ofmigrations/registry.ts,content/docs/references/ui/component.mdx, and theui/strictness counts (191 → 192 sites, +1 strict for themobileblock).@objectstack/specminor, a BREAKING banner,Clause-②: yes (narrowing), a FROM → TO table, the measured census, and the ADR-0087 markerregistered.objectui fixtures (A7)
None needs respelling at objectui's next
@objectstack/specbump. The 8 refusedsubmitBehaviorvalues are render-only tests that mountObjectForm/WizardFormdirectly; none parses throughObjectFormBlockSchema. objectui's parse-through tests (types/src/__tests__/object-form-properties-bag-10859-b4.test.ts,p1-spec-alignment.test.ts) author only values that parse, or members this PR does not narrow. objectui's source indexesSpecObjectFormProps['layout']only, which is not narrowed.Gates
All readings are at
ee6a556ad8, the fourth merge oforigin/main(cc645f2385, docs only), unless one says otherwise. Every exit code was written to disk before any pipe. This run resumed after a container restart atf63c7849b6: the third gate round there had recorded 87 of 114 exit codes when it was cut off, so after the fourth merge every gate, build, test and the ablation below re-ran atee6a556ad8. The branch's own added and removed lines are byte-identical to the second round's (only context lines moved). After the fourth merge,mainadvanced bya7ab047cf6and901e7cf13a(packages/rest,packages/cloud-connection, the dogfood suite and two changesets): none of this branch's 9 paths and nothing underpackages/spec. They are not merged here, so every reading below stays at the head it cites; the merge queue rebuilds onto currentmain.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 114 commands (9 paths, 549 changed lines, +521 / -28, merge basecc645f238), all run;--ran: 114 derived, 114 run, 0 NOT-MEASURED, 0 UNRUN, every one exit 0.@objectstack/spec:buildexit 0 (the package's own build, after a turbo run that replayed 72 of 72 tasks from cache), and the tree stayed clean;check:generatedexit 0 — "All 15 generated artifacts are up to date";check:migration-registry— "src/migrations/registry.ts is current (357 semantic, 246 retired-key, 218 retired-def)";check:liveness— "packages/spec/liveness/state-counts/ is current";check:strictness-ledger— "463 triaged site(s) measured";check:authorable-surface(the base trails its anchor, an information line);check:api-surface— "public API surface + factory signatures unchanged";check:docs— "226 generated files in sync with packages/spec": each exit 0.check:generatedandcheck:api-surfacere-ran after the package build, exit 0.pnpm --filter @objectstack/spec test— Test Files 607 passed (607), Tests 17992 passed, 1 todo, exit 0;typecheckexit 0 with "check:test-typecheck: OK";pnpm --filter @objectstack/lint test(the import side ofComponentPropsMap) — 119 files, 5620 tests passed, exit 0; the threeComponentPropsMappins — 3 files, 139 tests passed.pnpm check:doc-authoringandpnpm check:nul-bytes("scanned 9949 text file(s) … no raw ASCII control bytes"): exit 0.check-widening-tellson the branch diff:--declaration noexit 4 with six tells. Five are T1 tells at the newmobileblock's keys inside the formerz.unknown()member — the shape the gate's own text rules a true refusal, so declareyes. The sixth is a T2 atcomponent.zod.ts:41, theFormViewSchemaspecifier in the multi-line import list from./view.zod, read as a new member of a closed set; it is a false tell (an import specifier widens no accept set).--declaration yesexit 0.--eventpayload:check-changeset-no-majorexit 0 — "LEVEL AXIS: this PR declares clause-②yes (narrowing), and no package whosepackages/**/src/**it moves is gradedpatch" · "direction arm:narrowing— a BREAKING change; during the launch window it shipsminor";check-adr-0087-registrationexit 0 — "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition" (registered ui-object-form-members-typed);check-empty-changesetexit 0 — "No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added)".node scripts/ablation-replace.mjsin wrap mode, inside a script with its ownEXIT INT TERMrestore trap):object-formcontentLayoutreverted toz.unknown().optional(), with no ledger line. It landed: anchor x1 to x0, replacement x0 to x1, blob2594178cfcto82b2f86644. Red: Tests 5 failed | 86 passed (91) — the enumeration pin's §1 received exactly[ 'object-form contentLayout' ]and its census-equals-ledger control read 96 against 95, and the companion pin failed its twocontentLayoutrefusals and its vocabulary case. Restored: blob after restore2594178cfc== blob at HEAD,git diff HEADempty (the tool's check and the trap's both). Green rerun: Tests 91 passed (91). The pins import./component.zodfrom source, so no build sits between mutation and run.--print-config; the other 4 changed files resolve none),--no-inline-config --format json: 5 files, 0 errors, 0 warnings, exit 0.eslint.config.mjsnever enables type-aware linting (lines 326-328), so this diff cannot move the verdict on an untouched file.pnpm lint; reason: CI-owned.Acceptance notes
fields: objectui's declared type and its zod twin arestring[], its registration calls{ name }"tolerated", and its own pin calls it "drift, not a second contract" — the arguments for a ruling, not a licence to ship past the caveat. Formsections: the shape-3 entry is how objectui's sectioned variants run with no data source (submitTarget.ts:36-61), shipped in its README. Both are the seat's to card on the renderer side; this PR changes neither accept set.sectionsruling, observed: the form view's section accepts the deprecatedvisibleOnand stringcolumns('2'), which the form view's own parse normalizes. A page component'spropertiesis never parsed, so on this door the renderer would read them raw: no arm reads a sectionvisibleOn, and the simple arm's column clamp takes numbers only (ObjectForm.tsx:1599-1600). Typingsectionsby reference would accept both on a door where they render nothing.skills/objectui/guides/page-builder.md:263) teachesobject-formfieldsentries withlabel,typeandrequired; the form draws each entry bynameand drops the other three. It belongs with thefieldshold's ruling; noted, not filed here.#21464 remains open for S-metric and S-objectui-held. object-kanban
conditionalFormattingstays held on objectstack-ai/objectui#11522, object-gridcolumnson objectstack-ai/objectui#11544, and the four form members here await the seat's carrier.Generated by Claude Code