Skip to content

feat(spec)!: an object-master-detail-form block's detail entries are a strict shape whose columns are the inline grid column contract (#20928) - #21215

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-20928-master-detail-details-strict
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-20928-master-detail-details-strict

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20928

Clause-②: yes

The third carrier of the inline grid column is brought to the contract the other two already hold. ComponentPropsMap['object-master-detail-form'].details was z.array(z.unknown()); each entry is now a strict shape of the keys objectui's MasterDetailForm reads, and its columns references InlineGridColumnSchema. defineStack's identity-only check (collectHydratedInlineColumnErrors) now reaches the block on a page, and field-no-consumers reads a detail entry as a child collection. Governed by ruling 5791803339 (#19629, letter B) and ruling 5805782503 (#19910, letter 乙); routed by triage 5919655566.

What changed

  • packages/spec/src/ui/component.zod.ts: details is z.array(masterDetailDetailEntry()). The entry is a strictObject with childObject (required), relationshipField, columns (z.array(InlineGridColumnSchema), the same object, not a copy), formFields, inlineMode (grid or form), amountField, sortField, totalField, title, minRows, maxRows, addLabel. The keys shared with a form view's subforms[] entry take that entry's types and alias table. The entry is built inside the props schema's own lazy body, as the subforms[] entry is: a standalone lazySchema element is a function-typed proxy the alias-integrity graph walk never descends into (measured: alias-integrity.test.ts went red on it, see commit e734f15e). New type ObjectMasterDetailFormPropsParsed: the props schema's input and output now differ, because the column schema's are different.
  • packages/spec/src/stack.zod.ts: the page-component traversal that collectInlinePageActions owned (regions, slots, nested container arrays) is extracted into forEachPageComponent, unchanged, and the collector walks it for object-master-detail-form blocks, judging details[j].columns against details[j].childObject. A column is judged only once it parses as a column WITHOUT the resolved type. On the two parsed carriers this was already true. On the page carrier, whose properties the stack parse never reaches, it keeps every finding one that the resolved type brings; a column with its own defects is left to the component-props gate.
  • packages/lint/src/validate-field-consumers.ts: CHILD_COLLECTION_KEYS gains details. A detail entry's column name, amountField and relationshipField credit the child's field, totalField the parent's, and an entry without columns credits the derived grid. The page slot map's own details key holds components, which carry none of an entry's keys at their own level. A pinned control shows such a component is read the same under slots.details as under slots.header.
  • ADR-0087: new D3 semantic entry ui-object-master-detail-form-details-closed, plus a step-18 rationale fragment (order 56). gen:migration-registry regenerated the registry. No D2 conversion, per the census below.
  • content/docs/protocol/objectui/layout-dsl.mdx: the master-detail example's columns: [product, quantity, unit_price] (bare strings) becomes three name: columns. The strict entry refuses bare strings, and the renderer could not draw them either: hydrateColumns returns a string column unchanged, and the grid reads col.name.
  • Generated: dropped-refinements.baseline.json (one new site, ui/ObjectMasterDetailFormProps at details.element.columns.element, plus its two counts), api-surface/ui.json, export-origins/ui.json, content/docs/references/ui/component.mdx, docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md. type-alias-convention.pin.test.ts drops the props schema's isomorphism pin (779 to 778) for the new XParsed alias, the route that file prescribes.
  • Changeset .changeset/20928-master-detail-details-closed.md: @objectstack/spec minor (BREAKING, Clause-②: yes (narrowing), marker registered ui-object-master-detail-form-details-closed), @objectstack/lint patch.

Measurements

Premise (origin/main 2c1cef3345 at dispatch, re-read at this head): details: z.array(z.unknown()).optional() sat at component.zod.ts:4776. Premise holds.

The read set, from objectui packages/plugin-form/src/MasterDetailForm.tsx at the .objectui-sha pin 31971ff1e28f. The file is byte-identical at the dispatch pin e420df310f5b. MasterDetailDetailConfig is declared at :64-93. Each of its twelve keys has a live read: childObject :986, :1003; relationshipField :1033, :1312; columns :858, :928, :1034; formFields :847, :1063; inlineMode :850, :1064; amountField :861, :1035, :1550; sortField :862, :1038; totalField :1549; title :755; minRows :863; maxRows :864; addLabel :865. A grep of every d.X, detail.X and e.config.X member read in the file finds no thirteenth key; detail.saving and detail.add are i18n keys. deriveDetail hydrates an authored list with hydrateColumns and derives with deriveColumns, the same rule deriveInlineGridColumns reproduces.

Census (origin/main ebdb6f2aca, the branch point): blocks typed object-master-detail-form appear in the showcase project-workspace.page.ts (one entry, { title, childObject, addLabel }, no columns), in one doc example (three bare-string columns, rewritten here), and in the lint and spec test fixtures (copies of the showcase entry). sdui.manifest.json is objectui's registry, not a producer. field-keyed detail columns: 0. The regex details:.*field: does match three API-error details payloads elsewhere, so the matcher is live. Control: one authored inlineColumns block in examples/ (showcase invoice.object.ts). So only a narrowing marker is owed. Page-component properties is not parsed on the save or load path, so no stored row is ever refused, and no conversion is registered.

Collector reach: config.pages is reachable from the collector, and the existing page walk already reached component properties with hard cross-reference errors (inline actions). Measured below.

Field-consumer scan: before this change it did not read details[].columns[].name (CHILD_COLLECTION_KEYS was subforms only). Ablation A4 below confirms it.

Pinned objectui (31971ff1e28f, git grep): the only non-test importer is packages/types/src/zod/objectql.zod.ts:52 / :3913, which takes ObjectMasterDetailFormPropsSchema by reference through stripImportedDefaults, a generic walk. The tests registered-type-arms-10859-b2.test.ts compare the node's props to SpecObjectMasterDetailFormProps by reference on both sides, and their showcase fixture { title, childObject, addLabel } stays valid. No value the narrowed type refuses was found at that pin. objectui's own suites against this spec: NOT MEASURED (objectui coordination, below).

Public door (os validate --json, CLI built at bfb26d52, a probe stack with a currency child field):

probe column(s) exit valid finding
{ name: 'amount', type: 'currency', scale: 2 } 0 true advisory component-props-invalid at properties.details.0.columns.1.scale, the ruled first sentence
{ zzz_not_a_key: 1 } 0 true advisory component-props-unknown-key (zzz_not_a_key) and component-props-invalid (name required)
{ name: 'amount', scale: 2 } over a currency field 1 false STACK_CROSS_REFERENCE_INVALID: Page 'probe_invoice_entry' (regions.0.components.0) object-master-detail-form details[0].columns[1].scale: column 'amount' declares no type, so it renders as a currency column …
{ name: 'quantity' }, { name: 'amount' } (control) 0 true none; and field-no-consumers reports neither child field

Verification (at bfb26d52, after merging origin/main 0d421041 through scripts/pm/os-regen-merge.sh)

  • @objectstack/spec vitest run --project local, two shards: 297 + 297 files passed, 8915 + 8511 tests passed, 1 todo. --project repo subset (step18-rationale-merge, conversions-major18-merge, retired-key-migrate-sentence): 35 passed. pnpm --filter @objectstack/spec typecheck: exit 0. tsc -p tsconfig.test.json --listFiles includes both edited test files, and none of the errors held in the debt ledger names them.
  • @objectstack/lint vitest run: 118 files / 5494 passed. typecheck: exit 0.
  • Consumers, each named package exactly (--filter PKG, not a closure): @objectstack/sdui-parser 13 files / 217 passed; @objectstack/metadata-protocol 200 passed, 3 skipped files / 2973 passed, 19 skipped. Packages whose fixtures write the block: spec and lint only, covered above.
  • pnpm --filter @objectstack/spec check:generated: all 15 artifacts up to date after a fresh build.
  • Derived gates node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands: 114 derived, 114 run, all exit 0. --ran reconciliation: "0 NOT-MEASURED (a DERIVED zero)". Three first exited 3 (PREREQUISITE NOT MET: check:doc-formula-expressions, check:doc-security-posture, check:skill-examples) and were green after turbo run build over ./packages/* and ./packages/*/*. Five roster gates marked as sharing a directory with this diff (check-changeset-fixed, check:meta-url-spelling, check:authz-resolver, check:error-code-casing, check:filter-alias-parity) and check-sdui-manifest: exit 0.
  • ESLint, a narrowed run, stated as such. eslint --no-inline-config --format json over the 8 changed .ts files reports files 8, errors 0, warnings 0. The population is eslint.config.mjs:971, **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}. That config never enables type-aware linting (its comment at :326-328: no parserOptions.project, no typed rules), so this diff cannot move the verdict on any untouched file.
  • Ablations, each through scripts/ablation-replace.mjs from committed state (bfb26d52), each restored with blob equal to HEAD and git diff HEAD empty. A first pass at 074d6ca4, before the entry became a factory, gave the same four counts. The subjects resolve to src through relative imports, so no dist leg was owed.
    • A1 details: z.array(masterDetailDetailEntry()) → z.array(z.unknown()) (blob 72d6abd987ce → 242fee1c1fb5): carriers pin 8 failed | 28 passed (36). All seven props-half refusals went red, plus the first assertion of the "left to the props half" pin. I predicted 7; the eighth is that pin's own props-half check.
    • A2 the collector's page loop emptied (aa6368d1f138 → 318e3e0ee35f): 2 failed | 34 passed. Those are the region refusal and the nested and slot refusal.
    • A3 the valid-without-type precondition deleted (aa6368d1f138 → 047cac280736): 1 failed | 35 passed. The red run printed the misattribution the precondition exists to stop: "column 'amount' declares no type, so it renders as a currency column … Unrecognized key(s) on this inline grid column: zzz_not_a_key".
    • A4 CHILD_COLLECTION_KEYS back to subforms only (3efd1236571e → 7b8b0ab03798): 3 failed | 66 passed (69). Those are the authored column, amountField, and the derived grid.
    • A5 the doc's old bare-string columns restored (b6df7f7748e0 → 2aef1e440273): check:yaml-examples exit 1, "1 of 19 tagged YAML block(s) do not hold their claim … expected object, received string" at layout-dsl.mdx:774.

Acceptance notes

  • Two enforcement tiers on one carrier. The entry and column half is read where every page component's props are read: the component-props gate, advisory by its own declared posture (validate-component-props.ts header). So the card's typed and bogus columns move from silent green to valid: true with advisory findings. The identity-only column is a hard cross-reference error, like the inline-action targets already judged on the same pages. Upgrading the props gate is that gate's own step.
  • Save door: a page saved through the metadata door keeps a failing detail entry, because properties is not parsed there. This is the same posture as every page-component narrowing in step 18. NOT MEASURED at the door.
  • Scan, adjacent position: field-no-consumers still reads a detail entry's sortField and formFields in the parent's context, through the general walk. Not changed here. finding(lint): field-no-consumers still calls two in-use child-context fields "inert" — a lookup's inline-grid join key, and the fields an inline grid's per-row expand form draws (the family's closeout after #20951) #21091's positions are not touched.
  • Files beyond the claim's list: layout-dsl.mdx (a producer the census found, which check:yaml-examples reds on, see A5), type-alias-convention.pin.test.ts and dropped-refinements.baseline.json (both forced by referencing the column schema). conversions/registry.ts and the showcase page are untouched, because nothing needed converting or rewriting.
  • The D3 entry inline-grid-column-identity-only-currency-scale-refused still names its two carriers. The new entry records the third rather than editing a step-18 text that other open PRs hold.
  • origin/main moved after the merge (e18fea6d). Its only spec file is data/filter.zod.ts, which this diff does not touch. Merge-queue revalidation covers it.
  • objectui's MasterDetailForm mirror and its spec-parity probes over this row are not in this PR. The seat files the objectui coordination child.

Generated by Claude Code

claude added 5 commits October 1, 2026 15:27
…a strict shape whose columns are the inline grid column contract (WIP)

ComponentPropsMap['object-master-detail-form'].details becomes a strict
entry of the twelve keys objectui's MasterDetailForm reads, with columns
referencing InlineGridColumnSchema; defineStack's identity-only column
check reaches the block on a page; field-no-consumers reads a detail
entry as a child collection. Generated artefacts follow.

Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d
Co-authored-by: Claude <noreply@anthropic.com>
…moves

dropped-refinements baseline gains the one new site the column schema's
refinement drops in ui/ObjectMasterDetailFormProps; api-surface,
export-origins, the component reference page and the strictness ledger
counts regenerated by check:generated --fix.

Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d
Co-authored-by: Claude <noreply@anthropic.com>
…azy body so the alias walk reaches it

A standalone lazySchema element is a function-typed proxy the
alias-integrity graph walk does not descend into; the subforms entry's
inline construction is the shape that walk reaches.

Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d
Co-authored-by: Claude <noreply@anthropic.com>
…l entry read set

origin/main moved `.objectui-sha` to 31971ff1e28f; MasterDetailForm.tsx is
byte-identical at e420df310f5b and 31971ff1e28f, so the twelve-key read
set holds at the pin this branch now builds against. Post-merge
regeneration: gen:migration-registry and check:generated report no drift.

Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation protocol:ui tests tooling labels Oct 1, 2026
@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/spec, touching 15 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/spec/api-surface/ui.json, packages/spec/dropped-refinements.baseline.json, packages/spec/export-origins/ui.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/field-types.mdx (via relationshipField (literal, a string literal in masterDetailDetailEntry))
  • content/docs/data-modeling/fields.mdx (via relationshipField (literal, a string literal in masterDetailDetailEntry))
  • content/docs/deployment/validating-metadata.mdx (via childObject (literal, a string literal in masterDetailDetailEntry), relationshipField (literal, a string literal in masterDetailDetailEntry))
  • content/docs/getting-started/common-patterns.mdx (via relationshipField (literal, a string literal in masterDetailDetailEntry))
  • content/docs/protocol/objectql/types.mdx (via relationshipField (literal, a string literal in masterDetailDetailEntry))
  • content/docs/protocol/objectui/layout-dsl.mdx (via childObject (literal, a string literal in masterDetailDetailEntry), relationshipField (literal, a string literal in masterDetailDetailEntry))
  • content/docs/ui/pages.mdx (via relationshipField (literal, a string literal in masterDetailDetailEntry))
What this run could not see
  • 3 changed file(s) yielded no anchor (packages/spec/api-surface/ui.json, packages/spec/dropped-refinements.baseline.json, packages/spec/export-origins/ui.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 — 137 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 e18fea6dcdf6af5619cae05eb37c38417230d83b → packageMentionDocs.

Which tree this was computed on

This run read content/docs from e7e00637e6f3b0e5f5990cb12a9b923e57e10bdc — the merge of head bfb26d52e9a14b9f3808510e00aa6400f5c7f9fd into base e18fea6dcdf6af5619cae05eb37c38417230d83b, 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 e7e00637e6f3b0e5f5990cb12a9b923e57e10bdc && git checkout e7e00637e6f3b0e5f5990cb12a9b923e57e10bdc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e18fea6dcdf6af5619cae05eb37c38417230d83b bfb26d52e9a14b9f3808510e00aa6400f5c7f9fd && git checkout -B drift-repro e18fea6dcdf6af5619cae05eb37c38417230d83b && git merge --no-ff bfb26d52e9a14b9f3808510e00aa6400f5c7f9fd

node scripts/docs-audit/affected-docs.mjs --json e18fea6dcdf6af5619cae05eb37c38417230d83b

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs e18fea6dcdf6af5619cae05eb37c38417230d83b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

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

Inputs, and nothing else: card #20928 (body, triage 5919655566, claim 5934221277, dev report 5936596153), PR #21215 (body, its 15-file list, the net diff of the branch at the head against origin/main, merge-base 0d421041d), and the head's check-runs. Read-only reads of origin/main and of the pinned objectui at 31971ff1e28f (the .objectui-sha on origin/main and on the merge-base) were taken to judge the claims; nothing was built, run or re-run.

① Derived judgments

  1. ComponentPropsMap['object-master-detail-form'].details element goes from z.unknown() to a strict entry of twelve keys. RIGHT. objectui packages/plugin-form/src/MasterDetailForm.tsx at 31971ff1e28f declares MasterDetailDetailConfig with exactly these twelve members (lines 64 to 93), each with a live d.X read; the other member reads in the file (detail.saving, detail.add, d.input) are an i18n key pair and a batch-input map, not config keys. inlineMode as grid | form equals the renderer's InlineMode (deriveMasterDetail.ts:423). title and addLabel as plain strings match the renderer (a React child at :755, a button label at :865) and the subforms entry. childObject required matches the renderer's declined branch (:986).
  2. The keys shared with the form view's subforms[] entry take that entry's types and alias table. RIGHT, byte-equal against view.zod.ts on origin/main: the ten-alias table and the types of childObject, relationshipField, columns, amountField, totalField, title, addLabel, minRows, maxRows. The three added keys (formFields, inlineMode, sortField) are renderer-read. No alias collides with a declared key.
  3. columns references InlineGridColumnSchema by object identity, not a copy. RIGHT, and pinned by the toBe case. Rulings B ([finding] FieldSchema.scale on a currency field is offered to authors by the field designer, ignored by every display face, and still enforced on writes — half-live in the direction that surprises #19629) and 乙 ([Decision] Ruling 5791803339 points currency authors at currencyConfig.precision — but nothing reads that key, and removing scale leaves two console faces rendering currency with zero decimals until objectui#10221 lands #19910) reach this carrier through the column schema's own refinement; no second scale rule was written.
  4. Accept-set effect: a narrowing on a published export (ObjectMasterDetailFormPropsSchema, ui namespace, listed in api-surface/ui.json). Refused now: an entry without childObject, an unknown entry key, a bare-string column, a field-keyed column, an unknown column key, scale on a column declaring type: 'currency'. The named producer's entry { title, childObject, addLabel } parses unchanged. A bare-string column gets zod's own expected object, received string (no alias is possible for a string); the changeset's FROM → TO table and the D3 replacement text carry that prescription. RIGHT.
  5. Public surface: one additive type export, ObjectMasterDetailFormPropsParsed (api-surface/ui.json and export-origins/ui.json regenerated; the isomorphism pin count 779 → 778 by the pin file's own prescribed route). RIGHT: input and infer differ because the column's readonlyWhen / requiredWhen bare-string arm transforms to an envelope.
  6. stack.zod.ts: the page-component traversal is extracted into forEachPageComponent. RIGHT: the regions, slots and nested-array walk and its path spellings are byte-identical to the former collectInlinePageActions body, and the inline-action collector now reuses it.
  7. The new precondition inside judge (a column is judged only when InlineGridColumnSchema.safeParse(column) succeeds without the type). RIGHT on all three carriers. validateCrossReferences receives the parsed data, so on the two parsed carriers the column is parse output, and EvaluatedExpressionInputSchema is a union whose envelope arm accepts its own output, so the re-parse is stable and the guard is a no-op there. On the raw page carrier it stops a column's own defects from being reported as currency findings (ablation A3 printed exactly that misattribution). Reach is unchanged: a declared type is skipped, an undeclared child object or field is skipped.
  8. Page-carrier reach: config.pages only, every region, slot and nested container array, the same reach the inline-action cross-reference check already has. RIGHT and consistent.
  9. validate-field-consumers.ts: CHILD_COLLECTION_KEYS gains details. RIGHT. The walk treats every array element under a details key as a child entry. The only other details arrays in stack metadata are the page slot map's (components, which carry none of an entry's keys at their own level, pinned by the control case) and API error payloads the scan never walks. totalField stays parent-scoped through the general walk and is pinned so.
  10. ADR-0087: D3 entry ui-object-master-detail-form-details-closed plus a step-18 rationale fragment at order 56 (unique among the fragments; the (#20928) citation inside a rationale text follows ten sibling fragments that cite the same way). No D2 conversion. RIGHT: PageComponentSchema.properties is z.record(z.string(), z.unknown()), and outside spec and lint tests the only ComponentPropsMap consumer is the dogfood expression-conformance ledger, so nothing on a save or load door parses component props and nothing at rest is refused.
  11. dropped-refinements.baseline.json gains one site, details.element.columns.element, the exact analogue of ui/FormView's subforms.element.columns.element; the hand-edited ledger grows because build-schemas refuses an undeclared site. RIGHT.
  12. Generated artefacts (references/ui/component.mdx, the strictness ledger counts with one more strict site in component.zod.ts) and the layout-dsl.mdx producer rewrite (the renderer's hydrateColumns cannot draw a string column). RIGHT.
  13. Governed surfaces: none in the file list; the record is owed by the Clause-②: yes and packages/spec/src/** limbs, not by tier. Head repo equals base repo; 805 changed lines.

② Semver level

.changeset/20928-master-detail-details-closed.md: @objectstack/spec: minor, @objectstack/lint: patch; Clause-②: yes (narrowing); a BREAKING banner; exactly one adr-0087 disposition marker, registered ui-object-master-detail-form-details-closed, in the comment form the gate reads; a FROM → TO table with the one-line fix; the census on ebdb6f2aca. RIGHT, each clause:

  • yes takes at least minor: yes. (narrowing) is BREAKING and carries its migration: yes. minor for a breaking accept-set narrowing is the repo's launch-window convention (fourteen sibling changesets on main say so; check-changeset-no-major refuses major).
  • The additive ObjectMasterDetailFormPropsParsed export is covered by minor.
  • @objectstack/lint: patch: a false-positive fix in field-no-consumers, no export or option change.
  • No tombstone owed: nothing declared was removed, the carrier was z.unknown(); retiredKey() does not apply.
  • Declaration consistency: claim 5934221277 declares Clause-②: yes, the PR body repeats it verbatim, the changeset adds the (narrowing) arm where the registration gate reads it. One arm at most, consistent. skip-changeset correctly not applied (spec and lint publish). Check Changeset on the head: success.

③ Boundary flags

Dev open_questions: none. Each deviation and out-of-scope finding in report 5936596153:

  • D1 Files beyond the claim's surface (layout-dsl.mdx, type-alias-convention.pin.test.ts, dropped-refinements.baseline.json): ANSWERED, forced by the diff (ablation A5 red, the Iso pin route, the build-schemas refusal); no scope expansion. Claim-listed files left untouched (conversions/registry.ts, the showcase page): ANSWERED, correct, no D2 is owed and the showcase entry is inside the twelve keys.
  • D2 forEachPageComponent extraction: ANSWERED, verified unchanged (①6).
  • D3 Entry built by a factory inside the parent's lazy body rather than a standalone lazySchema: ANSWERED, acceptable; the alias-integrity walk reaches it, and the subforms entry is built the same way.
  • D4 PR body Clause-②: yes with no arm while the changeset carries (narrowing): ANSWERED in ②.
  • D5 Pin citation repointed to 31971ff1e28f: ANSWERED; it equals .objectui-sha on origin/main and on the merge-base, and every cited line resolves there.
  • D6 origin/main moved after the merge (e18fea6d; its only spec file is data/filter.zod.ts, untouched here): ANSWERED; the queue rebuilds on current main.
  • D7 Commit trailers in the model-free pair: ANSWERED, correct per AGENTS.md.
  • F1 The objectui coordination child (the MasterDetailForm mirror, and apps/console/src/__tests__/registry-inputs-spec-parity.test.ts row object-master-detail-form.details, whose prose and probes state the spec row is z.array(z.unknown())): ESCALATED to the seat as its owed act; the triage and the claim both assign that filing to the seat, not to this PR. Not a landing blocker here: this is a narrowing, not a removal; at the pin the only non-test importer takes the schema by reference through stripImportedDefaults, whose walker handles lazy, object, array, union and pipe nodes and falls through on the rest; and Console Pin Gate builds the pinned console, it does not run objectui's suites. Those suites against this spec stay NOT MEASURED, as the dev states.
  • F2 field-no-consumers still reads a detail entry's sortField and formFields in the parent's context (CHILD_ENTRY_FIELD_KEYS is amountField and relationshipField only): ESCALATED. Two child-field keys credited against the wrong object is a real gap on this carrier, reach unmeasured; it belongs on finding(lint): field-no-consumers still calls two in-use child-context fields "inert" — a lookup's inline-grid join key, and the fields an inline grid's per-row expand form draws (the family's closeout after #20951) #21091 (the named family carrier, same file) as a comment, not only in this PR's acceptance notes.
  • F3 Two enforcement tiers (advisory component-props findings for the entry and column half, a hard STACK_CROSS_REFERENCE_INVALID for the identity-only half; the save door does not parse props): ANSWERED; declared in the changeset, the D3 entry and the acceptance notes, so the claim is as narrow as the enforcement (Prime Directive chore: version packages #10).

Check-runs on the head, read 2026-10-01T17:27Z (the fourth read of this round; the first was 2026-10-01T17:16Z): 28 success, 2 skipped (Console Pin Gate and Packed-tarball smoke (opt-in), both paths-filtered), 3 still in_progress: Test Core (3/6), Test Core (5/6), Test Core (6/6); 1 queued: TypeScript Type Check, the required aggregate, which started once its four Type Check · legs went green. Green so far: Lint & Repo Gates, Type Check · source gates, Type Check · consumer gates, Type Check · debt ledger, Type Check · workspace, Build Core, Dogfood Regression Gate and its three shards, Dogfood Verify CLI, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard, Check Changeset, Spec property liveness, Test Core (1/6), Test Core (2/6), Test Core (4/6). No check-run on the head has failed. A running gate is not a pass: landing waits for the four to conclude green (the Test Core aggregate context among them).

Note, not verdict-bearing: the block-level docblock on ObjectMasterDetailFormPropsSchema still cites the renderer at eb7f586b beside the entry's 31971ff1e28f citation; pre-existing text, not this diff's.

Implemented-by: claude/issue-20928-master-detail-details-strict
Reviewed-by: session_01UtnxvdiN376GF3sgXwAw4d

VERDICT: PASS

Recorded 2026-10-01T17:28Z.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 1, 2026 17:33
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit a29a0ea Oct 1, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20928-master-detail-details-strict branch October 1, 2026 18:03
os-project-manager pushed a commit to objectstack-ai/objectui that referenced this pull request Oct 5, 2026
… is the spec's closed entry, by reference (objectui#11396)

`@objectstack/spec` 17.6.0 judges `ComponentPropsMap['object-master-detail-form'].details`
as an array of closed entries (objectstack-ai/objectstack#21215): `childObject` required,
`columns` the spec's inline grid column, an undeclared key refused by name.
`@object-ui/types` already took the whole props row by reference, so both published
zod faces give the spec's verdict on an entry; the TypeScript face in
`@object-ui/plugin-form`, `MasterDetailDetailConfig`, restated the entry by hand.

- `MasterDetailDetailConfig` is derived from the spec's entry type (its `z.input`
  authoring face) by a type-only import, minus the one member the renderer does
  not read: the spec still declares `sortField`, which objectui#11070 round 9
  retired here; the omission is stated on the type and pinned, and the spec-side
  retirement stays objectstack's half. Member for member the type is the shape
  it was, so the published accept set does not move (empty-frontmatter changeset).
- The `object-master-detail-form` registration declares `of: 'object'` for
  `details`, derived from the now single-kind member contract, and a description.
- `masterDetailDetailsMembers-8071.test.tsx` holds the config `Equal` to the spec
  entry minus `sortField`, pins that the spec still declares `sortField`, and
  that a `columns` entry is the grid's `GridColumn`; its header no longer says
  the spec row is `z.array(z.unknown())`.
- New `packages/types` pin: both faces refuse an undeclared entry key, a missing
  `childObject`, a bare-string column and an undeclared column key, accept
  `{ childObject }` and `sortField`, with one verdict across the two doors.
- The console parity row for `object-master-detail-form.details` describes the
  closed entry, the derivation and the `of`.

Claude-Session: https://claude.ai/code/session_01CPvhwGcirXqBGEdPSb72TZ
Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Oct 7, 2026
… is the spec's closed entry, by reference (objectui#11396) (objectstack-ai#11624)

Fixes objectstack-ai#11396
Clause-②: no — the published `MasterDetailDetailConfig` keeps its accept
set: the derived type is `Equal` to the hand-written interface it
replaces (a one-off compile below proves the FROM and TO shapes
identical, member for member), so only the type's definition moves, and
the changeset is the empty-frontmatter declaration.

`@objectstack/spec` 17.6.0 judges
`ComponentPropsMap['object-master-detail-form'].details` as an array of
CLOSED entries (objectstack-ai/objectstack#21215, `a29a0ea555`):
`childObject` required, `columns` the spec's inline grid column, an
undeclared key refused by name. `@object-ui/types` already took the
whole props row by reference, so both published zod faces give the
spec's verdict on an entry. The TypeScript face in
`@object-ui/plugin-form`, `MasterDetailDetailConfig`, restated the entry
by hand, and the console parity row still said the spec row is
`z.array(z.unknown())`. This PR makes the TypeScript face follow the
spec entry by reference, makes the prose true, and pins both.

Dispatched under claim `5982160146` (`domain:spec`, size S; a
re-dispatch of the same claim). Session:
`https://claude.ai/code/session_01CPvhwGcirXqBGEdPSb72TZ`.

## What changes

- `packages/plugin-form/src/MasterDetailForm.tsx`:
`MasterDetailDetailConfig` was an `interface` with eleven hand-written
members. It is now a type DERIVED from the spec's entry — the element
type of `ObjectMasterDetailFormProps['details']`, which is the spec's
`z.input` authoring face (what a document writes), imported type-only
from `@objectstack/spec/ui` — with ONE member omitted by name,
`sortField` (the fork, below). The docblock states the fork and where
each member lands in the renderer. The two imports only the interface
used (`GridColumn` from `@object-ui/fields`, `InlineMode` from
`./deriveMasterDetail`) are gone from this file; every other line is
unchanged. The `amountField` → grid `totalField` hand-off is untouched
(the same six-key grid object, pinned by row 2 of the members test).
- `packages/plugin-form/src/index.tsx`: the `details` registration goes
from `{ name, type: 'array', required: true }` to the same plus `of:
'object'` and a description. ⚠️ This file is outside the claim's file
surface; it is here because the dispatch asked for the `of` decision and
the declaration lives on the registration, not on the parity row. The
`required: true` is not touched (Acceptance note 1).
-
`packages/plugin-form/src/__tests__/masterDetailDetailsMembers-8071.test.tsx`:
the header no longer says the spec row is `z.array(z.unknown())`; the
compile-time block gains two rows — `derivedFromSpec` (the config
`Equal`s the spec entry with `sortField` omitted, on 17.6.0 and on
objectstack `main` alike: the `Omit` subtracts the key whether the entry
declares it as a string or as a `retiredKey()` tombstone) and
`columnsAreTheGridColumn` (a `columns` entry `Equal`s `GridColumn`) —
and keeps `memberList` (the eleven keys spelled out) and the
`@ts-expect-error` row on a written `sortField`. Patch round 1
(`999aecb6`) dropped a third row, `specStillDeclaresSortField`, which
pinned the 17.6.0 shape and was red against objectstack `main` in the
`Spec Main Shape Gate`. The runtime rows are unchanged.
-
`packages/types/src/__tests__/object-master-detail-form-details-entry-11396.test.ts`
(new; 7 `it` blocks, 19 tests: six rows on each of two faces, six
cross-door rows, one non-vacuity row): a type-level row that the block's
`properties.details` entry `Equal`s the spec's entry; on the tolerant
face (`safeValidateSchema`, which `objectui validate` runs) and on the
strict authoring face, for a node in the spec's `{ type, properties }`
form: `{ childObject }` accepted (control); an undeclared entry key
refused at `properties.details.0` with one `unrecognized_keys` naming
it; no `childObject` refused with `invalid_type` at that member; a
bare-string column refused at `properties.details.0.columns.0`; a column
with an undeclared key refused there naming it; `sortField` ACCEPTED
(the fork, recorded so that closing it is a deliberate flip). Plus one
verdict across the two doors: for each probe, `safeValidateSchema` and
the spec's own props row agree on success and on the issue codes at the
same paths under `properties`.
- `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`: the
`object-master-detail-form.details` member-pin prose. Every true
sentence is kept, including the camelCase grid sentence objectui#11610
wrote; the last two sentences (the `z.array(z.unknown())` row, the
five-of-eleven count, "declares no `of`") are replaced by the closed
entry, the derivation, the fork and the `of`.
- `.changeset/11396-master-detail-details-entry.md`: empty frontmatter,
with a body saying why nothing is released.

## Premise readings (this worktree, base `9dfaca65`, which is
`origin/main` at PR time)

- After `pnpm install`, `packages/types` and `packages/plugin-form` both
resolve `@objectstack/spec` at `17.6.0`; both declare `^17.6.0`.
- `ObjectMasterDetailFormPropsSchema.shape.details`, read off the
installed schema: an OPTIONAL array of a `z.object` whose catchall is
`never`. The entry's keys, in the schema's own order: `childObject`
(string, required), then `relationshipField`, `columns`, `formFields`,
`inlineMode`, `amountField`, `sortField`, `totalField`, `title`,
`minRows`, `maxRows`, `addLabel`, each optional. `{}` is refused with
`invalid_type` at `childObject`; `{ childObject: 'x', bogus: 1 }` is
refused with `unrecognized_keys` naming `bogus`.
- `columns`: an array of a strict object whose key set equals that of
`InlineGridColumnSchema` from `@objectstack/spec/data`, and whose
`z.input` type `Equal`s `GridColumn` (which IS that `InlineGridColumn`),
judged by `tsc`. ⚠️ Runtime identity does not hold across the published
entry points: the entry's column element is not `===`
`InlineGridColumnSchema` as `@objectstack/spec/data` exports it, and
`@objectstack/spec/ui`'s own `InlineGridColumnSchema` export is not
`===` the `data` one either — the `ui` bundle carries its own instance.
The card's "by identity" is a statement about objectstack's source.
Nothing here depends on identity (Acceptance note 2).
- `sortField` is NOT retired at 17.6.0: `{ childObject: 'x', sortField:
'a' }` parses with success and the value kept. objectstack `main` has
retired it since — a `retiredKey()` tombstone on the entry
(objectstack-ai/objectstack#21589, PR objectstack-ai/objectstack#21632,
`6ec54f00`): `git merge-base --is-ancestor` answers 0 for that commit
against objectstack `origin/main` and 1 against the
`@objectstack/spec@17.6.0` tag, with `a29a0ea555` as the control
answering 0 on the same tag — so the retirement is unreleased after
17.6.0. The fork therefore stands on the installed spec; the runtime pin
records it, and the compile-time rows hold on both shapes.
- `inlineMode` is the enum `grid | form`, the same two values
`InlineMode` declares; `formFields` is `string[]`; `title` and
`addLabel` are plain strings (not `I18nLabel`); `minRows` / `maxRows`
are numbers. So the derived members are the hand-written members' types.
- `ObjectMasterDetailFormPropsParsed` is exported alongside
`ObjectMasterDetailFormProps` (the `z.input` face). The authoring face
is the one callers write, so it is the one derived from; the parsed face
differs only inside a column (`readonlyWhen` / `requiredWhen` normalised
to Expression envelopes).
- `packages/types` needed no edit: `ObjectMasterDetailFormBlockSchema`'s
`properties` is `propsBag(...
stripImportedDefaults(SpecObjectMasterDetailFormPropsSchema))`, and the
new pin measures a bogus entry key refused on both faces with the spec's
own code at the spec's own path.

## The fork: `sortField`, reported and not absorbed

The spec entry declares `sortField`; `MasterDetailForm` reads no such
member (objectui#11070 round 9 retired the authored override — the
line-position field is DERIVED from the child object by `deriveDetail` —
and row 2c of the members test pins that a written one reaches nothing);
`MasterDetailDetailConfig` leaves it off; `objectui validate` ACCEPTS
it, because `@object-ui/types` takes the row by reference. Per the seat
ruling this is reported with its measurement, not widened or narrowed
here:

- widening the TypeScript face to the full spec entry would declare, on
the authoring face, a key the runtime does not honour;
- narrowing the zod face would be a consumer-side departure from the
spec row the mirror exists to take by reference.

The spec-side retirement has landed on objectstack `main`
(objectstack-ai/objectstack#21589, PR objectstack-ai/objectstack#21632,
`6ec54f00`) and is unreleased after 17.6.0 (the card's section 3
recorded it as owed on objectstack-ai/objectstack#6017 before it
landed). The fork closes at objectui's bump to the first
`@objectstack/spec` release that carries it. One row reds at that bump
and says what to do: the `sortField` ACCEPTED rows in the new types pin.
It is a RUNTIME row on purpose: the `Spec Main Shape Gate` compiles this
repository against objectstack `main`, so a compile-time row pinning the
17.6.0 shape is red there today — which is what patch round 1 corrected.

## The `of` decision

The parity file's own rule (`member declarations are derived from
single-kind member contracts`): a key is declared with `of` when the
contract accepts exactly ONE coarse member kind in its member position,
and left alone when it accepts several. On 17.5.0 the `details` member
position accepted every kind (`z.unknown()`), so no `of` was derivable.
On 17.6.0 it accepts `object` alone — the string, number, boolean and
array probes are refused with `invalid_type` at the entry — so `of:
'object'` is derivable, and the registration now declares it. The gate's
member direction witnesses it against the spec row (the
`object-master-detail-form.details:of=object` judgement is `witnessed`,
and the single-kind row reads `contract accepts {object}`; the parity
run below is green with the declaration in place). Precedent:
`record:details.sections` declares `of: 'object'` for its strict spec
entry. `object-kanban.columns` does not and is not this card.

## FROM → TO, proven once

A temporary test file (archived in the dispatch scratchpad, deleted
before the commit) copied the HEAD interface byte for byte as
`OldMasterDetailDetailConfig` and asserted `Equal` of OLD and NEW `=
true`, beside a non-vacuity row asserting `Equal` of OLD and
NEW-minus-`title` is `false`. `pnpm --filter @object-ui/plugin-form run
type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`) exited 0 with
that file in the test program (`tsc -p tsconfig.test.json --listFiles`
names `masterDetailDetailsMembers-8071.test.tsx`, `MasterDetailForm.tsx`
and the spec's `dist/ui/index.d.ts`). That is the whole of the
`Clause-②: no`.

## Tests and the ablation

- `pnpm exec vitest run packages/plugin-form/
apps/console/src/__tests__/registry-inputs-spec-parity.test.ts
--maxWorkers=2` (under `os-verify-lock`): `Test Files 166 passed (166)`,
`Tests 2190 passed | 1 skipped (2191)`, `VERDICT command-exit 0`.
- `pnpm exec vitest run packages/types/ --maxWorkers=2` (under the
lock): `Test Files 358 passed (358)`, `Tests 9519 passed (9519)`,
`VERDICT command-exit 0`.
- The three touched test files by name (under the lock): `Test Files 3
passed (3)`, `Tests 283 passed (283)`.
- `pnpm --filter @object-ui/plugin-form run type-check` (both programs):
`VERDICT command-exit 0`. `pnpm --filter @object-ui/console run
type-check` (`tsc --noEmit && tsc -b tsconfig.node.json --force`, after
building the console's dependency closure with pnpm): `VERDICT
command-exit 0`. The first console run was refused on an unbuilt
`@object-ui/plugin-tree` (`TS2882`, outside the doc-snippets closure);
building `plugin-tree` and `test-support` and rerunning gave the green
above.
- **Ablation** (the fix committed first, as `a4ac1f38`; objectstack's
`scripts/ablation-replace.mjs` in WRAP mode over `MasterDetailForm.tsx`,
child command `tsc -p tsconfig.test.json --noEmit`):
- A first attempt was a NO-OP by the tool's own evidence and is reported
as such: anchor `'sortField'` with replacement `'sortField' |
'addLabel'` left the anchor count at 1 → 1, so the tool refused the
mutation before running the child and restored (blob == HEAD). No
reading was taken from it.
- The real leg: anchor `'sortField'` → replacement `'addLabel'` (the
derivation now omits `addLabel` instead). `anchor 1 -> 0`, `blob
2fa79a3 -> 165e0f7923a5`, mutation proven on disk. The child went
RED with five errors: the `derivedFromSpec` row and the `memberList` row
(`Type 'true' is not assignable to type 'false'`), the
`retiredSortField` row (`Unused '@ts-expect-error' directive`), and two
renderer reads of `d.addLabel` in the grid hand-off (`Property
'addLabel' does not exist on type 'MasterDetailDetailConfig'`). Restore:
`blob after restore 2fa79a3`, `blob at HEAD 2fa79a3`, `git
diff HEAD empty`, `git status` clean. The rows and the renderer both
hang off the derivation.
- Resolution path: the test program compiles `../MasterDetailForm` from
source and reads the spec from its installed `.d.ts`; no `dist/` of this
package is in the path, so no dist preflight applies.

## Gate readings, at head `a4ac1f38`

- `eslint --no-inline-config --format json` over the five touched
TypeScript files: 5 files, 0 errors. Warnings: 0 on the parity file and
on the new pin; on `MasterDetailForm.tsx`, the members test and
`index.tsx` every warning is `no-explicit-any` on a pre-existing `any`
line, `react-hooks/exhaustive-deps` or
`react-refresh/only-export-components` on lines this diff does not
touch.
- This is a declared narrowing of `pnpm lint`. Population:
`eslint.config.js` lints `**/*.{ts,tsx}`; the changeset `.md` is outside
it. File count: 5, from the JSON output. Invariance: the config sets no
`parserOptions.project` and no `projectService`, so type-aware linting
is off and this diff cannot move the verdict on any untouched file. The
full run belongs to CI.
- `check:doc-snippets`: `Semantic phase: 777 of 777 block(s) judged, 0
failed`.
- `check:new-line-citations`: `0 new citation(s)`.
`check:control-bytes`: OK.
- Changeset checkers, exit 0 each: `changeset:check` (fixed and
no-major), `check:changeset-claims`, `check:pending-changeset-literals`,
`check-changeset-presence` ("Every one of them has an EMPTY frontmatter
— declared as releasing nothing"), `check-changeset-overwrite`.
- Exit 0 each: `check:spec-symbols`, `check:spec-floors` (first refused
`no-artifact` on the unbuilt `plugin-tree`; green after the build
above), `check:installed-pin-claims`, `check:phantom-deps`,
`check:unused-deps`, `check:readme-exports`, `check:test-path-roots`,
`check:component-surface-parity`, `check:doc-types`.
- `check-governed-queue-guard --test` over the six paths: NOT GOVERNED.
- NOT MEASURED, left to CI: the full `pnpm test`, the tree-wide `pnpm
lint`, `check:published-dist`.

## Acceptance notes

1. **`details` is optional on the spec row and required on the
registration and the TypeScript face.** The spec's `details` is
`z.ZodOptional`; the registration declares `required: true` (kept as it
was), `MasterDetailFormSchema.details` is required, and the renderer
itself tolerates absence (`schema.details || []`). objectui#11605
records the mechanism by which a `required: true` the spec row does not
carry refuses a node at the page compile ("its only requiredness check
is `input.required && !(input.name in node)`"); that card's rule is
about binding-supplied keys, which this one is not. Not measured through
the page compile here, and not this card. Carrier: none.
2. **Two instances of `InlineGridColumnSchema` across
`@objectstack/spec`'s published entry points** (Premise readings). Any
objectui pin that asserts cross-entry-point identity would be false;
none does today, and this PR adds none. Carrier: none.
3. **The registration's new description** is authoring help for the
manifest and the designer; the member SHAPE is carried by `of` and by
the pins, not by that prose. It names the two things the spec's
description cannot tell an author: `amountField` is the child column
summed and `totalField` the parent field it is saved to, and `sortField`
is read by nothing here.

## Patch round 1 — `999aecb6` (seat ruling `5986267745`)

The `Spec Main Shape Gate` was red on `a4ac1f38` with one attributed
diagnostic: `masterDetailDetailsMembers-8071.test.tsx`, TS2322, compiled
against `@objectstack/spec` built from objectstack `ebfe658c7241`.
Confirmed: objectstack `main` carries `sortField:
retiredKey(MASTER_DETAIL_DETAIL_SORT_FIELD_RETIRED)` on the detail entry
(`retiredKey()` is `z.never().optional()`), from
objectstack-ai/objectstack#21589 / PR #21632 / `6ec54f00`, which is an
ancestor of `origin/main` and not of the `@objectstack/spec@17.6.0` tag
(control: `a29a0ea555` is an ancestor of the tag). The row
`specStillDeclaresSortField` pinned the 17.6.0 shape and failed there.

- Rows: `specStillDeclaresSortField` dropped. `derivedFromSpec`,
`columnsAreTheGridColumn`, `memberList` and the `@ts-expect-error` row
stay, and hold on both shapes (measured below). The forward tripwire is
the runtime `sortField` ACCEPTED pin in `packages/types`, renamed to say
so; it flips at the bump.
- Sentences: the `MasterDetailForm.tsx` docblock, the members-test
header, the types-pin header, the registration description, the console
parity row and the changeset body now say the retirement landed on
objectstack `main`, is unreleased after 17.6.0, and that the fork closes
at objectui's bump to the first release carrying it.
- Local reproduction of the gate, with the spec built from objectstack
`main` `e83c9f615441` in a scratch worktree (packed with `npm pack`,
injected with `scripts/spec-main-shape-gate.mjs inject`; `no dependency
substitution`), `TURBO_FORCE=true` and a scratch `--cache-dir`, under
`os-verify-lock`:
- RED (declared narrowing: turbo filtered to `@object-ui/plugin-form`,
the attributed test file checked out from `a4ac1f38` while HEAD held the
fix): `turbo run type-check --filter=@object-ui/plugin-form
--concurrency=2 --continue` exited 2; the gate's `report` printed `1
diagnostic(s) in 1 objectui file(s)` — the members test, TS2322 `Type
'true' is not assignable to type 'false'`. Restore proven: blob == HEAD,
`git diff HEAD` empty.
- GREEN (the gate's full step, on `999aecb6`): `pnpm type-check
--continue --concurrency=2` — `Tasks: 81 successful, 81 total`, `Cached:
0 cached`, 9m26s; `report`: `✅ objectui type-checks against
@objectstack/spec at that commit`.
- Back on 17.6.0 (reinstalled; `0` inject markers in the store):
plugin-form closure rebuilt, `pnpm --filter @object-ui/plugin-form run
type-check` exit 0, the three touched test files `3 passed`, `283`
tests, `pnpm --filter @object-ui/console run type-check` exit 0.
- `Clause-②: no` re-measured on `999aecb6`: the pre-PR interface at
`9dfaca65`, byte-copied, `Equal`s the derived type (`tsc -p
tsconfig.test.json` exit 0, temp file deleted before push).
- Ablation redone on `999aecb6` (anchor `'sortField'` → `'addLabel'`):
mutation landed (`anchor 1 -> 0`, blob changed), five errors —
`derivedFromSpec`, `memberList`, the unused `@ts-expect-error`, two
renderer reads of `d.addLabel` — restore proven (blob == HEAD, `git diff
HEAD` empty).
- Gates on `999aecb6`, exit 0 each: eslint over the five touched
TypeScript files (0 errors), `check:installed-pin-claims`,
`check:spec-symbols`, `check:control-bytes`, `check:new-line-citations`
(0 new), `changeset:check`, `check:changeset-claims`,
`check:pending-changeset-literals`, `check-changeset-presence`,
`check-changeset-overwrite`.

Serial: objectui#11170 was dispatched in this lane during this round and
shares no file with this PR. `origin/main` has not moved since this
branch was fast-forwarded to `9dfaca65`, so the merge before pushing was
a no-op in both rounds.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01CPvhwGcirXqBGEdPSb72TZ)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…n key, its per-row expand form, a detail entry's formFields and a record:line_items block against the child (objectstack-ai#21091) (objectstack-ai#21256)

Fixes objectstack-ai#21091
Clause-②: yes

## What changes

`field-no-consumers` (`packages/lint/src/validate-field-consumers.ts`)
called several kinds of in-use child field "inert". This PR corrects
them. The per-row expand form goes through a new derivation the spec
owns, as `deriveInlineGridColumns` (PR objectstack-ai#21089) did for the grid.

1. **Position 1: a `lookup`'s inline-grid join key.** A `lookup` or
`master_detail` field that sets `inlineEdit` (with a resolvable
`reference`) is now recorded as a behaviour read at its `inlineEdit`,
whether the grid's columns are authored or derived. The renderer loads
the child rows filtered on it and stamps it on save (objectui
`MasterDetailForm.tsx` 1321 and 552, at the `.objectui-sha` pin
`31971ff1e28f`). `master_detail` was already exempt; `lookup` now reads
the same.
2. **Position 2: the derived per-row expand form.** Two new
`@objectstack/spec/data` exports live in
`packages/spec/src/data/inline-grid-columns.ts`. They sit in the same
module as `deriveInlineGridColumns` because they share its system-name
and sort-name sets.
- `deriveInlineRowFormFields(def, { relationshipField?, exclude? }):
string[]` is objectui's `deriveFormFields` stated as the spec's rule. It
skips the same names as the grid, plus the relationship field,
`exclude`, `system` / `hidden` fields and the computed types (`formula`,
`summary`, `rollup`, `autonumber`, `auto_number`). It keeps `readonly`
fields and every type a cell cannot edit.
- `isInlineRowFormOffered({ inlineMode?, formFields?, columns? }):
boolean` is the renderer's offer condition at
`MasterDetailForm.tsx:847`: `inlineMode === 'form'`, or more form fields
than grid columns.
- The lint credits the derived row form wherever it credits the derived
grid: an inline relationship field with no authored `inlineColumns`, or
a `subforms` / `details` entry with no `columns`. A `details` entry is
excluded when it authors `formFields`, because an authored list replaces
the derived one. No copy of objectui's rule lives in the lint.
3. **Position 3 (pointer `5936875973`): a detail entry's authored
`formFields`.** These names are read against the entry's `childObject`;
the general walk no longer reads them against the parent.
`isInlineRowFormOffered` decides whether the list is drawn, and a list
the form is never offered for is a carrier. The renderer resolves an
entry one of two ways, and the lint feeds the predicate what each way
feeds the expand control (round 2, F1):
- **Kept as authored:** the entry names both `relationshipField` and at
least one column (`MasterDetailForm.tsx` 967, 1048–1052). Nothing is
derived. The form factor is the declared `inlineMode`, or none at all,
so the predicate decides exactly. With an omitted `inlineMode`, the form
is offered only when the list is longer than the grid.
- **Derived:** anything else (1055–1066). A declared `inlineMode` is
kept. An omitted one is resolved from the relationship's `inlineEdit`,
else from the child's shape. The lint does not reproduce that
resolution, so with an omitted mode the list is credited as drawn. With
a declared mode, the predicate decides whenever the grid can be counted.
4. **Position 4 (pointer `5940763140`): a `record:line_items` block.**
Its raw `properties` are read as one child entry: authored
`columns[].name`, `relationshipField` and `amountField` against
`childObject`, with `totalField` left on the parent. objectui
`LineItemsPanel.tsx` at the pin reads these keys this way. It derives no
grid and offers no row form. `RecordLineItemsProps` is not imported.
**Round 2, flag B:** the block's `sort` and `filter` are now walked in
the `childObject`'s context. `LineItemsPanel` applies them to the child
query (366–379, 516–521). Since PR objectstack-ai#21244 landed `RecordLineItemsProps`,
the contract declares `filter` as the ViewFilterRule array. The panel's
lowering also takes the field-keyed map, and the lint reads whichever is
authored. Both forms are pinned.

**Fixture triage (round 1).** Six tests in the `[objectstack-ai#20951]` site-2 block
pinned that a derived carrier leaves the `json` and `readonly` child
fields inert. The derived row form now draws them, so their expected
sets were re-judged: `DERIVED` keeps only the `hidden` field, and
`NO_ROW_FORM` keeps the old set for the three cases that draw no derived
row form.

## Round 2: the contract review `5942628181` (FAIL) and what this head
does about it

- **F1, fixed.** The round-1 lint credited an authored `formFields` list
as drawn whenever `inlineMode` was omitted. On the kept-as-authored path
that is false: the renderer leaves the mode undefined, and line 847's
count decides. The lint now decides that path with
`isInlineRowFormOffered({ inlineMode: undefined, formFields, columns
})`. The docblock and test titles state both paths. The test's own
fixture (`relationshipField` and two columns, one form field) now pins
`itm.notes` as `carrier-only`.
- **Flag B, measured and closed.** See position 4. The probe confirmed
it: the three child fields read only by a block's `sort` / `filter` were
inert, and the same-named parent fields were credited in their place. It
is pinned with two enumeration rows (`sort[].field`, `filter[].field`)
and three unit tests.
- **Flag A, measured; not closed on this surface.** Reading below.

### Flag A: a row form opened with no field list

This happens when an authored grid is in the `form` factor and has no
`formFields`. That covers authored `inlineColumns` with `inlineEdit:
'form'`, or with `inlineEdit: true` and a child the smart default sends
to `form`, and a detail entry kept as authored with `inlineMode:
'form'`. The renderer then opens the child's `ObjectForm` with no
`fields` (`MasterDetailForm.tsx` 1821). That form draws the child's
generated field set (`ObjectForm.tsx` 961) through `filterSystemFields`
(`autoLayout.ts` 231): every field except the server-owned names,
`hidden` fields and `readonly` fields, laid out by `fieldGroups` when
the child declares any.

**Probe reading (all three heads below):** `pg_line.note_g`,
`ph_line.body_h`, `ph_line.note_h` and `pi_line.note_i` are reported
inert, and the renderer draws them. `pg_line.ro_g` (`readonly`) and
`pg_line.hid_g` (`hidden`) are reported inert, and the renderer does not
draw them either. The reach is confirmed.

**Why it does not close here:**
1. Crediting it needs a spec-owned statement of the default object
form's field set: `ObjectForm`'s generated set, the server-owned roster
from objectui `sanitize.ts`, the `hidden` and `readonly` filters, and
the `fieldGroups` layout. That is a new cross-repo contract with its own
differential and its own objectui consumer.
2. The `inlineEdit: true` arm also needs the smart default
(`resolveInlineMode`: the form-only types, the two-rich-field threshold
and the eight-field threshold) promoted to the spec.
3. It meets this rule's documented posture. The default layout is never
a site (`creditFieldGroupLayout`: only a KEYED section counts), because
the platform's default form draws every visible field of every object.
The probe's own control `pa_order.buyer` is drawn by `pa_order`'s
default form and reported by design. Crediting the same form when a
parent opens it as a row editor makes the verdict depend on which door
opens it. That is a decision about the rule's contract, not an omission
in this diff.

So the module note and a pinned boundary test state the position: an
authored grid in the `form` factor with no `formFields` keeps those
child fields reported. The enumeration pin's sentence now reads "the
form the spec derives, and an authored `formFields` list the form is
offered for". The position goes to a point card the seat files. The
report carries the options.

## The spec functions against objectui's rule (round 1, unchanged)

The differential ran the spec functions against `deriveFormFields` and
line 847's expression, both read from the pinned files
(`deriveMasterDetail.ts` blob `90aa44c9`, `MasterDetailForm.tsx` blob
`7a96a130`). The offer expression was evaluated from the source text.

- **`deriveInlineRowFormFields`: 100,004 cases, 0 mismatches.** The
cases were objectui's 4 fixtures plus 100,000 random definitions: null
and string field definitions, array-shaped `fields`, non-spec type
names, truthy and falsy flags, prototype-ish names, and random
`relationshipField` / `exclude`.
- **`isInlineRowFormOffered`: 300,012 cases, 0 mismatches.**
- **Subset property: 0 violations.** The derived grid is always a subset
of the derived form.
- **Lit control: 648 of 2,000 mismatches.** The same harness was run
against a function that is not the rule, so the harness can fail.

## Evidence

**The door: `os validate --json` on a `defineStack` probe stack.** Three
heads were measured, each built from source:
- `a7d9768e`, the card's base, in a separate worktree;
- `1d1258a5`, the round-1 head;
- `a87f03e1`, this head.

All three were run with the same probe file (its `filter` blocks in the
rule-array form). All three exit 0 with `valid: true`.
`field-no-consumers` findings: 32, 17, 17.

| field | a7d9768 | 1d1258a | a87f03e | position |
|:--|:--|:--|:--|:--|
| `pa_order_note.order` / `pa_ticket_line.ticket` /
`pb_case_comment.case_ref` / `ph_line.header` (`lookup` + `inlineEdit`)
| inert | — | — | 1 |
| `pb_invoice_line.notes` / `.config` / `.frozen`,
`pb_case_comment.body`, `pb_memo_line.long_note` | inert | — | — | 2 |
| `pc_line.memo` (detail `formFields`, `inlineMode: 'form'`) | inert | —
| — | 3 |
| `pc_header.memo` (parent twin) | — | inert | inert | 3: was credited
in the child's place |
| `pd_line.memo2` (declared `grid`, 1 field vs 2 columns) | inert |
carrier-only | carrier-only | 3 |
| `pf_line.memo_f` (kept as authored, no `inlineMode`, 1 field vs 2
columns) | inert | — | carrier-only | 3, F1 |
| `pe_line.qty_e` / `.note_e` / `.header` / `.amt` (`record:line_items`
columns and keys) | inert | — | — | 4 |
| `pe_header.amt` (parent twin) | — | inert | inert | 4 |
| `pk_line.srt_k` / `.flt_k` / `.flt2_k` (block `sort`, two blocks'
`filter`) | inert | inert | — | 4, flag B |
| `pk_header.srt_k` / `.flt_k` (parent twins) | — | — | inert | 4, flag
B: were credited in the child's place |
| `pg_line.note_g`, `ph_line.body_h` / `.note_h`, `pi_line.note_i`
(default form) | inert | inert | inert | flag A: not credited, see above
|
| `pg_line.ro_g` / `.hid_g` (`readonly` / `hidden`) | inert | inert |
inert | flag A: not drawn either |
| `pc_line.position` (detail `sortField`) | inert | inert | inert | no
lint read; see notes |
| `pa_order.buyer`, `pb_invoice_line.secret`, `pe_line.unused_e`,
`pk_line.unused_k` | inert | inert | inert | controls |

(— means not reported.)

**A real producer: `examples/app-showcase`.** There are 52 findings at
`a7d9768e` and 52 at `a87f03e1`, with identical verdict sets. PR objectstack-ai#21244
changed its `record:line_items` page in between, and that block has no
`sort` or `filter`.

**Tests at `a87f03e1`** (the head of this PR):
- `pnpm --filter @objectstack/lint exec vitest run`: 119 files, 5,572
tests passed. The `validate-field-consumers.test.ts` file has 126 tests,
including the `[objectstack-ai#21091]` block: positions 1 to 4, the flag-A boundary,
and the enumeration pin's 13 rows, each paired with a control.
- `pnpm --filter @objectstack/spec exec vitest run --project local`: 597
files, 17,483 passed and 1 todo.
- `pnpm --filter @objectstack/cli exec vitest run --project unit`: 243
files, 3,439 passed, with the CLI closure built with declarations. The
integration tier is declared to CI.
- `pnpm --filter @objectstack/spec --filter @objectstack/lint run
typecheck`: both exit 0, and `check:test-typecheck` is OK for both.
- Filter direction: `@objectstack/spec`, `@objectstack/lint`, and the
downstream lint consumer `@objectstack/cli`.

**Reverse verification and ablations.** Each was committed first, made
through `scripts/ablation-replace.mjs` or a blob restore, and restored
to the HEAD blob with `git diff HEAD` empty. All were predicted red, and
all were red.
- Round 1: the lint source restored to the base blob `3efd1236` failed
29 of 115 tests. The spec row form made to drop `readonly` failed 2 of
20.
- Round 2, at `a87f03e1`, flag B: the panel's `sort` / `filter` read
switched off failed exactly the 5 flag-B tests (3 tests and 2 pin rows).
- Round 2, at `a87f03e1`, F1: the kept-as-authored decision switched off
failed exactly the F1 carrier test.

**Gates.** `node scripts/pm/dispatch-gates.mjs --commands` derived 8
paths and 86 commands at `a87f03e1`. Every one was run. `--ran` reports
"86 derived, 86 run, 0 NOT-MEASURED, 0 UNRUN", and all 86 exited 0.
`check:generated`: all 15 artefacts are up to date. The two spec shards
gain exactly the two names each.

**Base.** `origin/main` moved under generated files three times and was
merged each time through `scripts/pm/os-regen-merge.sh`: at `1d1258a5`,
`ee505255` and `6084ce01`. The last merge brought PR objectstack-ai#21244's
`RecordLineItemsProps`. No merge owed a regeneration, and the delta
against `origin/main` is exactly this PR's 8 paths. Since then,
`origin/main` has moved by 4 commits, none of which touches a generated
artefact or one of the 8 paths.

## Acceptance notes

- **Exports.** There are two new names, both functions:
`deriveInlineRowFormFields` and `isInlineRowFormOffered`. No schema
accepts or refuses anything new.
- **For the objectui ④ child:**
- `deriveFormFields(childSchema, opts)` equals
`deriveInlineRowFormFields(childSchema, opts)` on every measured input.
- Line 847's expression equals `isInlineRowFormOffered({ inlineMode:
d.inlineMode, formFields: d.formFields, columns: d.columns })`.
  - The verdicts are above.
- **`sortField` (pointer position 3), probe reading.**
`pc_line.position` is inert at all three heads. At the pin the renderer
only stamps it (`GridField.tsx:735`). It loads rows with `$filter` and
`$top` and no ordering, so it never reads the field. objectui
`0a3e5409f` retired the authored key after the pin, and no lint read was
added. The general walk still reads `details[].sortField` against the
parent. That reading leaves with the key at the next `.objectui-sha`
bump.
- **Flag A** goes to a point card the seat files. The pin sentence and a
boundary test state what this PR covers.
- **Kept as stated:** an omitted `inlineMode` on the DERIVED path (the
renderer's smart default), and a derived grid with no
`relationshipField`, both credit an authored list as drawn.
- **"Not in this card"** stays out: the explicit `form.subforms`
override, and a `subforms` entry with no `relationshipField`.
- **Changeset.** `@objectstack/spec: minor`, because `Clause-②: yes`
takes at least minor. `@objectstack/lint: patch` follows PR objectstack-ai#21089 and
PR objectstack-ai#21215. The lint bullets now state the round-2 reads. The rule's
message and hint text are unchanged.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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

2 participants