Skip to content

fix(lint,spec): field-no-consumers reads a subform entry's child keys against the child, and credits a derived inline grid through deriveInlineGridColumns - #21089

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-20951-field-consumers-child-context
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-20951-field-consumers-child-context

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20951

Clause-②: yes (widening)

What changes

field-no-consumers (packages/lint/src/validate-field-consumers.ts) called two kinds of in-use child field "inert". Both are corrected here, and the second goes through one new derivation the spec owns.

Site 1: a subforms entry's child-field keys are read per key. amountField ("Numeric child column summed for the running total") is now read against the entry's childObject. totalField ("Parent field to receive the rolled-up sum") stays on the parent, which is the context the walk already carries. The read uses the child resolution that PR #20950 added for subforms[].columns: strName(rec.childObject) in the CHILD_COLLECTION_KEYS branch of walk. There is no second child-object lookup. The generic walk now skips these keys on an entry, so a same-named parent field is no longer credited in the child's place.

  • relationshipField ("FK on the child pointing back to the parent") gets the same per-key read. This is a bounded in-place fix: the same defect class as amountField, in the same file, with the same gate family. Evidence is in the probe table below (pr_quote_line.quote). The renderer loads the child rows with $filter on this key and stamps it on save, so the field is read.

Site 2: a derived inline grid credits the columns it draws. The new deriveInlineGridColumns lives in packages/spec/src/data/inline-grid-columns.ts, beside deriveFieldGroupLayout, and is exported through the @objectstack/spec/data barrel. The lint credits exactly what it returns, defaultHidden overflow included, because those columns are collapsed into the column chooser and never dropped. Two carriers trigger it:

  • a relationship field with inlineEdit (true, 'grid' or 'form'; both modes pass the same columns to the grid), type master_detail or lookup, a target that resolves, and no authored inlineColumns (absent or empty). These are the conditions objectui's attachInlineSubforms checks;
  • a subforms entry with no columns (absent or empty). The spec documents this second carrier with the same words, "derived from the child object when omitted", and the renderer uses the same derivation for it.

The spec function and objectui's rule (triage's ⛔, PM hypothesis H2)

Signature: deriveInlineGridColumns(def: unknown, opts?: { relationshipField?: string; exclude?: readonly string[]; maxColumns?: number }): DerivedInlineGridColumn[]. DerivedInlineGridColumn is { name: string; defaultHidden?: true }, which is a valid identity-only inlineColumns entry. DEFAULT_MAX_INLINE_GRID_COLUMNS is 6. Import path: @objectstack/spec/data. The input discipline matches deriveFieldGroupLayout: it takes the child object's definition and tolerates un-parsed input.

The rule, as measured in objectui main at be5211522412 (packages/plugin-form/src/deriveMasterDetail.ts, deriveColumns plus curateColumns, read over REST):

  • Every child field is a candidate, in the field map's order.
  • A field is skipped when:
    • its name is an identity, audit, tenancy or ownership column (id, _id, recordId, created_at/updated_at/created_by/updated_by and their camelCase forms, organization_id, tenant_id, space, owner);
    • its name is a sort-position name (position, sort_order, sequence, line_no, line_number, sort);
    • it is the relationship field, or a name in exclude;
    • it is flagged system, readonly or hidden (a truthy value is enough);
    • its type cannot be edited in a cell: formula, summary, rollup, autonumber, auto_number, json, object, grid, table, location, vector, html, markdown or richtext.
  • Visible budget: 6. The first name-like column is kept visible (or the first column, when none is name-like), and so is every required column. A computed column is never required. The remaining slots go by cell type: select first, then currency and number, then lookup, then date, datetime and time, then text, and file last. Ties keep field order. Columns past the budget are marked defaultHidden. maxColumns of 0 or less marks no column hidden.

The differential: 80,004 cases and 0 mismatches. I ran the spec function against objectui's deriveColumns, imported from that main file. The cases were objectui's 4 own fixtures, 50,000 random definitions and 30,000 wide definitions built to exercise the budget (29,075 of them produced defaultHidden columns). The random inputs included null field definitions, array-shaped fields, non-spec type names, truthy and falsy flag values, CEL-envelope expressions, and NaN, negative and absent maxColumns. Names, order and defaultHidden matched in every case. So the spec function reproduces the rule with no behaviour change. objectui's renderer is not touched here.

For objectui's switch (the coordination child; not in this PR): hydrateColumns(deriveInlineGridColumns(schema, opts), schema) equals deriveColumns(schema, opts) in every case but one kind. When a derived field's own definition is falsy (null), hydrateColumns returns the bare { name } where deriveColumns builds a text column labelled with the name. That covered 3,889 of the cases, all of that kind. A served schema never carries a null field definition. Still, keeping objectui's own per-column builder over the returned names makes the switch exact by construction.

Evidence

The door: os validate --json, CLI from this branch's source, before vs after. The probe stack is a defineStack app with four parent/child pairs. "Before" rebuilt @objectstack/lint from the base commit's source; ablation-dist-preflight --absent confirmed the change was gone from dist/. Both runs exit 0 with valid: true.

field before after why
pr_invoice_line.line_total inert not reported site 1: amountField
pr_invoice.line_total (unused parent twin) not reported inert it was credited in the child's place
pr_invoice_line.total (unused child twin) inert inert control: totalField stays on the parent
pr_invoice_line.memo inert inert control: nothing reads it
pr_quote_line.quote (a lookup FK) inert not reported relationshipField, per key
pr_order_item.sku, .quantity inert not reported site 2: derived grid columns
pr_ticket_note.body inert not reported site 2, on a lookup relationship
pr_order_item.secret (hidden) inert inert lit control: the derivation leaves it out

A real producer: examples/app-showcase, the same door, before vs after. The finding count went from 57 to 54, and no finding was added. The three removed findings are showcase_expense_line.category, .incurred_at and .incurred_on, which were carrier-only before. showcase_expense_line.expense_report sets inlineEdit: 'grid' with no inlineColumns. examples/app-crm opportunity_line_item.opportunity has the same shape (I read it; I did not run it).

Tests (final HEAD 513570747):

  • pnpm --filter @objectstack/lint exec vitest run: 118 files, 5,467 tests passed. This includes the new [#20951] block in validate-field-consumers.test.ts (15 tests) and the unchanged [#20929] block.
  • pnpm --filter @objectstack/spec exec vitest run --project local: 585 files, 17,222 passed and 1 todo. That run was at bf01c7979; the only later commit is a one-line lint change, and the new inline-grid-columns.test.ts (11 tests) was re-run at 513570747.
  • pnpm --filter @objectstack/spec --filter @objectstack/lint run typecheck: both exit 0, and check:test-typecheck is OK for both.
  • The lint import of deriveInlineGridColumns compiles only against the rebuilt .d.ts, because the name does not exist in the base build.
  • pnpm --filter @objectstack/cli exec vitest run --project unit: 238 files and 3,391 tests passed. 2 files (10 tests) are NOT MEASURED; see below.

Reverse verification. The fix was committed first. Then validate-field-consumers.ts was restored to the base blob 4c109d4ef. With that source, 11 of the 15 new tests fail, and the 4 baselines and controls pass. The restore went through git checkout HEAD -- and was checked by blob hash (2ef0118a7, then equal to HEAD); git status was clean afterwards.

Gates. node scripts/pm/dispatch-gates.mjs --commands was derived from this diff (8 paths, 86 commands) and every command was run at 513570747. --ran reports "86 derived, 84 run, 2 NOT-MEASURED, 0 UNRUN". 83 exited 0, including check:generated (all 15 artefacts up to date after gen:api-surface and gen:export-origins), check:api-surface, check:export-origins, check:entry-nameability, check:dual-source-exports, check:spec-changes (inside check:generated), check:nul-bytes and check:engine-double-contract. The rest are NOT MEASURED, listed below.

NOT MEASURED (none of these are a verdict on this diff):

  • check:dts-closure exited 1. It names 55 packages with missing .d.ts. This tree built those packages with OS_SKIP_DTS=1, only so that os validate could run from source. spec, lint, formula and sdui-parser had full builds and are not named.
  • check:dual-build-cjs-loads and check:type-check-debt exited 3 with PREREQUISITE NOT MET: they need the whole workspace built with declarations.
  • In the CLI unit tier, published-subpath-console.pin.test.ts and published-subpath-hook-body.pin.test.ts (10 tests) fail with ENOENT on packages/cli/dist/*.d.ts. That is the same JS-only build.
  • The CLI integration tier and packages/qa/dogfood (an importer of field-group-layout, whose bytes do not change) are left to CI.
  • I did not merge main. Since the base, 14 commits have landed there, and none of them touches the 8 paths in this diff.

Acceptance notes

  • Exports. The claim said one new export. There are three: the function, its element type and the budget constant. The constant lets objectui re-export one value instead of keeping a second 6. No accept set moves.
  • Landing sites. Everything lands at the expected sites. The derived-grid credit also covers subforms entries with no columns, and relationshipField is read per key. Both are named above.
  • Boundary: a subforms entry that names no relationshipField. The renderer detects the FK itself. The lint keeps no copy of that detection, so the derived list it credits includes the FK. The FK is read anyway, as the join key, so the verdict is unchanged.
  • Boundary: an explicit override. When a parent form has an explicit form.subforms entry for the same child, objectui draws that entry instead of the field-derived grid. The lint still credits the field-derived grid, which matches how PR fix(lint): field-no-consumers reads an inline grid column name as a field of the child object #20950 already treats inlineColumns. This is an over-credit in that case only.
  • Not filed; same family, measured at the door after this PR, handed to the seat:
    • A lookup relationship field that sets inlineEdit is the inline grid's join key, yet pr_ticket_note.ticket is still reported inert. master_detail is exempt; lookup is not.
    • Fields drawn only in the per-row expand form (objectui deriveFormFields: rich text, JSON, readonly) are still reported. pr_order_item.spec_sheet, a richtext field, is reported inert, while the grid offers the expand form because the child has more form fields than grid columns.

Generated by Claude Code

claude added 4 commits October 1, 2026 04:05
…child reads and derived grids

Claude-Session: https://claude.ai/code/session_017VaLJnYwhPsanVCe9dMCJU
Co-authored-by: Claude <noreply@anthropic.com>
…t, as the renderer does

Claude-Session: https://claude.ai/code/session_017VaLJnYwhPsanVCe9dMCJU
Co-authored-by: Claude <noreply@anthropic.com>
@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 37 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json, packages/spec/src/data/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

44 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 94608a7d72ecef7bf61d10dbab3bd80aea311055.

⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json, packages/spec/src/data/index.ts) — pages documenting those are invisible to this run
  • 3 anchor(s) matched too much of the corpus to be a work list: created_at (literal, 35 pages), master_detail (literal, 30 pages), organization_id (literal, 32 pages)
  • 13 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 94608a7d72ecef7bf61d10dbab3bd80aea311055 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 94608a7d72ecef7bf61d10dbab3bd80aea311055

⚠️ 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 94608a7d72ecef7bf61d10dbab3bd80aea311055 → 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: 51357074754f69de9685e703590696baf67f3de2
Local-runs: none

Inputs: card #20951 (body and comments 5920525534, 5921156625, 5924389801, 5925562923), PR #21089 (body, 8-file list, net diff against main at the head), the check-runs on the head, and objectui main at be5211522412 read over REST for the renderer rule the new spec function reproduces (packages/plugin-form/src/deriveMasterDetail.ts, packages/app-shell/src/providers/MetadataProvider.tsx, packages/plugin-form/src/MasterDetailForm.tsx). Nothing built, run or re-run.

① Derived judgments

  1. Public surface: three new names on @objectstack/spec/data. packages/spec/src/data/index.ts adds export * from './inline-grid-columns'; that module exports exactly deriveInlineGridColumns (function), DerivedInlineGridColumn (interface) and DEFAULT_MAX_INLINE_GRID_COLUMNS (const, 6), every other binding in it is module-private. ./data is an exports-mapped subpath of packages/spec/package.json, so this is a published accept set. api-surface/data.json and export-origins/data.json carry the same three names with origins pointing at the new file. The root entry re-exports only two types from data modules, so root.json and api-assembled.json rightly stay untouched (neither carries deriveFieldGroupLayout today either). No existing export is removed or re-typed. Judged right: widening only.

  2. The spec function is objectui's rule, not a second copy. Compared by inspection against deriveColumns and curateColumns at be5211522412: INLINE_GRID_SYSTEM_FIELDS, INLINE_GRID_SORT_FIELDS and INLINE_GRID_NON_EDITABLE_TYPES match SYSTEM_FIELDS, SORT_FIELD_NAMES and NON_EDITABLE_TYPES member for member; cellTypeOf matches fieldTypeToColumnType case for case; CELL_FILL_PRIORITY matches TYPE_FILL_PRIORITY with the same fallback of 5 for file and unknown cells; the truthy system/readonly/hidden skip, the relationship-field and exclude skip, required with the computed override (bare string expression or CEL envelope source, non-empty), the primary pick (first name-like name, else the first column), required always visible, the fill by priority then field index, the default budget of 6, the "zero or less marks nothing hidden" and "count at most the budget returns all visible" short-circuits, and the field-order-preserving output all match. DEFAULT_MAX_INLINE_GRID_COLUMNS equals objectui's DEFAULT_MAX_INLINE_COLUMNS. The output is identity-only ({ name } plus defaultHidden: true on the overflow) where objectui's columns also carry label, type and options; that division is documented in the module and pinned by the test that parses every derived entry through InlineGridColumnSchema (whose defaultHidden is an optional boolean at the head). The module names the sha it was measured against. I did not re-run the dev's 80,004-case differential; inspection finds no divergence. Judged right, and it satisfies triage's ruling in 5921156625 (one rule the spec owns, the renderer untouched).

  3. Lint verdict changes in packages/lint/src/validate-field-consumers.ts (@objectstack/lint behaviour, not a published schema):

    • (a) Site 1, per-key read. CHILD_ENTRY_FIELD_KEYS is amountField and relationshipField; on a subforms entry each is read against strName(rec.childObject) inside the existing CHILD_COLLECTION_KEYS branch (no second child lookup), and the generic loop skips those two keys, so a same-named parent field is no longer credited in the child's place. totalField is left to the generic walk, whose context is the enclosing view's object: contextOf switches on object, objectName, targetObject, data.object, config.objectName, config.object, list.data.object, name, a dataset and flow nodes, never on childObject. This matches the schema text at the head (amountField "Numeric child column summed for the running total", relationshipField "FK on the child pointing back to the parent", totalField "Parent field to receive the rolled-up sum"). Under the views root both keys bucket as display, a consumer, so the field is cleared; an unresolvable name counts in ledger.unresolved like every other credit helper. Judged right.
    • (b) Site 2, field carrier. In walkObject, when field.inlineEdit is truthy, type is master_detail or lookup, referenceTargetOf(field) answers a target and inlineColumns is absent or empty, deriveInlineGridColumns({ fields }, { relationshipField: fieldName }) is credited on the declaring (child) object as display. objectui's attachInlineSubforms at the same sha skips unless inlineEdit is truthy, type is master_detail or lookup and reference is set, passes inlineColumns only when it is an array, and deriveDetail derives whenever override.columns?.length is falsy, so an empty authored list is "no authored list" in both. MasterDetailForm passes the same d.columns under both inlineMode values (displayMode list or grid), so crediting inlineEdit: 'form' and true as well as 'grid' is right. Judged right. One edge named, not blocking: objectui attaches the grid only when an object named by reference is among the loaded objects, while the lint's !!reference checks that the spelling resolves through the spec's arbiter, not that the parent is declared in the stack. A dangling parent reference is another rule's finding, and the finding(lint): field-no-consumers calls a field "inert" when an inline grid column names it (form.subforms[].columns[].name), because name is in LITERAL_KEYS #20929 inlineColumns credit on the line above takes the same posture, so this is consistent with the precedent on the head.
    • (c) Site 2, subforms carrier. An entry with no columns (absent or empty) credits the derivation on childObject, excluding the entry's relationshipField when named; matches "derived from the child object when omitted" and deriveDetail. When the entry names no relationshipField the lint keeps no copy of findRelationshipField, so the FK is credited among the derived columns; the FK is read either way as the join key, so no verdict changes. Judged right.
    • (d) defaultHidden columns are credited. objectui's curateColumns marks and never drops ("collapse the overflow into the chooser"), so the column is drawn on demand. Judged right.
    • (e) fieldMapByObject is filled in the declare pass before any walk, in collectionEntries order, so the derivation sees every declared field with its raw record; order affects only defaultHidden, which the lint does not read. Judged right.
    • (f) Exemptions unchanged: master_detail fields, the title field and injected columns stay exempt and nothing new is exempted. A hidden, readonly, system or non-editable-type child field the derivation leaves out is still reported (pinned by line.secret, line.frozen, line.blob). Judged right.
    • (g) Finding message and hint unchanged; the docblock gains the third "consumer that names the field nowhere" paragraph. Judged right.
  4. Tests. 15 new lint tests pin both sites with a same-named parent/child pair, a swapped-key control, each inlineEdit value, an empty inlineColumns, an authored list, a non-relationship inlineEdit, the over-budget case and both subforms shapes. 11 spec tests use objectui's own showcase_task fixture plus the budget cases. The dev's reverse-verification claim is the dev's; the head's Test Core check-runs decide it and are in progress at my reading.

  5. Claim boundaries held. field.zod.ts and view.zod.ts are read, not edited; objectui's deriveColumns is untouched (the coordination child is the seat's to file after landing). The 8-file list is exactly the claim's file surface. content/docs/** and apps/docs/** are untouched; the reference pages are generated from zod descriptions that did not change, and "Flag docs affected by code changes" is success on the head.

② Semver level

.changeset/20951-inline-grid-derived-columns.md: @objectstack/spec: minor, @objectstack/lint: patch. The diff publishes three new names on an exports-mapped subpath of @objectstack/spec and removes or re-types none, so minor is the right level (the repo's other '@objectstack/spec': minor changesets are new-export changesets of the same kind). @objectstack/lint changes one rule's verdicts (fewer false "inert" findings, one newly correct finding on an unused parent twin) with no API change, so patch is right. No major (Check Changeset: success). The prose names the three exports, the two carriers, the skip sets, the defaultHidden semantics and says "No schema accepts anything new or refuses anything new", which is accurate against the diff. Clause-②: yes (widening) stands on its own line in the PR body and in the changeset, matching the claim's Clause-②: yes (widening) in 5924389801; the export count moved from one to three but the clause's value does not change. Judged right.

③ Boundary flags

open_questions in the dev report 5925562923: none. Dev flags, each answered:

  1. Three exports instead of the claim's one (function, element type, budget constant). Answer: accepted. The element type is the function's return shape and has to be nameable; the constant lets objectui re-export one value instead of keeping a second 6, which is the "one rule" triage asked for. All three are in the generated artefacts; Clause-② unchanged.
  2. Bounded in-place scope: relationshipField read per key, and a subforms entry with no columns also credited. Answer: accepted. Same defect class, same file, same gate family, both named in the PR body with probe evidence, both following the schema's own words at the head. Neither widens a schema.
  3. Local build state (OS_SKIP_DTS; three gates and two CLI pin files NOT MEASURED locally). Answer: not a verdict on this diff. The head's check-runs are the gate verdicts (Build Core, Type Check source gates and debt ledger are success; Type Check workspace and consumer gates, Lint & Repo Gates and Test Core are in progress at my reading).
  4. main not merged, 14 commits behind the base. Answer: the dev states none touches the 8 paths; the PR's base is 94608a7d72 and mergeable state reads unknown at my reading. Not a contract matter; the merge-base signature is the seat's at landing.
  5. Attribution trailer follows AGENTS.md rather than the harness reminder. Answer: commit messages are outside this record's input set and not a contract face; no ruling from me.
  6. Acceptance-note boundary: an explicit form.subforms entry for the same child overrides the field-derived grid in objectui, so the lint over-credits the field-derived columns in that case. Answer: accepted and recorded, not escalated; same posture PR fix(lint): field-no-consumers reads an inline grid column name as a field of the child object #20950 took for inlineColumns, and an over-credit silences a finding rather than producing a false one.
  7. Acceptance-note boundary: a subforms entry with no relationshipField credits the FK among the derived columns. Answer: accepted; verdict unchanged because the FK is read as the join key.
  8. out_of_scope_findings: two class-a findings in the same family (a lookup relationship field with inlineEdit still reported inert; fields drawn only by the per-row expand form via objectui deriveFormFields still reported) and two noted-not-filed boundaries. Answer: outside this PR, handed to the seat for the family closing card; not blocking.

Triage's ⛔ in 5921156625 (record any difference from objectui's rule; never change the renderer silently). Answer: the rule is reproduced exactly by inspection (①.2) and the renderer is untouched. The one difference the dev records (a null field definition under objectui's hydrateColumns) concerns objectui's future switch, not this PR, and is written into the PR body for the coordination child.

Check-runs on the head, read 2026-10-01T05:56Z: 32 runs, 17 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke opt-in), 0 failure, 12 in progress (Dogfood Regression Gate 1/3 and 2/3, Lint & Repo Gates, Temporal Conformance, Test Core 1/6 through 6/6, Type Check consumer gates, Type Check workspace). No red at my reading; the seat re-reads every check before landing.

Implemented-by: claude/issue-20951-field-consumers-child-context
Reviewed-by: session_017VaLJnYwhPsanVCe9dMCJU

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 1, 2026 06:10
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 1, 2026 06:10
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit e07566b Oct 1, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20951-field-consumers-child-context branch October 1, 2026 06:31
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:data size/l tests tooling

Projects

None yet

2 participants