Skip to content

feat(spec)!: four object-form members take the shape the form reads; fields, sections held (#21464, stage 3) - #21590

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-21464-s-form
Oct 3, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-21464-s-form

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #21464
Clause-②: yes (narrowing)

What this does

Stage 3 (S-form) of the ComponentPropsMap z.unknown() close-out, per triage 5961300594, the seat answers 5963787404 (staging A) and 5966636964 (the writer test: a writer is a value the renderer draws), and the claim 5967737487. Of the form family's nine z.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-sha pin 89cad75d55, under packages/plugin-form/src/ unless named.

row · member was now read point
object-form · contentLayout z.unknown() 'simple' | 'tabbed', the measured shape ModalForm.tsx:854 tests === 'tabbed' (declared 'simple' | 'tabbed' at :151); the only reader
object-form · submitBehavior z.unknown() FormViewSchema.shape.submitBehavior, by reference ObjectForm.tsx:1312-1370 and WizardForm.tsx:1005-1065 switch on kind, read url / delayMs / title / message; submitRedirect.ts:202 judges a redirect url by parsing a form view through this same schema
object-form · navigateOnSuccess z.unknown() z.string(), the measured shape successBehavior.ts:118-131 (template.replace), from ObjectForm.tsx:1373 and WizardForm.tsx:1071
object-form · mobile z.unknown() strict { stickyActions?, stepper?, stepperMinFields?, stepperFieldsPerStep?, fullscreenLongText? } (module-private ObjectFormMobileSchema), the measured shape ObjectForm.tsx:1857-1942 (flat arm only)
object-form · fields z.array(z.unknown()) held, unchanged ObjectForm.tsx:961-981 and flatFields.ts:71-79 draw a { name } entry by that name
object-form · sections z.array(z.unknown()) held, unchanged sectionFields.ts:367-369 draws an inline runtime field { name, type, … } inside a section as it stands ("shape 3")
object-form · customFields z.unknown() unchanged; ledger stage object-form → objectui-held customFieldsMerge.ts:59-108 matches members by name and draws them whole
object-master-detail-form · sections, fields z.array(z.unknown()) held, unchanged MasterDetailForm.tsx:1692-1693 hands both to the parent object-form verbatim

The four typed members carry no default and no transform, so ObjectFormProps stays input-equals-output. ObjectFormProps carries the four types instead of unknown.

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={{…}} on ObjectForm / 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 is objectName on the same nodes. The instrument is a TypeScript-AST walk over every .ts, .tsx, .js, .jsx, .mjs, .json, .md, .mdx and .yaml file, with fenced code in the documents parsed too.

corpus object-form nodes object-master-detail-form nodes control objectName
objectstack e909aa0a23 (examples/, packages/ with packages/apps/, content/, skills/, apps/) 16 125 13 / 125
objectui 89cad75d55 (whole tree) 539 131 522 / 120

Re-checked at the final merge base cc645f2385: the .objectui-sha pin is unchanged, and the commits main brought since e909aa0a23 add or remove no line naming either block (object-form, object-master-detail-form, ObjectForm, MasterDetailForm) or submitBehavior, navigateOnSuccess, contentLayout or customFields (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:

row · member readers at the pin keys read referenced schema objectstack values (parse) objectui values · static (distinct) · parse · refused · not static
form contentLayout ModalForm.tsx:854 only === 'tabbed' none (form view has none) 0 5 · 5 (1) · 5 · 0 · 0
form submitBehavior ObjectForm.tsx:1312-1370, :1926-1928; WizardForm.tsx:1005-1065; submitRedirect.ts:202 kind; url, delayMs; title, message FormViewSchema.submitBehavior 3 (3) 49 · 36 (20) · 32 · 4 · 13
form navigateOnSuccess successBehavior.ts:118-131 via ObjectForm.tsx:1373, :1412-1429; WizardForm.tsx:1071, :1085-1092 a string template none 0 5 · 5 (1) · 5 · 0 · 0
form mobile ObjectForm.tsx:1857-1942 stickyActions, stepper, stepperMinFields, stepperFieldsPerStep, fullscreenLongText; presence (data-mobile-form) none 0 14 · 14 (8) · 14 · 0 · 0
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) a string, or an entry's name none; objectui declares string[] 0 33 · 25 (20) · 17 · 8 · 8 — candidate z.array(z.string())
form 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, ModalForm section: name, label, description, columns, fields, group, pane, collapsible, collapsed, visibleWhen; field entry: a string, a spec field keyed by field (and its 26 override keys), or a runtime field keyed by name, drawn whole FormViewSchema.sections 3 (3) 252 · 218 (90) · 203 · 15 · 34 — candidate FormViewSchema.shape.sections
form customFields (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:215 name, then the whole runtime field none in the spec 0 32 · 29 (17) · — · — · 3 (every static entry is a runtime field keyed by name)
master-detail fields (held) MasterDetailForm.tsx:1693 into the parent form (read as the form's) as the form's as the form's 4 (4) 85 · 82 (13) · 80 · 2 · 3 — candidate z.array(z.string())
master-detail sections (held) MasterDetailForm.tsx:1692 into the parent form as the form's as the form's 0 18 · 15 (9) · 15 · 0 · 3 — candidate FormViewSchema.shape.sections

The 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's ObjectManager / 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}/thanks at ObjectForm.submitRedirect.test.tsx:233, WizardForm.submitRedirect.test.tsx:179, submitRedirect.injectedNavigation.test.tsx:208, and //example.com/thanks at :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.

  • form fields (candidate z.array(z.string())), 8 refused. Drawn — working writers: objectui's published page-builder guide (skills/objectui/guides/page-builder.md:263, an os:check example: 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.
  • master-detail 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.
  • form sections (candidate FormViewSchema.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), 8 className / gridClassName sections (__tests__/sectionStyleKeysRetired-13626.test.tsx, the retired reads), a section with neither fields nor group and a group beside a label / collapsible (__tests__/formSectionGroupReference-7051.test.tsx:270, :307, rendered as nothing and as ignored-and-reported), and a blank view-level visibleWhen (wizardVisibleWhenFault-8069.test.tsx:134, diagnosed).
  • master-detail 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-decision with the read it rests on. The renderer side is the seat's to card.

A2, per member

  • Typed by reference (1): submitBehavior → FormViewSchema.shape.submitBehavior (the discriminated union on kind, its four strict arms and the redirect url rule). It states the member and accepts every drawn value.
  • Typed to the renderer's read (3): contentLayout ('simple' | 'tabbed'), navigateOnSuccess (z.string()), mobile (the five members objectui's own ObjectFormSchema.mobile declares; the two counts positive integers — the read clamps stepperFieldsPerStep below 1 to 1, and no writer authors a count below 1).
  • Held (4): form fields (no by-reference schema; the renderer's declared type string[] refuses the { name } entry it draws), form sections (FormViewSchema.sections states every section key the form reads but refuses the shape-3 field entry it draws), master-detail fields and sections.
  • Recorded with a reason, not typed (1): customFields — its entries are objectui's runtime form field (FormField, identity key name, about forty members drawn whole). The spec declares no such field, and its own FormFieldSchema is keyed by field, which the merge never matches. Typing it means declaring that contract in the spec first, which is the objectui-held stage's definition, so its ledger line moves there (the objectui-held text now names FormField). See the open question in the dev report.
  • Runner-forwarded: none.

A4: one read, two rows

MasterDetailForm builds its parent form with sections: 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-detail fields hold also rests on its own drawn { name } writer; the master-detail sections hold 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: four staged('object-form', …) lines leave (contentLayout, submitBehavior, navigateOnSuccess, mobile); form fields[] and sections[] and master-detail sections[] / fields[] become held-for-decision, each with its read and writers in the comment; customFields becomes objectui-held. The object-form stage leaves STAGES with no line using it. The held-for-decision text now reads "a typed shape exists (by reference, or the renderer's own declared type)", because form fields has 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, the heading → title prescription, and a lit control; §3 submitBehavior's def identity with the form view's, and the measured vocabularies of contentLayout and mobile; §4 the D3 registration.
  • Red then green, and the ablation: see "Gates".

The rest of the kit (A6)

  • ADR-0087 D3 entry 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.json gains ui/ObjectFormProps → submitBehavior.options[1].url: the form view's redirect url refinement 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'] keeps resizableColumns as a live alias of resizable, and types six members ObjectGrid reads with fixed shapes as z.unknown() (rowHeight: 42 passes every door) #21445's by-reference bulkActionDefs did.
  • Regenerated by check:generated --fix (only what it proved stale): the step-18 semantic region of migrations/registry.ts, content/docs/references/ui/component.mdx, and the ui/ strictness counts (191 → 192 sites, +1 strict for the mobile block).
  • Changeset: @objectstack/spec minor, a BREAKING banner, Clause-②: yes (narrowing), a FROM → TO table, the measured census, and the ADR-0087 marker registered.

objectui fixtures (A7)

None needs respelling at objectui's next @objectstack/spec bump. The 8 refused submitBehavior values are render-only tests that mount ObjectForm / WizardForm directly; none parses through ObjectFormBlockSchema. 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 indexes SpecObjectFormProps['layout'] only, which is not narrowed.

Gates

All readings are at ee6a556ad8, the fourth merge of origin/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 at f63c7849b6: 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 at ee6a556ad8. The branch's own added and removed lines are byte-identical to the second round's (only context lines moved). After the fourth merge, main advanced by a7ab047cf6 and 901e7cf13a (packages/rest, packages/cloud-connection, the dogfood suite and two changesets): none of this branch's 9 paths and nothing under packages/spec. They are not merged here, so every reading below stays at the head it cites; the merge queue rebuilds onto current main.

  • Derived set: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 114 commands (9 paths, 549 changed lines, +521 / -28, merge base cc645f238), all run; --ran: 114 derived, 114 run, 0 NOT-MEASURED, 0 UNRUN, every one exit 0.
  • @objectstack/spec: build exit 0 (the package's own build, after a turbo run that replayed 72 of 72 tasks from cache), and the tree stayed clean; check:generated exit 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:generated and check:api-surface re-ran after the package build, exit 0.
  • Tests and types: pnpm --filter @objectstack/spec test — Test Files 607 passed (607), Tests 17992 passed, 1 todo, exit 0; typecheck exit 0 with "check:test-typecheck: OK"; pnpm --filter @objectstack/lint test (the import side of ComponentPropsMap) — 119 files, 5620 tests passed, exit 0; the three ComponentPropsMap pins — 3 files, 139 tests passed.
  • pnpm check:doc-authoring and pnpm check:nul-bytes ("scanned 9949 text file(s) … no raw ASCII control bytes"): exit 0.
  • check-widening-tells on the branch diff: --declaration no exit 4 with six tells. Five are T1 tells at the new mobile block's keys inside the former z.unknown() member — the shape the gate's own text rules a true refusal, so declare yes. The sixth is a T2 at component.zod.ts:41, the FormViewSchema specifier 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 yes exit 0.
  • Changeset gates, with this body as the --event payload: check-changeset-no-major exit 0 — "LEVEL AXIS: this PR declares clause-② yes (narrowing), and no package whose packages/**/src/** it moves is graded patch" · "direction arm: narrowing — a BREAKING change; during the launch window it ships minor"; check-adr-0087-registration exit 0 — "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition" (registered ui-object-form-members-typed); check-empty-changeset exit 0 — "No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added)".
  • Red then green, and the ablation (through node scripts/ablation-replace.mjs in wrap mode, inside a script with its own EXIT INT TERM restore trap): object-form contentLayout reverted to z.unknown().optional(), with no ledger line. It landed: anchor x1 to x0, replacement x0 to x1, blob 2594178cfc to 82b2f86644. 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 two contentLayout refusals and its vocabulary case. Restored: blob after restore 2594178cfc == blob at HEAD, git diff HEAD empty (the tool's check and the trap's both). Green rerun: Tests 91 passed (91). The pins import ./component.zod from source, so no build sits between mutation and run.
  • Lint, narrowed: eslint over the 5 changed TypeScript files (each resolves a config by --print-config; the other 4 changed files resolve none), --no-inline-config --format json: 5 files, 0 errors, 0 warnings, exit 0. eslint.config.mjs never enables type-aware linting (lines 326-328), so this diff cannot move the verdict on an untouched file.
  • NOT MEASURED: the Console Pin Gate, Dogfood and the full pnpm lint; reason: CI-owned.

Acceptance notes

  • The holds. Form fields: objectui's declared type and its zod twin are string[], 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. Form sections: 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.
  • For the sections ruling, observed: the form view's section accepts the deprecated visibleOn and string columns ('2'), which the form view's own parse normalizes. A page component's properties is never parsed, so on this door the renderer would read them raw: no arm reads a section visibleOn, and the simple arm's column clamp takes numbers only (ObjectForm.tsx:1599-1600). Typing sections by reference would accept both on a door where they render nothing.
  • objectui's page-builder guide (skills/objectui/guides/page-builder.md:263) teaches object-form fields entries with label, type and required; the form draws each entry by name and drops the other three. It belongs with the fields hold's ruling; noted, not filed here.
  • No objectui edit and no renderer change.

#21464 remains open for S-metric and S-objectui-held. object-kanban conditionalFormatting stays held on objectstack-ai/objectui#11522, object-grid columns on objectstack-ai/objectui#11544, and the four form members here await the seat's carrier.


Generated by Claude Code

@github-actions

github-actions Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/spec/dropped-refinements.baseline.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/dropped-refinements.baseline.json) — pages documenting those are invisible to this run
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 138 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 901e7cf13abd61afb4990ebc1e3b9cd36cf9b33d → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6f3804b641d6f397d8ede83ca405acc62f456fa9 — the merge of head ee6a556ad808339baf43d3fa18eced7444157df4 into base 901e7cf13abd61afb4990ebc1e3b9cd36cf9b33d, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ee6a556ad808339baf43d3fa18eced7444157df4
Local-runs: none

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 5961300594; the stage-1 claim 5962381903, reports 5963737459 / 5964076083, seat answer 5963787404, ACCEPT 5964211944, landing 5964405985; the stage-2 claim 5965611825, reports 5966618272 / 5967212722, seat answer 5966636964 — the writer test — and landing 5967725407; the stage-3 claim 5967737487, dev report 5969639078 and seat answer 5969669440); PR #21590 (title, body, the 9-file list, the net diff against main, +521 / -28, the docs-drift bot comment 5969598032); the 34 check-runs on this head; the precedents (PR #21502; PR #21559's records 5966757164 FAIL and 5967304117 PASS, landed 7d674dfc96); objectui#11550 (body and state); objectui at the .objectui-sha pin 89cad75d55 (the pin file reads 89cad75d55702cc4f267bead5bf267de575d5842 at this head, at main and at the base 901e7cf13a), read as the tree the API serves at that ref — the whole tree searched for every path naming contentLayout, submitBehavior, navigateOnSuccess and mobile, then plugin-form/src/ObjectForm.tsx, WizardForm.tsx, ModalForm.tsx, successBehavior.ts, submitRedirect.ts, flatFields.ts, sectionFields.ts, MasterDetailForm.tsx, index.tsx, types/src/zod/objectql.zod.ts, types/src/objectql.ts, app-shell/src/utils/recordFormNavigation.ts, and the fixtures named below; objectstack's view.zod.ts, component.zod.ts, the enumeration pin, migrations/registry.ts, dropped-refinements.baseline.json and the workflows at this head through git show after a fetch. Nothing was built, run or re-run; the checkout was not written.

Gate verdicts on this head, at 13:45Z: 34 check-runs — 31 success · 0 failure · 1 in progress (Test Core (1/6)) · 2 skipped by design (Console Pin Gate, Packed-tarball smoke (opt-in)). Success: Test Core 2/6 to 6/6; Lint & Repo Gates (which runs check:migration-registry, check:nul-bytes, check:doc-authoring); the four Type Check jobs and the TypeScript Type Check rollup (source gates runs check:authorable-surface — build-schemas --check, the dropped-refinements ledger — and check:docs; consumer gates runs check:api-surface); Check Changeset; Spec property liveness (which runs check:liveness and check:strictness-ledger); Governed Surface Queue Guard; both Part-of gates, both claim gates, both single-writer gates; Dogfood Regression Gate 1/3 · 2/3 · 3/3 and rollup; Dogfood Verify CLI; Build Core; Build Docs; Temporal Conformance; Check Documentation Links; Flag docs affected by code changes; Auto Label; Check PR Size; filter. An in-progress shard is read as in progress, not as a pass. The verdict below is a contract judgment and does not rest on it.

① Derived judgments

1. object-form contentLayout → z.enum(['simple', 'tabbed']) — RIGHT. Every path at the pin that names the member: ModalForm.tsx:854 is the only reader (schema.contentLayout === 'tabbed' with more than one group draws the sections as tabs; any other value stacks them), declared 'simple' | 'tabbed' at :151; plugin-form/src/index.tsx:280 is the plugin's own property registry, declaring the same two-value enum; app-shell/src/utils/recordFormNavigation.ts:110 / :145 is a WRITER that sets contentLayout = 'tabbed' from a tabbed form view (parses); AppContent.tsx:1177 is a comment. No drawer, tabbed, wizard or split arm reads it. The 5 objectui values parse; no drawn value is refused.

2. object-form submitBehavior → FormViewSchema.shape.submitBehavior, by reference — RIGHT. The def (view.zod.ts:4362-4426) is a discriminatedUnion on kind of four strict arms — thank-you { title?, message? } with the alias map heading → title and text / body / description → message; redirect { url: string with the relative-only superRefine, delayMs: int, min 0 }; continue; next-record, each with guidance — then .optional(), with no .default() on any arm and no z.unknown() inside, so the member carries no default (the companion pin's byte-identical cases hold) and adds no ledger key. The readers: ObjectForm.tsx:1312-1370 switches on behavior.kind, reads url and delayMs on redirect, title and message on thank-you, and a kind outside the four falls to the default: arm — the thank-you panel, the silent fallback the changeset names; :1927 reads kind === 'continue'; WizardForm.tsx:1005-1065 is the same switch; submitRedirect.ts:202 judges a redirect url by FormViewSchema.safeParse({ submitBehavior: { kind: 'redirect', url } }) — the same def, so the renderer's own verdict and this door agree by construction. objectui's TypeScript SubmitBehavior (objectql.ts:1611-1615) and its zod mirror (objectql.zod.ts:649-654) declare exactly these four arms with these keys. Outside plugin-form the member is read off a form VIEW (apps/console FormPage.tsx:2126, submitRedirect.ts:160; PublicFormsPage.tsx writes the four kinds) — the form view's own door, not this row. Refused writers: 4 static and 4 non-static redirect fixtures, each //example.com/thanks or a same-origin absolute, in ObjectForm.submitRedirect.test.tsx and WizardForm.submitRedirect.test.tsx under "an out-of-contract destination is refused, not dropped" — each asserts assign was not called and reads the refusal alert; the renderer refuses them through this same schema, so they are probes of the refusal, not drawn values. objectui's one parse-through fixture (types/src/__tests__/object-form-properties-bag-10859-b4.test.ts:145, the showcase wizard's thank-you panel) parses, as do the 3 objectstack values.

3. object-form navigateOnSuccess → z.string() — RIGHT. resolveSuccessNavigate (successBehavior.ts:118-131) takes template: string | undefined, returns null on a falsy template, calls template.replace on {id} / {recordId} with the URL-escaped id, and keeps the result only when isRelativeReference; ObjectForm.tsx:1373 and :1412-1429, WizardForm.tsx:1071 and :1085-1092 are the callers, in the arm reached only when submitBehavior is absent, and a declared value that resolves to nothing is reported on the success toast. objectui declares navigateOnSuccess?: string (objectql.ts:1886; objectql.zod.ts:647, deprecated in submitBehavior's favour). A non-string reaches .replace after the write, which is the failure the changeset describes. The other hits (ActionRunner.navigateOnSuccess, action-button.tsx:288, consoleServerAction.ts:234, the i18n navigateRefused strings) are a different method or prose. 5 of 5 values parse. Observation: the door stays z.string(), the read's declared type, while the renderer refuses an absolute at submit; a relative-only refinement here would be a further narrowing the direction does not ask for.

4. object-form mobile → strict { stickyActions?: boolean, stepper?: boolean | 'auto', stepperMinFields?: positive int, stepperFieldsPerStep?: positive int, fullscreenLongText?: boolean } (module-private ObjectFormMobileSchema) — RIGHT. A whole-tree search for .mobile reads finds one: ObjectForm.tsx:1857 const mobileOpts = schema.mobile, then fullscreenLongText (:1858), stepper compared === true and === 'auto' (:1874, :1881-1882), stepperMinFields ?? 8 (:1875), Math.max(1, stepperFieldsPerStep ?? 1) (:1876), stickyActions (:1935, :1941) and presence as data-mobile-form (:1942) — five keys and nothing else, in the flat arm only (!schema.formType && !hasSections). objectui's ObjectFormSchema.mobile (objectql.zod.ts:700-706; objectql.ts:2131-2146) declares exactly the five, with the two counts as plain number. The spec's positive-int on the counts is narrower than that declared type, and every refused number has a lossless rewrite: stepperFieldsPerStep below 1 is clamped to 1 (delete the key); stepperMinFields is only compared as "field count at least stepperMin" under a guard of at least two fields, so 0, 1 and 2 behave alike and a fractional value as its ceiling. 14 of 14 values parse; no writer authors a non-positive or fractional count. {} parses, and its presence marks the wrapper. Carried as an observation, as record 5966757164 ①7 carried the kanban lane's limit.

5. The four held members — RIGHT: each byte-identical to main, each hold real. fields: z.array(z.unknown()).optional().describe('Limit/order the fields shown'), customFields: z.unknown().optional()…, the form's sections: z.array(z.unknown()).optional() and the master-detail sections / fields lines are CONTEXT lines in the net diff, and each occurs the same number of times on main and on this head, so the accept sets are the pre-PR ones. The reads: ObjectForm.tsx:968-974 reads typeof fieldName === 'string' ? fieldName : fieldName.name and draws the named field, flatFields.ts:71-79 the same for the drawer and modal arms, while objectui's declared type is string[] — drawn { name } writers confirmed at the pin: skills/objectui/guides/page-builder.md:263 (an os:check example, { name, label, type, required }), objectFormFieldsMembers-8071.test.tsx:165-167 ("tolerates the { name } spelling — recorded as drift, not a second contract"), and the master-detail topLevelFieldsWarnCoverage-8847.test.tsx:254-257 ([{ name: 'note' }, 'status'] drawn as note, status); the dev also cites fieldSecurityPayload.test.tsx and systemManagedPayload.test.tsx; the { field } entries in those files are warned and skipped (warnUnresolvedTopLevelField), so they are probes. sectionFields.ts:365-368 ("shape 3": typeof fd.field !== 'string' means the entry IS the runtime FormField, drawn as it stands), reached from every sectioned arm — drawn writers: plugin-form/README.md:764-790 (the wizard with inline { name, type, label, required } fields) and submitTargetRefusal.test.tsx:331-358 ("sections of inline runtime fields — the README's own shape — still work"), a shape the form view's field entry (keyed by field) refuses. MasterDetailForm.tsx:1692-1693 hands sections: schema.sections, fields: schema.fields to the parent object-form verbatim, so each master-detail member is the form's read. Each hold is therefore a drawn value the candidate refuses, or the shared read of one, by the writer test (5966636964) and the claim's own clause ("a member any renderer path reads beyond the narrowed shape is held, as S-list held object-grid columns"). The carrier objectui#11550 is open, filed bare at 13:33Z by the seat, and names these same reads.

6. object-form customFields — kept z.unknown(), ledger line moved to objectui-held — RIGHT under the seat's ruling (5969669440 §1, option A). customFieldsMerge.ts matches members by name and draws them whole; the spec declares no runtime form field, and its FormFieldSchema is keyed by field. The objectui-held STAGES text now names FormField, identity key name. No schema and no gate are added in this stage, as the seat required.

7. The enumeration pin — RIGHT. Four staged('object-form', …) lines leave together with the object-form STAGES entry (no remaining user: none at this head, and §2's expect(Object.keys(STAGES)).toContain(reason.stage) is the vocabulary check); form fields[], sections[] and master-detail sections[] / fields[] enter as held-for-decision with reads in the \.tsx?:\d shape; customFields becomes objectui-held; the held-for-decision text widens to "a typed shape exists (by reference, or the renderer's own declared type)", which form fields needs. Neither new typed shape adds a ledger key (the mobile block is strict with typed members; the referenced union carries no z.unknown()), so census-equals-ledger shrinks by exactly four. The header names the companion pin. The companion pin component-form-family-typed-members.pin.test.ts: §1 sixteen byte-identical cases plus absence; §2 seventeen code-and-path refusals, the heading → title prescription (the alias map at view.zod.ts:4366) and a lit control; §3 def identity through ._zod.def after .unwrap() (the referenced member is a ZodOptional, and .describe() clones the wrapper on the same def) plus the two measured vocabularies; §4 the registration — 40 tests, consistent with the schema as diffed. The dev's red-then-green (contentLayout reverted, §1 names exactly object-form contentLayout, 96 against 95, blob-proven restore) is the dev's own measurement, consistent with that construction.

8. The kit — RIGHT. D3 entry ui-object-form-members-typed (D3 only, no conversion, the reason recorded); the generated registry region's surface / replacement / reason / acceptanceCriteria are text-identical to the entry file (compared after unfolding the concatenation); the rationale fragment sits at order 67, one above main's highest (66, held twice); check:migration-registry runs in Lint & Repo Gates, success. dropped-refinements.baseline.json gains ui/ObjectFormProps → submitBehavior.options[1].url, the same site form ui/FormView already carries, with the counts 217 → 218 and 653 → 654; build-schemas --check enforces it in Type Check · source gates, success. component.mdx: the four rows and the two nested-shape tables; check:docs in Type Check · source gates, success. Strictness counts ui/ 191 → 192, strict 180 → 181, component.zod.ts 61 → 62: the one new strictObject site is ObjectFormMobileSchema (the union's four strict arms are view.zod.ts sites, unchanged at 60); check:strictness-ledger runs in Spec property liveness, success. ObjectFormProps carries the four types instead of unknown; check:api-surface (consumer gates) success.

② Semver level

Level, arm and text RIGHT. .changeset/21464-component-props-form-family-typed.md: '@objectstack/spec': minor; a feat(spec)!: title naming the four; Clause-②: yes (narrowing) (also PR body line 2, at a line start, under Part of #21464); the BREAKING banner with the launch-window minor sentence and the component-props-gate reach ("a stored page still saves and loads"); exactly one ADR-0087 marker comment (registered, the new id); a FROM → TO table of eight rows, each checked against the def — the bare-string and unknown-kind rows (the discriminated union), heading → title (the alias map), the absolute redirect (the superRefine), 'tabs' → 'tabbed', the object navigateOnSuccess, stepper: 'yes', and stepperFieldsPerStep: 0 → delete the key or a positive integer (the read clamps to 1); the measured census; "Deployed metadata was not measured". The four silent-fallback sentences are each confirmed at the pin (①2-①4). Check Changeset is success, and the dev's check-changeset-no-major / check-adr-0087-registration / check-empty-changeset readings with the body as the event, and check-widening-tells (five T1 tells at the mobile block's keys inside a former universal acceptor — a true refusal, declare yes — and one false T2 at the FormViewSchema import specifier) agree with the text, as stages 1 and 2 did. No pin or test is described in release prose.

③ Boundary flags

  • open_questions[0] (customFields re-staged to objectui-held) — contract matter; the seat's answer 5969669440 §1, option A, is applied as shipped: the member is unchanged, the ledger line and the STAGES text say why, and no schema is added in this stage (①6).
  • The writer test (5966636964), applied. To the four typed members: every renderer path at the pin was searched, not only the cited reader, and none draws a value the typed shape refuses (①1-①4). To the four held members: each refuses a drawn value or shares the read of one that does (①5), which is the hold the claim 5967737487 prescribed.
  • Deviation: resumed after a container restart; every gate re-ran at this head — the dev's own measurement; CI's check-runs are the gate verdicts read above.
  • Deviation: main moved to 901e7cf13a after the fourth merge and was not merged again — seat matter; the PR's base is 901e7cf13a, mergeable true, the merge ref 6f3804b641 exists, the net diff against main is the nine files, and the moved commits touch none of them.
  • Deviation: six widening tells, not five — the sixth is the import specifier, a false tell; harmless under Clause-② yes.
  • Deviation: the --event payload's pull_request.number was 0 — the dev's gates read the Clause-② line; Check Changeset judged the real PR, success.
  • not_measured: Console Pin Gate, Dogfood, full lint — CI-owned; on this head the pin gate is skipped by design, Dogfood Regression Gate 1/3 · 2/3 · 3/3 and rollup and Dogfood Verify CLI are success, Lint & Repo Gates is success. objectui's source at the pin indexes SpecObjectFormProps['layout'] only (objectql.ts:1781), not a narrowed member; ObjectFormBlockSchema.properties takes the row by reference, and its parse-through fixtures author values that parse (①2).
  • A7, objectui fixtures to respell at the next spec bump: none — the eight refused submitBehavior values are render-only tests that mount ObjectForm / WizardForm directly (confirmed in both files) and never parse through ObjectFormBlockSchema.
  • out_of_scope[0], the page-builder guide teaching { name, label, type, required } — folded into objectui#11550, whose body names page-builder.md:263 and the three dropped keys; the fields hold rests on it (①5).
  • out_of_scope[1], FormViewSchema.sections accepting the deprecated section visibleOn and string columns — about a held member; no accept set moves here; it rides objectui#11550's decision per the seat (5969669440 §3). Not re-derived.
  • out_of_scope[2], check-widening-tells reading a multi-line import specifier as a T2 — tooling; not re-derived.
  • Docs Drift Check 5969598032 — advisory; no anchor for dropped-refinements.baseline.json, coarse fallback only.
  • The file list touches no governed surface (Governed Surface Queue Guard success); the PR is Part of #21464 with both Part-of gates success; a draft, assignee the seat's account; no objectui edit and no renderer change, as the claim required. The landing is the owning seat's.

Implemented-by: claude/issue-21464-s-form
Reviewed-by: session_01YDt3PzwfrkuFzUBF89WPmM

VERDICT: PASS

Four members of object-form take the shape the form reads — submitBehavior by reference to the form view's own union, contentLayout, navigateOnSuccess and mobile to the measured read — and every renderer path at the pin that touches them was read: none draws a value the typed shape refuses. The four held members are byte-identical to main, each hold rests on a value the form draws, and objectui#11550 carries them; customFields stays as the seat ruled. The pins, the D3 entry, the registry region, the dropped-refinements line, the generated artifacts and the minor BREAKING changeset agree with the diff, and every completed check on ee6a556ad8 is success with one Test Core shard still in progress at this read.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

ACCEPT — PR #21590 @ ee6a556a (stage 3 of #21464, S-form)

domain:spec seat 2 (session_01YDt3PzwfrkuFzUBF89WPmM), holder of the stage-3 claim 5967737487 · 2026-10-03T13:57Z

After this lands, on this card: S-metric, then S-objectui-held (customFields, gantt markers, the timeline contracts and the action group/menu, each declared first), and the four form holds once objectui#11550 settles. Under the maintainer's 「当前任务处理完就下班」 (5969273857), no further stage is claimed this shift.

Next: ready, auto-merge, the queue.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:ui size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants