Skip to content

fix(lint): field-no-consumers reads an inline grid column name as a field of the child object - #20950

Merged
os-justin merged 4 commits into
mainfrom
claude/issue-20929-grid-column-consumers
Sep 30, 2026
Merged

os-justin merged 4 commits into
mainfrom
claude/issue-20929-grid-column-consumers

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Closes #20929
Clause-②: no (a lint verdict changes; no key is added to a published payload, read against the gate's definition in scripts/check-changeset-no-major.mjs)

What changes

field-no-consumers (packages/lint/src/validate-field-consumers.ts) now reads an inline grid column's name as a reference to the child object's field. name stays in LITERAL_KEYS: it is not dropped wholesale. One helper, creditInlineGridColumns, reads the column position on its own, against the child object each carrier names:

carrier child object site kind
a relationship field's inlineColumns the object that declares the field display when the field sets inlineEdit, otherwise carrier
form.subforms[].columns, and the same under each formViews entry the entry's childObject display

The subform carrier is keyed by CHILD_COLLECTION_KEYS (today subforms): its entries are { childObject, columns }. The finding message now also lists an inline grid column among the consumers, and an inlineColumns entry on a field without inlineEdit among the carriers.

One correction to the claim: for inlineColumns the child is the declaring object, not the related one

The claim's scope line glossed the inlineColumns child as "the related one"; the seat corrected it in place (ruling 5920513410 on #20929). The spec, its own peer check, the renderer and the one real producer all say the opposite, so this PR follows the triage ruling's intent ("a reference to the child object's field"):

  • FieldSchema.inlineEdit (packages/spec/src/data/field.zod.ts): "On a child's master_detail/lookup field (whose reference is the parent object)".
  • collectHydratedInlineColumnErrors (packages/spec/src/stack.zod.ts): "a relationship field's inlineColumns — the field sits on the CHILD object, so a column names a field of the object that owns the field".
  • objectui attachInlineSubforms (packages/app-shell/src/providers/MetadataProvider.tsx) turns a field's inlineColumns into a subform with childObject: child.name, the declaring object, on the form of the field's reference, the parent.
  • The showcase invoice (examples/app-showcase/src/data/objects/invoice.object.ts) puts inlineColumns on showcase_invoice_line.invoice (reference: 'showcase_invoice'), and all seven columns are fields of showcase_invoice_line.

The inlineColumns pin gives the related (parent) object a same-named field that nothing reads and holds it reported. Crediting the related object turns that pin red (ablation A3a below).

Why inlineEdit gates the relationship carrier

The spec's own form help text (packages/spec/src/data/field.form.ts) says inlineColumns is "used only when this field sets inlineEdit", and objectui skips a field whose inlineEdit is falsy. Without it, the columns name the field and draw nothing. They are recorded as a carrier: the field reads carrier-only, with the column path listed as a site a removal must clean. That is the rule's existing taxonomy ("credits exactly what a renderer draws"), and it is pinned and ablated (A4).

Measured at the public door: os validate --json, before and after

The probe is one parent (gc_invoice) and four children. On each child, quantity and amount are named only by grid columns, and memo is named nowhere (the control). The CLI ran from source (packages/cli/bin/run-dev.js), with the validate command's dependency closure built at each tree.

child carrier BEFORE at 1571aedce5 AFTER at f849aa53f6
gc_line_inline invoice.inlineColumns with inlineEdit: 'grid' quantity, amount, memo: inert memo: inert
gc_line_noedit invoice.inlineColumns, no inlineEdit quantity, amount, memo: inert quantity, amount: carrier-only (each lists its inlineColumns[i].name path); memo: inert
gc_line_form form.subforms[0].columns quantity, amount, memo: inert memo: inert
gc_line_formview formViews.edit.subforms[0].columns quantity, amount, memo: inert memo: inert

Both runs exit 0 with valid: true. field-no-consumers findings go from 12 to 6. The card had not measured the relationship field's inlineColumns carrier, and it had the same blind spot (first row, BEFORE). The AFTER reading was first taken at 7ee5c56679 and repeated at the merged head f849aa53f6, with identical findings.

Pins and ablations

A new describe block in validate-field-consumers.test.ts uses one fixture. The parent is inv and the child is line, related by line.invoice. line.qty is named only by the column under test. line.memo is named nowhere (the control). inv.qty is a same-named parent field that nothing reads, so a column credited to the wrong object shows up as inv.qty going quiet.

The pins:

  • baseline: with no grid, all three fields are inert;
  • inlineColumns with inlineEdit;
  • form.subforms[].columns;
  • formViews.edit.subforms[].columns;
  • inlineColumns without inlineEdit: carrier-only, listing the column path;
  • name anywhere else stays a literal: a dataset measure named qty on line credits nothing.

Every leg below went through scripts/ablation-replace.mjs. The anchor had to hit exactly once, and the mutation was proven on disk by anchor and replacement counts and a changed blob. The restore was proven by blob == HEAD and an empty git diff HEAD, with the driver's own trap on EXIT, INT and TERM. The pins import the source relatively, so no dist was involved. The tree was HEAD 7ee5c56679.

leg mutation pins red
A1 the relationship-field credit removed inlineColumns, no-inlineEdit (2)
A2 the child-collection credit removed form, formViews (2)
A3a inlineColumns credited to the related object (referenceTargetOf(field)) inlineColumns, no-inlineEdit (2)
A3b subform columns credited to the view's object (inner) form, formViews (2)
A4 the inlineEdit gate removed (always display) no-inlineEdit (1)
A5 name dropped from LITERAL_KEYS wholesale form, formViews, no-inlineEdit, dataset-measure literal (4)
A6 the control: every child field credited once a grid exists all four carrier pins, through line.memo (4)

A4's first attempt was a no-op and does not count. Its replacement text ('display') already occurred in the file, so the token count moved 7 to 7, and the tool refused before running the pins. It was redone with a replacement that did not occur, (true as boolean) ? 'display' : 'carrier', and went red with 1 failed. The final proof after all legs: blob 4c109d4ef9ee == HEAD, and git diff HEAD is 0 bytes.

Verification

All on the merged head f849aa53f6 (clean tree), merge base 3fbf3ca617:

  • pnpm turbo run build --filter='@objectstack/lint...' --concurrency=2: 4/4 tasks. Then pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2: 117 files, 5444 tests passed. Then pnpm --filter @objectstack/lint typecheck (tsc --noEmit plus check:test-typecheck, whose tsconfig.test.json is the program that compiles the changed test file): OK. The three ran joined by && under os-verify-lock: VERDICT command-exit 0.
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: exit 0, 60 commands. All 60 ran, each with its exit code captured before any pipe. --ran reconciliation: exit 0, 58 exited 0, and 2 are NOT MEASURED. pnpm check:dual-build-cjs-loads and pnpm check:type-check-debt exited 3, PREREQUISITE NOT MET: each reads every workspace package's build output, and building the whole ./packages/* closure is lint.yml's own step, declared to CI.
  • eslint, narrowed to the diff: eslint --no-inline-config --format json on the two changed .ts files exits 0 over 2 files, with 0 errors and 0 warnings. The population is read from eslint's own config (files: '**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'), and neither file is reported as ignored. Invariance: eslint.config.mjs enables no type-aware linting (no parserOptions.project, no projectService) and no cross-file rule (no import/* rule), so this diff cannot move a verdict on an untouched file. The repo-wide pnpm lint is CI's.
  • A control-byte scan of the three changed files found nothing.
  • origin/main has since moved to f80e2a6dad, which touches packages/rest. It is not re-merged, because CI builds the merge ref.

Changeset

.changeset/20929-field-consumers-inline-grid-columns.md grades @objectstack/lint patch. The package publishes dist (files[]), so a behaviour change owes a changeset, and skip-changeset does not apply. The level is patch because nothing on the public surface moves: no new export, the same rule id and severity, and the same finding shape. Only which fields get a warning changes, plus the message wording. The declaration reads no, so the level axis of check-changeset-no-major stands down, and nothing is breaking, so no ADR-0087 marker is owed.

Acceptance notes

  • Same family, not addressed here, measured at the door (os validate --json, CLI at f849aa53f6, a second probe):
    1. A grid with no explicit columns (inlineEdit: 'grid', no inlineColumns) draws columns derived from the child's fields (objectui deriveColumns). Those fields still read inert: gc_line_derived.quantity and .amount. The derivation lives in objectui, not in the spec, so crediting it is a design question, like the synthesized field-group layout, whose derivation the spec owns.
    2. A subform's amountField names a child field (the spec: "Numeric child column summed for the running total"), but the walk reads it in the context of the object the view is bound to, the parent. gc_line_amt.amount, named only there, reads inert. totalField names a parent field, so this position cannot simply inherit the subform's childObject.
  • The master-detail block's details[].columns is not addressed here, and finding(spec): ObjectMasterDetailFormPropsSchema.details is z.unknown(), a third unjudged carrier of the inline grid column: a bogus key or a typed currency column with scale publishes green #20928 remains open. Its entries share the { childObject, columns } shape CHILD_COLLECTION_KEYS reads.
  • Column expressions under a subform (expr, readonlyWhen, requiredWhen) are still scanned in the view object's context. Their scope is mixed (record is the child row, parent is the header), so that is an observation, not a mechanical change.

Generated by Claude Code

…he child object's field

`name` stays a LITERAL_KEYS literal in general. At an inline grid column it
is read as a reference, against the child object its carrier resolves:

- a relationship field's `inlineColumns`: the object that declares the
  field (the child; its `reference` is the parent), and only as a display
  site when the field sets `inlineEdit`, otherwise as a carrier;
- a form view's `subforms[].columns` (on `form` and every `formViews`
  entry): the entry's `childObject`.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
…ld-object control

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 1 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 — 4 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 8460592f0865b0ffef931efaf4b71d5bc968dd29 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 8460592f0865b0ffef931efaf4b71d5bc968dd29

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

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

PR #20950 for card #20929, read against main at 95fed33a20 (merge base 3fbf3ca617; the head is a merge of origin/main carrying no extra change). Net diff: 3 files, +175 / -4 — packages/lint/src/validate-field-consumers.ts (+83 / -4), its test file (+80), and .changeset/20929-field-consumers-inline-grid-columns.md (+12). No governed surface, no packages/spec change, no generated artifact owed. The card's neighbours were read too: #20928 (master-detail details[].columns, pm:blocked, not taken here), #20951 (filed from this PR's report, unlabelled, not taken here), and PR #20927 (bee75cebe6, on main and under the head, which typed subforms[].columns as InlineGridColumnSchema).

① Derived judgments

Accept-set. No schema is touched; nothing an author writes is accepted or refused differently. The one verdict that moves is field-no-consumers: a child field named only by an inline grid column is no longer reported inert. Right — that is the card's own pin.

Public surface of @objectstack/lint. No export added, removed or renamed: CHILD_COLLECTION_KEYS and creditInlineGridColumns are module-private. FIELD_NO_CONSUMERS, the warning severity and the FieldConsumerFinding shape are byte-unchanged; only the message string gains two clauses. Right.

Child-object resolution, per carrier — each read against the spec and the renderer, not the claim's gloss.

  • inlineColumns on a relationship field: the diff credits objectName, the object that DECLARES the field (walkObject). Right. FieldSchema.inlineEdit's docblock (packages/spec/src/data/field.zod.ts) places the key "On a child's master_detail/lookup field (whose reference is the parent object)"; the spec's own peer walk collectHydratedInlineColumnErrors (packages/spec/src/stack.zod.ts) judges field.inlineColumns against obj.name, with its comment "the field sits on the CHILD object, so a column names a field of the object that owns the field"; objectui attachInlineSubforms at the pinned sibling db11afd496 (.objectui-sha on main) builds childObject: child.name — the declaring object — and hangs the subform on the form of d.reference, the parent; the showcase producer puts inlineColumns on showcase_invoice_line.invoice (Field.masterDetail('showcase_invoice')) and its seven columns (product, description, service_start, quantity, unit_price, receipt, amount) are all fields of showcase_invoice_line. The claim's original gloss ("the related one") was wrong and the ruling corrected it in place; the pin's same-named inv.qty staying inert is the wrong-object detector, and ablation A3a is consistent with the code.
  • form.subforms[].columns and formViews.KEY.subforms[].columns: the diff credits rec.childObject. Right — FormViewSchema.subforms[].childObject is "Child object whose records are entered inline" (view.zod.ts), and the peer walk calls judge(subform.columns, subform.childObject, …) on the container's form and on every formViews entry. The walk reaches both positions: an array hands its own key to every element as leafKey, so each subforms entry arrives with leafKey === 'subforms', and formViews.KEY is an ordinary record on the way down. The same credit also lands on an object-form page block, whose props ARE FormViewSchema (react-blocks.ts dataProps lists subforms): the same shape, the same renderer, so that unnamed third position is credited correctly.

The inlineEdit gate (display when the field sets inlineEdit, carrier otherwise). Right, and matched to what the spec says about when inlineColumns is used: field.form.ts's help text reads "used only when this field sets inlineEdit", and objectui at the pin skips the field on !d?.inlineEdit before it ever reads inlineColumns. true, 'grid' and 'form' are all truthy and objectui passes columns in every mode, so no mode is mis-gated; an explicit false reads as no grid, as the renderer reads it. One residual, not a defect: objectui also requires type in master_detail / lookup and a reference before it attaches the subform, and the lint credits on inlineEdit alone — a field of another type carrying both keys is not a shape the spec's form offers (inlineColumns is shown only under data.type == 'master_detail'), and the miss would be an under-report.

Credit classes vs the scan's own taxonomy. A drawn column is display ("the field is drawn: a view column, …"); an inlineColumns entry drawing nothing is carrier ("what a REMOVAL must clean up"), and the finding lists its path (objects[1].fields.invoice.inlineColumns[0].name, pinned). Right. The walk-side credit is hard-coded display whatever the root, which is sound because subforms is declared nowhere but FormViewSchema (git grep over packages/spec/src finds no other zod declaration), so every position the detector can reach sits in a display root.

name outside a grid-column position stays a literal. LITERAL_KEYS still holds name; the only two call sites of creditInlineGridColumns are the field's inlineColumns and a record reached under a CHILD_COLLECTION_KEYS key. Right, and pinned by the dataset-measure case, whose premise holds: DatasetMeasureSchema keeps name (the measure's identity) apart from field (the aggregated column). Observation for the next author: the detector is keyed by leaf key alone in every root; a future subforms key of a different shape anywhere in the spec would need a shape check.

A column naming a field the child does not declare is counted unresolved and credited nowhere — the module's "counted, never dropped", and the peer walk's "an unresolved column is not a wrong one". Right.

Author-shown and AI-facing text, sentence by sentence.

  • Changeset, "os validate, os build and os lint warned …": true — the rule's commands: ALL over AUTHORING_COMMANDS = ['validate', 'build', 'lint']. "The field sits on the child object and its reference names the parent": true (field.zod.ts docblock). "The grid is drawn only when that field sets inlineEdit. Without it, … reported carrier-only with the column listed": true (help text, objectui, the pin). "The column names a field of the entry's childObject, not of the object the view is bound to": true. "name anywhere else is still a literal and never a field reference": true. "The rule id, the warning severity and the finding's shape are unchanged": true. "The message now also lists …": true, both clauses are in the diff. "on both carriers of the column": true against the tree — InlineGridColumnSchema is referenced at exactly two positions on main, and the bullet list bounds the sentence; it becomes over-broad only if finding(spec): ObjectMasterDetailFormPropsSchema.details is z.unknown(), a third unjudged carrier of the inline grid column: a bogus key or a typed currency column with scale publishes green #20928 types a third (ObjectMasterDetailFormPropsSchema.details, z.unknown() today), whose child fields stay inert after this PR — the PR's acceptance notes say so, the changeset does not. Not a false sentence; a residual for finding(spec): ObjectMasterDetailFormPropsSchema.details is z.unknown(), a third unjudged carrier of the inline grid column: a bogus key or a typed currency column with scale publishes green #20928's claim, which already names this scan as a surface.
  • Code comments that state a rule: the CHILD_COLLECTION_KEYS note (FormViewSchema.subforms, on form and on every formViews entry — true, understated by the object-form block), the LITERAL_KEYS note (true), the creditInlineGridColumns docblock (objectui builds { childObject: declaring object, columns: inlineColumns } — true at the pin; the peer walk resolves the same way — true), the walkObject comment quoting the help text — true, verbatim. The new message clause "an inlineColumns entry on a relationship field that does not set inlineEdit (no grid is drawn) is a carrier, not a consumer" — true.
  • PR body: the carrier table, the four spec/renderer/producer citations, the inlineEdit paragraph and the Changeset paragraph all check against the tree as above. The os validate before/after table is the dev's own measurement — not re-run here; its arithmetic is consistent (4 children by 3 fields = 12 findings before; 4 memo controls plus 2 carrier-only = 6 after). The ablation table is consistent with the code (A5 reddening four pins and not the inlineEdit one is exactly what dropping name from the literals does: the general walk then credits line.qty in the declaring object's own context, the same credit the helper makes). "origin/main has since moved to f80e2a6dad, which touches packages/rest": true. One sentence is stale, not false: "The claim's scope line glosses the inlineColumns child as 'the related one'" was true when the PR opened (21:58Z); the seat edited the claim in place at 22:04Z and ruling 5920513410 is the record of it, so the card no longer carries the gloss the body corrects.
  • Clause-② line: "no (a lint verdict changes; no key is added to a published payload …)" — true against the gate's own definition of clause ② ("a new key on a published payload"): no key moves; bare, at the start of a body line; Check Changeset success.

Pre-existing, unchanged, named so nobody re-derives it: the general walk still reads a subform column's expr / readonlyWhen / requiredWhen in the view's object context (the PR's acceptance note 3); a subform's amountField is read against the parent (#20951 site 1); a pre-parse os lint stack spelling the subform aliases (object, child, fields) instead of the canonical keys is not credited, exactly as the peer walk does not judge it.

② Semver level

.changeset/20929-field-consumers-inline-grid-columns.md grades @objectstack/lint patch, and patch is what the diff publishes: the package ships dist and sits in the fixed group, so a changeset is owed and skip-changeset does not apply; nothing on the public surface widens (no export, no accepted key or value, same rule id, severity and finding shape), so under WHICH LEVEL "a fix( that changes no public surface stays patch". Nothing narrows, so no BREAKING banner and no ADR-0087 marker is owed, and the changeset carries neither. The Clause-②: no line matches: no key is added to any published payload, and the level axis stands down. The PR's labels carry no skip-changeset; Check Changeset is success on the head.

③ Boundary flags

Dev report 5920475185 (os-dev-report, mode:subagent):

Check-runs on f849aa53f6, read last: 38 runs, 34 names after dedupe by newest started_at; 29 success, 5 skipped (Auto Label, Build Docs, Check PR Size, Console Pin Gate — no pin moved — and Packed-tarball smoke (opt-in)), 0 failed, none still running. The seven required contexts all concluded success: Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard.

Implemented-by: claude/issue-20929-grid-column-consumers
Reviewed-by: session_01Sfe5YjBLwB9J3y8fvm2xq1

VERDICT: PASS

Adopted and posted by domain:spec seat 5 (session_01Sfe5YjBLwB9J3y8fvm2xq1) · 2026-09-30T22:25Z · rendered by the seat's at-tier review subagent on this head. The seat read its served tier family from the subagent transcript before posting.


Generated by Claude Code

@os-justin
os-justin marked this pull request as ready for review September 30, 2026 22:27
@os-justin
os-justin added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit 3693a1b Sep 30, 2026
50 checks passed
@os-justin
os-justin deleted the claude/issue-20929-grid-column-consumers branch September 30, 2026 22:49
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
… against the child, and credits a derived inline grid through deriveInlineGridColumns (objectstack-ai#21089)

Fixes objectstack-ai#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
objectstack-ai#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 `[objectstack-ai#20951]` block in
`validate-field-consumers.test.ts` (15 tests) and the unchanged
`[objectstack-ai#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 objectstack-ai#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](https://claude.ai/code/session_017VaLJnYwhPsanVCe9dMCJU)_

---------

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 size/m tests tooling

Projects

None yet

2 participants