Skip to content

fix(lint)!: object-field-ref-unknown judges a field's relatedListColumns, lookupColumns, lookupFilters and dependsOn, and indexes[].fields - #20479

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20432-field-name-list-refs
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20432-field-name-list-refs

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #20432

Clause-②: no

What this does

This is step 1 of the triage direction on the card (5871296642). The object-level field-reference rule object-field-ref-unknown (packages/lint/src/validate-object-field-refs.ts, error, already on os validate / os build / os lint and the runtime publish door for object writes) now judges five more field-name lists. Before, it judged highlightFields and publicSharing.redactFields only:

  • field level: relatedListColumns, lookupColumns (both arms), lookupFilters[].field, dependsOn (both arms);
  • object level: indexes[].fields (the fold 5870812245).

A name that is not a field of the object the list addresses is refused at the exact path. Examples: objects[i].fields.FIELD.lookupColumns[j].field and objects[i].indexes[j].fields[k]. The message has the family's shape: the string that was written, the object it was judged against, a "Did you mean" when a name is close, then the consequence. The hint ends with that object's field list (Fields on "X": a, b, c.). The rule id, severity and suite wiring are unchanged. No new rule file and no new gate.

Step 2 is not in this PR: SqlDriver.syncDeclaredIndexes logging a skipped declared index at error through logDurabilityFailure, and showing it in drift. That is the domain:engine seat's second PR on this card, and #20432 remains open for it.

Which object a name is judged against (measured on each key's reader)

The PM's mechanism hypothesis 3 asked for this per key. The readers were read at the objectui pin .objectui-sha dd3f7e1b:

Position Judged against Reader
relatedListColumns[i] the object that owns the field (the child) app-shell/src/utils/deriveRelatedLists.ts:291, where the related list is childObject: child.name
lookupColumns[i] / .field the referenced object fields/src/widgets/LookupField.tsx:337,484, picker columns over refObjectSchema of referenceTo
lookupFilters[i].field the referenced object LookupField.tsx:339,661 lookupFiltersToRecord, which feeds the query on referenceTo. validate-preset-comparands.ts already binds the same key this way.
dependsOn[i] / .field the object that owns the field LookupField.tsx:451-458, where the gate reads the host record by this key
dependsOn[i].param, or the bare name on a picker the referenced object LookupField.tsx:371-380,647: param defaults to field, and dependentFilter[param]
indexes[i].fields[j] the object itself, plus injected columns SqlDriver.syncDeclaredIndexes reads names verbatim against physicalColumns

The referenced-object positions are judged only on types that render the picker. lookup and master_detail render LookupField (FieldEditWidget.tsx:105-106). user renders UserField, which delegates to LookupField with sys_user fixed (UserField.tsx:48). The target is the graph's referenceTargetOf answer. On any other type those keys have no reader and are not judged.

lookupColumns, dependsOn and index columns are read verbatim by their readers, so a dotted name there is judged as one name. relatedListColumns and lookupFilters[].field keep the family's path resolution. On dependsOn, a name that misses on the owner is reported once, not a second time against the referenced object. The field form rows that landed with #19332 G1b (field.form.ts:232-245) describe the same addresses, and none of them claims a refusal, so no published text changes.

Severity

error, the family's existing tier. os validate and os build turn author-time error findings into exit 1 (validate.ts and compile.ts, splitBySeverity then this.exit(1)). This was measured on the real built CLI over a two-object fixture (lookup invoice.account to account):

  • os validate: clean variant exit 0; misspelt variant exit 1 with 5 findings, one per position (index, relatedListColumns, lookupColumns .field, lookupFilters .field, dependsOn).
  • os build: clean variant exit 0; misspelt variant exit 1 with the same 5 findings.
  • The runtime publish door on an object write refuses too. This is pinned in the test file, with objects.proj_task.indexes[0].fields[1] and a lookup resolved against a context object.

Producers census

Measured before landing, with the rule run from source (tsx) over every exported object:

Corpus Objects Entries judged (idx / rLC / lC / lF / dO) Findings
platform, packages/**/*.object.ts at af444b4cd1 84 352 / 0 / 0 / 0 / 3 0
examples/app-showcase (+ platform: 108) 24 0 / 14 / 4 / 1 / 2 0
examples/app-crm (+ platform: 90) 6 0 / 0 / 0 / 0 / 0 0
examples/app-todo (+ platform: 85) 1 5 / 0 / 0 / 0 / 0 0
hotcrm src/**/*.object.ts at 5bec6eb0 18 68 / 13 / 0 / 0 / 6 0
hotcrm + platform 102 420 / 13 / 0 / 0 / 9 0

The hotcrm carriers match the card's reading exactly: relatedListColumns in 3 files, dependsOn in 4. There is no real misspelling in any producer, so nothing is fixed at a producer and nothing is handed to the hotcrm lane.

The controls are lit:

  • showcase: 5 planted misspellings (one per field-level position, both lookupColumns arms) gave 5 findings;
  • platform: 1 planted index misspelling (sys_metadata_audit) gave 1 finding;
  • hotcrm: 12 planted misspellings gave 12 new findings.

examples/app-multi-package declares 2 inline objects that carry none of these keys (population 0).

Tests (all at af444b4cd1)

  • @objectstack/lint: vitest run, 115 files, 5355 passed / 5 skipped. typecheck, including check:test-typecheck over the test layer, exit 0. The rule file went from 31 to 60 tests: per list, a misspelling is refused with its code and path, a correct name passes, and a name from the OTHER object is still refused. Both object arms are covered, plus user resolving to sys_user, the three skips, verbatim dotted names, and junk inertness.
  • @objectstack/cli --project unit: the first run gave 231 files passed, 3306 tests passed and 29 skipped. The other 2 files refused with the prerequisite "packages/cli is not built", which is not a red gate. After pnpm --filter @objectstack/cli build those 2 files passed, 29/29 tests. Integration tier is declared to CI.
  • examples/app-showcase: 29 files / 385 tests passed.
  • Runtime-door consumers: metadata-protocol protocol-publish-drafts-object-field-refs and build-probes-rule-failure (12 tests), plus platform-objects sys-email.highlight-fields-resolve (2 tests), all passed.
  • Filter direction: only the package itself (@objectstack/lint) and named consumers were run. No ...@objectstack/lint sweep was run, because the public surface (exports, types) is byte-unchanged; the behaviour narrowing is what the consumer runs above cover.

Ablation (one-shot, restored, not kept)

The changes were committed first. Each leg was applied through scripts/ablation-replace.mjs, whose anchor must hit, with the landing proven by anchor count 1 to 0 and a blob change. A shell trap restored HEAD on exit. The test file imports the rule's source, so no build was involved.

  • A, field-level slots emptied: 15 failed.
  • B, index walk emptied: 3 failed.
  • C, reference address collapsed onto the owner: 13 failed.

Restore was proven by blob equal to HEAD blob ac79635f and an empty git diff HEAD.

Gates

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at af444b4cd1 gave 60 commands, reconciled with --ran: 59 exited 0 and 1 is NOT MEASURED. The unmeasured one is pnpm check:dual-build-cjs-loads, which exited 3 with PREREQUISITE NOT MET because it reads every package's dist and 9 unrelated packages are unbuilt in this worktree. The four artifact-roster gates flagged under this diff's directories also exited 0: check-changeset-fixed, check:authz-resolver, check:error-code-casing and check:filter-alias-parity. ESLint, as a proven narrowing, ran on the 4 changed TS files (--format json: 4 files, 0 errors, 0 warnings). The config enables no type-aware linting (the printed config has parserOptions equal to ecmaVersion and sourceType only), so this diff cannot move a verdict on an untouched file. The repo-wide pnpm lint is CI's.

Acceptance notes

  • carrier: #20432: step 2, the sync half, belongs to the domain:engine seat's second PR on this card. SqlDriver.syncDeclaredIndexes logs a skipped declared index (unique above all) at warn. Its duplicate-row sibling in the same function logs at error through logDurabilityFailure. expectedIndexes drops the index from drift. The Studio save door (ObjectSchema.safeParse) still admits a misspelt name, so that half still matters after this PR.
  • Boundary: this rule judges existence only. An index column that names a real but virtual field (a formula) passes here and is still skipped at sync. Materialization stays the driver's question. It is written into the rule's docblock, which replaces the old "indexes[].fields[] is a storage question" paragraph (reason: that exclusion left a misspelling with no door at all).
  • Boundary: the build-probes receipt plane (metadata-protocol/src/build-probes.ts) runs this rule over a one-object universe. The referenced-object positions are therefore unknowable, and silent, there. Its owner-addressed positions are judged. The door-time gate judges both.
  • Boundary, pre-existing in the shared object graph: fields added by an objectExtensions entry are not merged into the graph. A list naming only such a field would be refused as a false finding. The census gave 0 findings in every producer measured, so no such case arose. Recorded here, not filed.
  • File surface beyond the named file, same package, comments only: reference-integrity-suite.ts's member note said the rule resolves names against the object's OWN field map only, which is now false for the picker keys, so it was rewritten. The index.ts export comment lists the new positions.
  • The changeset carries Clause-②: no (narrowing) with a BREAKING banner and minor, following the ADR-0087 gate (not-required (no-migration-prescription)) and the precedent of the security-role-word field-groups changeset. This body carries the claim's Clause-②: no verbatim.
  • origin/main moved during the run (dc0ab6a2ed). This branch was not re-merged. CI validates the merge ref. The gate derivation flagged one stale family file (scripts/cross-package-test-inputs.mjs), whose gate ran green on this tree.

Generated by Claude Code

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 20 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/lint/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

25 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 75b2169243911f54dbc967679489b7cd01125093.

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

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/index.ts) — 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 — 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 75b2169243911f54dbc967679489b7cd01125093 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 75b2169243911f54dbc967679489b7cd01125093

⚠️ 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 75b2169243911f54dbc967679489b7cd01125093 → 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: af444b4cd12f2bb8252ce319d94a9b676382310b
Local-runs: none

Inputs: card #20432 (body; comments 5870812245, 5871296642, 5872067468, 5874560373), PR #20479 (body, comment 5874529668, file list, the net diff of 5 files against merge-base 3cf6449), the objectui readers at the pin dd3f7e1b (.objectui-sha is identical at the head and at main), and the head's check-runs. Not read: the dispatch order or the dispatching seat's conclusions.

Check-runs on the head at judgment time: 34 total, 31 success, 3 skipped by path condition (Build Docs, Console Pin Gate, Packed-tarball smoke), 0 failed, 0 in progress. The nine that were in progress when the brief was written (Lint & Repo Gates, Test Core 1/2/3/5/6 and the rollup, Type Check · workspace, TypeScript Type Check) all concluded success.

① Derived judgments

Accept-set change: the rule object-field-ref-unknown (error; os validate / os build / os lint and the runtime publish door for object writes) refuses seven positions instead of two. Each new position, judged against its reader at the pin, not against the dev's prose:

  1. fields.F.relatedListColumns[i] against the OWNER (the child object), path-resolved — RIGHT. app-shell/src/utils/deriveRelatedLists.ts:281-292 emits childObject: child.name and columns: fieldDef.relatedListColumns off the child's FK field; plugin-detail/src/RelatedList.tsx:739-753 resolves the authored columns against the child objectSchema. Keeping path resolution is the lenient direction: a correct dotted column is never refused, a misspelt leaf still is.
  2. fields.F.lookupColumns[i] and [i].field against the REFERENCED object, verbatim — RIGHT. fields/src/widgets/LookupField.tsx:337,484,540: previewColumns are the authored lookupColumns, and buildLookupColumnDescriptors(previewColumns, refObjectSchema?.fields, referenceTo) reads fieldsMeta?.[col.field] as one key; core/src/utils/expand-fields.ts:263-274 matches columnIdentity(col) by set membership. No reader splits a picker column key on a dot, so a dotted name there addresses nothing and the verbatim refusal is a correct one.
  3. fields.F.lookupFilters[i].field against the REFERENCED object, path-resolved — RIGHT. RecordPickerDialog.tsx:151- lookupFiltersToRecord keys the filter record by f.field; LookupField.tsx:658-669 feeds it to useRecordQuery({ objectName: referenceTo }). The same binding validate-preset-comparands.ts:123-127,402-409 already makes for this key. Path resolution matches what an engine filter key admits.
  4. fields.F.dependsOn[i] and [i].field against the OWNER — RIGHT. LookupField.tsx:371-380 normalises a bare name to { field: d, param: d } and an object entry to param ?? field; :453-458 gates on resolvedDependentValues[field], the host record. useCascadingOptions.ts:28-57 reads the same key off the host's dependentValues for select / multiselect / radio, so judging the owner side on every type is right.
  5. fields.F.dependsOn[i].param, or the bare name, against the REFERENCED object on picker types only — RIGHT. LookupField.tsx:645-653 writes f[param] = value into dependentFilter, merged into the query on referenceTo (:658-669). Picker types: FieldEditWidget.tsx:105-107 maps lookup and master_detail to LookupField, and user to UserField, which delegates with reference: meta?.reference || 'sys_user' (UserField.tsx:48-58); referenceTargetOf (spec/src/data/field-value.zod.ts:194-201) answers an explicit reference first, else the type's implicit sys_user. tree is a reference type with no picker mapping at the pin and is correctly left unjudged on this side (silence, the safe direction). Reporting a bare name once, on the owner, is right: one fix answers both sides.
  6. indexes[i].fields[j] against the object itself plus injected columns, verbatim — RIGHT. driver-sql/src/sql-driver.ts:11858-11866 takes physicalColumns from columnInfo(), and :14192-14212 skips an index whose column is not in that set, at warn; schema-drift.ts:1818-1821 reads fields as verbatim strings (there is no direction-suffix spelling to preserve). The driver provisions id itself (table.string('id').primary()) and otherwise only what applySystemFields injects; the rule's injected set is resolveInjectedSystemColumns (spec/src/data/injected-system-columns.ts:139-185), the same plan. So id, created_at, organization_id under tenancy and owner_id under ownership: user resolve, and organization_id on a tenancy-off object is refused exactly where the driver would have no column to index.

False refusals, each a skip I verified in the code:

  • A registry-injected column in an index or any list: resolveFieldPath resolves the leaf through obj.injected (object-graph.ts:474-478); id is in the name set as PRIMARY_KEY_COLUMN. Pinned at validate-object-field-refs.test.ts:610-618.
  • A referenced object outside the checked stack (another package, a plugin not compiled in): graph.has(target) is false, the verdict is unknowable: object-not-in-stack, isUnjudgeable swallows it (object-graph.ts:441,470; the verbatim branch at validate-object-field-refs.ts:478). At the runtime door narrowObjectsToPackageClosure can only shrink that universe, which lands on the same silence. Pinned at test :470.
  • An ADR-0015 external object with no field map: graphObjectOf returns null, the whole object is skipped as owner (:539-540) and yields no-field-map as a target. An external object that declares fields is judged on them, this family's existing finding: author-time expression validation resolves injected anchors on external objects but cannot warn they are unprovisioned — the #7865 provenance marker is unreachable from @objectstack/lint #8116 stance, unchanged here.
  • A user field whose sys_user is not in the stack: the target is sys_user, graph.has is false, silent. Same branch as skip 1.
  • A lookupFilters[].field path through a relationship: verbatim: false walks the hops on the referenced object; a hop through an injected column is injected-hop, a hop with no target is hop-untargeted, both unjudgeable.
  • The REFERENCE slots on a non-picker type: target is undefined and the slot is skipped (:599,608-609). The platform's own dependsOn producers (sys-sharing-rule.object.ts: recipient_id is Field.text with widget: 'recipient-picker' and dependsOn: ['recipient_type', 'object_name']) are owner-only and pass.
  • The runtime door snapshot: buildRuntimeWriteSnapshots drops the same-named stored copy from the baseline and appends the written item to the candidate (runtime-gate.ts:646-655), so a field the write adds is in the graph. No stale-copy channel.
  • The raw lint path: a map-form fields goes through recordsOf; an unparsed object arm spelled with an alias ({ name }) reads as no name and is skipped, never refused.

Public surface: packages/lint/src/index.ts and reference-integrity-suite.ts change comments only; no export, type, rule id, severity or suite entry moves, and object-graph.ts is untouched. The member's runtimeTypes: ['flow', 'object'] is byte-identical to the merge-base (reference-integrity-suite.ts:348), so the runtime publish door is this rule's existing wiring since #15254, not a door this diff opens; the 422 is runtime-authoring-gate.ts:800. The rewritten suite note is accurate: a referenced object the snapshot does not carry is unknowable, so the missing-collection false-positive channel stays closed. The new consequence / prescription strings and the changeset prose carry no tracker number.

② Semver level

.changeset/20432-field-name-list-refs.md: @objectstack/lint: minor, a BREAKING banner, Clause-②: no (narrowing), adr-0087: not-required (no-migration-prescription). RIGHT for what the diff publishes: nothing widens (no key, export or payload grows), the accept set narrows at three existing doors, AGENTS.md makes (narrowing) BREAKING, and check-changeset-no-major refuses major in the launch window, so the banner carries the breaking-ness. Same form as .changeset/18306-role-word-field-groups.md and 19370-role-word-crosses-whole.md. No FROM → TO mapping is owed: nothing is renamed or retired, and the one-line fix is in the body (fix the name the finding points at or drop the entry; write param when the two sides differ). Check Changeset and Lint & Repo Gates are green on this head.

The claim about already-stored objects is honest: because the baseline excludes the same-named stored copy, an object republished with an unchanged misspelling is judged as new and refused; a draft save is not judged (D1); the changeset states both. #4463 D4 still keeps a sibling's stored violation off a clean write (test :138-146).

Clause-②: line: the PR body carries Clause-②: no (the claim's line verbatim), the changeset no (narrowing). The value is truthful (this diff widens nothing) and the fleet's one reader, scripts/pm/clause2-line.mjs, makes the arm optional (an absent arm declares no direction), so the body line is a legal declaration and the two gates read the arm where it lives, in the changeset. Not a FAIL. Recommendation to the seat, no code change: amend the body line to Clause-②: no (narrowing) so a reader of the PR alone sees the breaking direction the changeset declares.

③ Boundary flags

Dev flags (deviations in 5874560373 and the PR's Acceptance notes):

  1. Comment-only edits in reference-integrity-suite.ts and index.ts — answered: both notes are now true, and the old own-field-map sentence was false for the picker keys.
  2. Body Clause-②: no against changeset no (narrowing) — answered in ②.
  3. The runtime publish door refuses the five positions beyond the card's wording — answered in ① and ②: existing wiring, honestly stated in the changeset.
  4. origin/main not re-merged (main moved to dc0ab6a during the run) — answered: the PR reads mergeable, the only differences against current main outside this diff are validate-component-props.* from PR fix(lint): waive only a MISSING object beside a dataSource binding #20454 (not this branch), and CI ran green on the merge ref.
  5. Turbo cache hits during closure builds; the CLI integration tier left to CI — answered: this review is read-only; Dogfood Verify CLI, Test Core and Build Core are green on the head.

out_of_scope_findings:

  • carrier: #20432 step 2 (SqlDriver.syncDeclaredIndexes at warn, drift drops the index) — answered: matches triage 5871296642 direction 2, the domain:engine PR on this card; the card stays open for it.
  • A formula (virtual) index column passes existence and is still skipped at sync — answered: existence-only is the rule's stated scope (docblock and changeset); materialization folds into step 2.
  • build-probes.ts runs the rule over a one-object universe, leaving the REFERENCE positions silent on that plane — answered: silence, not refusal; the door-time gate judges both.
  • Fields contributed by objectExtensions are absent from the shared object graph — ESCALATED to the seat. Verified: indexObjectGraph reads objects[].fields only, and no CLI-side fold exists (the runtime registry folds extenders for listItems, but the written item itself replaces that folded copy unfolded). The channel is pre-existing for highlightFields / redactFields and every field-existence rule on this seam, and the census gave 0 findings, so it is not a defect this diff introduces; but the diff moves the exposure from 2 positions to 7 at error, and the dev left it noted, not filed. Whether a seam card against packages/lint/src/object-graph.ts is owed under Prime Directive chore: version packages #10 (a false refusal on valid metadata is an authoring trap) is the seat's call.
  • hotcrm 0 findings at 5bec6eb0 — dev-measured, not re-run here; the carrier counts match the card's own reading.

open_questions: none declared, none found.

Implemented-by: claude/issue-20432-field-name-list-refs
Reviewed-by: session_014EJ1ED8X4MMrT18BhVx4tx

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 28, 2026 17:13
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
Merged via the queue into main with commit 4b2d904 Sep 28, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20432-field-name-list-refs branch September 28, 2026 17:36
os-warren pushed a commit that referenced this pull request Sep 28, 2026
…dded

Since object-field-ref-unknown judges indexes[].fields, publishing and
os validate refuse an index column that names no field of the object; a
draft save (the schema parse) still does not check. A real field that is
not a stored column (a formula) is still skipped whole by the SQL driver
with a warning. The help text, its comment and its four catalogue
leaves now say exactly that.

Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH
Co-authored-by: Claude <noreply@anthropic.com>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…d inlineColumns form rows (objectstack-ai#19332, flight G2b) (objectstack-ai#20485)

Part of objectstack-ai#19332
Flight G2b of ruling 5861442317.

Clause-②: no

## Status: draft, no open gap

The first round stopped at one red test outside the claim's surface,
`packages/lint/src/validate-predicate-path-refs.test.ts`, the lint
census of every predicate the shipped metadata forms carry. The claim
both admitted mechanically moved population pins and forbade
`packages/lint/**`. The seat answered A and amended claim `5873857698`
in place (its "Amended 2026-09-28T17:27Z" line): that one file joined
the surface for its census pin only. The patch round landed it in
`16037890` (**Pins moved** below), and the file reads 54 of 54.

Under the claim's second amendment ("Amended 2026-09-28T17:51Z"),
`2bcad436` corrects one G2a text: the `indexes.fields` sub-row's help
text (and its code comment and four catalogue leaves) now reads "Saving
does not check them; publishing and os validate refuse a name that is
not a field of this object. A field that is not a stored column (a
formula, say) makes the SQL driver skip the whole index, with a warning
in the server log." Each clause was measured on this tree after
`4b2d9041` (objectstack-ai#20479): `ObjectSchema.safeParse` accepts a misspelt column;
`os validate`'s rules and the runtime publish gate
(`runRuntimeAuthoringRules`, type `object`) both return
`object-field-ref-unknown` at `error` on it; and an in-memory SQLite
`SqlDriver` sync skips an index on a formula field (and one on a
misspelt name) with `[sql-driver] skipping declared index … column(s)
not materialized` at `warn`, while the index on a stored column is
created.

## What

Four live keys had no form row, so an author could reach them only
through the Source tab. Each is now a row with hand-written sub-rows, as
the ruling says: 「**G2 (…) — hand-written curated sub-rows**, plus … one
nested `subset` row for `inlineColumns`」. The four-locale catalogue rows
are in this PR.

| key | form, section | row, sub-rows (face) | the row each copies |
|:--|:--|:--|:--|
| `object.activityMilestones` | `object.form.ts`, Advanced, after
`validations` | `type: 'repeater'`: `field` (`widget: 'text'`,
required), `value`, `summary` (text, required), `type` (text) | the
`fieldGroups` repeater face (declared, labelled sub-rows); the text
sub-rows copy the plain text rows in Basics |
| `object.publicSharing` | `object.form.ts`, Advanced, after
`requiredPermissions` | `type: 'composite'`: `enabled` (switch),
`allowedAudiences` and `allowedPermissions` (`widget: 'multiselect'`
with inline options), `maxExpiryDays` (number, `min: 1`), `redactFields`
(`widget: 'string-tags'`), `eligibility` (`type: 'code'`, `language:
'expression'`) | the `access` / `lifecycle` composite face; `enabled`
the `enable` toggles; the two lists the multiselect objectui derives for
an array of enum (the derived `appearance.allowedVisualizations` on the
view and page forms); `redactFields` the `highlightFields` row;
`eligibility` the `fields.visibleWhen` predicate rows |
| `object.userActions` | `object.form.ts`, Advanced, under `managedBy` |
`type: 'composite'`: `create`, `import`, `edit`, `delete` (`widget:
'json'`), `exportCsv` (switch) | the composite face; the four union keys
the G1a `requiredPermissions` row (`json` on a union); `exportCsv` the
`enable` toggles |
| `field.inlineColumns` | `field.form.ts`, Configuration, between
`inlineTitle` and `inlineAmountField`, gated `data.type ==
'master_detail'` like both | `type: 'repeater'` over a curated subset:
`name` (text, required), `label` (text), `width` (number),
`defaultHidden` (switch) | the `fieldGroups` repeater face; the gate
copies its two sibling rows |

**Shapes (dispatch assumption 2), confirmed on this base:**
`activityMilestones` is `z.array(strictObject(…))` at
`object.zod.ts:2093`, four keys; `publicSharing` is a `strictObject` at
`:2286`, six keys; `userActions` a `strictObject` at `:1769`, five keys;
`inlineColumns` is `z.array(InlineGridColumnSchema)` at
`field.zod.ts:1460`, the item schema at `:890`, twenty keys. The gate's
own `keysOf` read the same sets.

**Ledger:** one nested `subset` row at `field` / `inlineColumns` in
`metadata-form-zod-reconciliation.test.ts`. Its shape is the `object` /
`fields` subset row at the top of the ledger (and its two depth-two
children `fields.options`, `fields.summaryOperations`), which is the
ledger's `subset` precedent. The other three keys need no row: every key
they declare is offered.

**Row titles:** the two new repeaters' row schemas carry a JSON Schema
`title` on every property (**Row titles** below).

## Faces that needed a reason

- **`activityMilestones.field` pins `widget: 'text'`.** Read at the
`.objectui-sha` pin on `main`, `dd3f7e1b`: with no `widget`,
`SchemaForm`'s `resolveFieldWidget` runs its name conventions, and
`detectFieldRefWidget` turns a string property named `field` into the
`field-ref` picker whenever `widgetContext.objectFields` is present.
`ResourceEditPage` always hands that over as a load state, and on an
object draft it is `idle` (the draft names no `object` / `objectName` /
`data.object` / `interfaceConfig.source`). `FieldRefWidget` then renders
a select offering only "None", so a new milestone could not name its
field. An explicit `widget` skips the conventions, and `text` is a
passthrough hint, so the face is a plain input.
- **`redactFields` pins `widget: 'string-tags'`** for the same reason: a
string list named `...Fields` becomes `field-multi` by the same
convention.
- **`userActions.create` / `import` / `edit` / `delete` take `widget:
'json'`**, the ruling's union rule (「Union-typed values take `json`」).
Each is a boolean or a strict `{ enabled, visibleWhen, disabledWhen }`
object. At the pin, `json` is a passthrough hint: `resolveFieldFace`
picks the stored value's union branch. A new entry or a stored boolean
renders the switch (the first arm), and a stored object renders its
three keys as a nested form, whose `setField` merges each edit into it.
No face writes one arm over the other. The object arm is written in
source and edited here once stored.
- **`allowedAudiences` / `allowedPermissions` take `widget:
'multiselect'`** with inline options. Every member is a spellable option
value. `MultiSelectWidget` writes `undefined` when every choice is
cleared, so the form cannot store the empty list `getPolicy` reads as
"any audience".
- **The objectui faces re-checked here are the ones G2a did not use**,
read at `dd3f7e1b` (G2a read the repeater face at `f8a9d0fb`): the
declared composite (`CompositeField`, `pickSubSchema` reading
`properties[NAME]` after `inlineSchemaRefs`), the multiselect widget,
the switch and number branches of the scalar chain, and the `json` hint
on a union sub-row. Code readings only, no browser run.

## `inlineColumns`: the curated subset

- **Offered:** `name`, plus the three keys that apply to a column of any
type, `label`, `width` and `defaultHidden`. An entry that names only a
field is what the key's own describe recommends, because objectui's
`hydrateColumns` completes it from the child field.
- **Deliberately not offered (recorded in the `subset` row):**
  - `type`: declaring it opts the column out of that hydration.
- `options`, `reference`, `displayField`, `idField`, `autofill`,
`multiple`, `accept`, `prefix`, `step`, `scale`, `computed`, `expr`:
each applies to one cell type only. A column takes its type from the
child field at render, and no sub-row `visibleWhen` here can see it, so
each would be offered on every column.
- `required`, `readonlyWhen`, `requiredWhen`: hydration copies them from
the child field, where the rule the server enforces lives.
- The nested reading below shows the row is load-bearing: without it,
the gate's `zodOnly` for `field.inlineColumns` is exactly those sixteen
keys.

## Where a misspelt field name is refused, read from the code

The ruling's 「a misspelling is refused loudly at parse」 does not hold
for three of this flight's four name positions. Each help text claims
only what is measured.

| position | parse | publish door / `os validate` | runtime with a miss
| help text claims |
|:--|:--|:--|:--|:--|
| `publicSharing.redactFields[]` | accepts | **refused**:
`validate-object-field-refs` owns it at `error` (`runtimeTypes` includes
`object`); probe below | the redaction never binds (fails open) |
"refused at publish" |
| `activityMilestones[].field` | accepts (probe) | not judged:
`validate-object-field-refs` leaves it out by name; probe below |
`matchMilestone` compares `after[field] === value`, so the milestone
never fires | "Nothing checks it when you save or publish … never fires"
|
| a `{token}` in `activityMilestones[].summary` | accepts | not judged |
`renderMilestoneSummary` renders it empty | "a token that names no field
renders empty" |
| `inlineColumns[].name` | accepts (probe) | not judged; probe below |
`hydrateColumns` leaves an unknown name unhydrated, a plain text column
| "Nothing checks it when you save or publish … renders a plain text
column" |

Seat 2's objectstack-ai#20479 (for objectstack-ai#20432), which landed on `main` as `4b2d9041`
during this flight, extends `validate-object-field-refs` to four
field-level lists and `indexes[].fields`. Read on `origin/main`, its
list positions still do not include `activityMilestones[].field` or
`inlineColumns[].name`, so these texts stay true (**Out-of-scope
finding** below).

## Other help-text claims, each read from its consumer

- `activityMilestones`: an update that moves the field into the value
writes the summary in place of the field-change entry, and the first
match wins (`audit-writers.ts` `matchMilestone`). The comparison is
strict, and `value` is a string, so a milestone on a number or boolean
field never fires. A lookup, master-detail or user token shows the
referenced title (`REFERENCE_FIELD_TYPES`). An unset `type` is
`updated`: the update branch starts from `activityTypeFor('update')` and
a milestone replaces it only when it names one. (The schema's describe
says the default is "completed"; see Acceptance notes.)
- `publicSharing` (`share-link-service.ts`): `enabled` is re-read on
every redemption; unset audiences default to `['link_only']` and
permissions to `['view']`; `createLink` refuses any other with 422.
Every audience still needs the token: `resolveToken` adds a signed-in
check for `signed_in` and an allowlist check for `email`.
`maxExpiryDays` defaults to 365, and a link created without an expiry is
stored with none (`expiresAt ?? null`), so the cap does not force one.
`eligibility` binds `record`, is checked at mint and at every
redemption, and a predicate that does not compile or faults refuses.
- `userActions` (`resolveCrudAffordances`): the per-bucket defaults in
the help text are `CRUD_AFFORDANCE_DEFAULTS` verbatim. On an
`engine-owned` or `append-only` object, turning a verb on also passes
plugin-security's `assertEngineOwnedWriteAllowed`, so users can make
that write through the data API.
- `inlineColumns`: read only when the field sets `inlineEdit`
(`attachInlineSubforms`). Unset, `deriveColumns` curates past six
columns into the column chooser. `defaultHidden` never hides a required
column (`GridField`: `c.defaultHidden && !c.required`).

## Row titles (admitted by the claim from the start)

- **The guard.** `repeater-item-titles.test.ts` (objectstack-ai#17232) requires a JSON
Schema `title` on every authorable property of every repeater's row
schema, and forbids a ledger entry. Both new repeaters are new carriers:
`object:activityMilestones` and `field:inlineColumns`.
- **The change.** 24 `.meta({ title })` calls, and nothing else in
either file:
- `object.zod.ts`, the `activityMilestones` entry: `field` 'Field',
`value` 'Value', `summary` 'Summary', `type` 'Type'.
- `field.zod.ts`, `InlineGridColumnSchema`, all twenty properties: Name,
Label, Type, Width, Required, Options, Prefix, Step, Reference, Display
Field, ID Field, Multiple, Accept, Default Hidden, Computed, Expression,
Scale, Autofill, Read-only When, Required When.
  - The four offered sub-rows' titles equal their declared labels.
- **Byte proof.** Stripping exactly the added calls line by line gives
each file's base blob byte for byte: `object.zod.ts` sha256 prefix
`8979b5feea7ed0ff` both ways (4 removed), `field.zod.ts`
`24713de3b50d5f71` both ways (20 removed).
- **Reverse verification.** Run through `scripts/ablation-replace.mjs`
in wrap mode, on the committed state. Deleting the `Summary` title reads
`object:activityMilestones … expected [ 'summary' ] to deeply equal []`,
1 failed of 29. Deleting the `Default Hidden` title reads the same for
`field:inlineColumns` with `[ 'defaultHidden' ]`. The tool proved each
mutation landed (anchor 1 → 0, blob changed) and each restore (blob
equals HEAD, `git diff HEAD` empty).
- **No accept set moves.** `check:generated` reads all 15 artifacts up
to date on this head, `check:authorable-surface` and `check:api-surface`
included.

## Residue of the reconciliation gate (dispatch assumption 1)

The test file's own helper block was copied verbatim into a probe that
was never committed, and run with the gate's own functions. At base it
is lines 1-838, sha256 prefix `5e04d44fc5c5edb3`, the prefix G2a read.
On this branch it is lines 1-851, and it differs from the base block
only by the 13 inserted ledger lines. Residue = offerable root keys −
offered − root `omit` rows, per type, with `view` apart.

Controls, asserted inside the probe: lit, `name` is offered by 17 of 17
forms; dark, `object.zzFabricated19332G2b` and `object.name` are in no
residue.

| tree | residue | per type | view |
|:--|:--|:--|:--|
| base `e956924e` | **4** | object 3, field 1 | 42 |
| this branch (`82c5b111`, forms and ledger as on the head) | **0** |
none | 42 |

Removed: `object.activityMilestones`, `object.publicSharing`,
`object.userActions`, `field.inlineColumns`. Added: none.

Nested reading on the branch, through the gate's own
`reconcileNestedLists`:
- `field.inlineColumns` reads `zodOnly = []` with the `subset` row, and
without it `zodOnly` = `accept, autofill, computed, displayField, expr,
idField, multiple, options, prefix, readonlyWhen, reference, required,
requiredWhen, scale, step, type`.
- `object.activityMilestones`, `object.publicSharing` and
`object.userActions` read `formOnly = [] · retired = [] · zodOnly = []`
with or without any row of their own.

## Pins moved (measured, mechanical)

| file | pin | from → to | why |
|:--|:--|:--|:--|
| `object-collapsed-sections-echo-decisions.test.ts` | collapsed-section
leaves / `advanced` | 69 → 105 / 60 → 96 | three new Advanced rows with
fifteen sub-rows: 18 rows, 36 leaves |
| `object-lifecycle-panel-echo-decisions.test.ts` | translated `.label`
control, per locale | 634 → 657 | 23 new row labels |
| `field-panel-echo-decisions.test.ts` | the field form's repeater row
properties / walked parents | 6 → 10 / `['options']` → `['options',
'inlineColumns']` | the new `inlineColumns` repeater and its four
children, all translated |
| `packages/lint/src/validate-predicate-path-refs.test.ts` (admitted by
the claim's 17:27Z amendment) | predicates / literal comparisons | 81 →
82 / 56 → 57 | the one new predicate, `field :: inlineColumns` on
`data.type == 'master_detail'`. Measured, not inferred: the shipped
corpus, keyed `FORM::FIELD::SOURCE`, was enumerated at the merge base
`e956924e` (81 predicates, 56 comparisons) and on this branch (82, 57),
and the difference is exactly that one entry added and none removed |

## Verification

Test runs went through `scripts/pm/os-verify-lock.sh`. The table is the
first round's, at `66b73be5`, and the lint row is the patch round's, at
`16037890`. After the merge and the G2a text correction, these re-ran at
the final head `2bcad436`: `pnpm --filter @objectstack/spec test` `Test
Files 572 passed (572)` · `Tests 16791 passed \| 1 todo (16792)`; `pnpm
--filter @objectstack/platform-objects test` `Test Files 55 passed (55)`
· `Tests 911 passed (911)` (the text change moves no pin); lint
`validate-predicate-path-refs.test.ts` +
`validate-object-field-refs.test.ts` 2 files, 114 passed; `pnpm
check:i18n` OK (9 packages in sync) after the three translated leaves
were authored and a second `--write` left no source-hash row; `pnpm
--filter @objectstack/spec check:generated` `All 15 generated artifacts
are up to date`.

| run | result |
|:--|:--|
| `pnpm --filter @objectstack/spec test` | `Test Files 569 passed (569)`
· `Tests 16698 passed \| 1 todo (16699)` |
| `pnpm --filter @objectstack/spec test:repo` | `Test Files 38 passed
(38)` · `Tests 690 passed (690)` |
| `repeater-item-titles.test.ts` +
`metadata-form-zod-reconciliation.test.ts` |
`repeater-item-titles.test.ts` 29 +
`metadata-form-zod-reconciliation.test.ts` 57: `Test Files 2 passed (2)`
· `Tests 86 passed (86)` |
| `pnpm --filter @objectstack/platform-objects test` | `Test Files 55
passed (55)` · `Tests 911 passed (911)` (before the pin moves: 4 failed,
the three pins above) |
| `pnpm --filter @objectstack/spec typecheck` / platform-objects
`typecheck` | exit 0 both; `check:test-typecheck: OK` (53 file(s) / 251
error(s) / 138 pinned; 1 / 3 / 2) |
| `pnpm check:i18n` | `check-i18n-bundles: OK (9 package(s) — all
bundles in sync, no undeclared authoring keys)` |
| `pnpm --filter @objectstack/spec check:generated` | `All 15 generated
artifacts are up to date` |
| metadata-protocol `src/protocol.meta-types-*.test.ts` | 4 files, 58
passed |
| cli unit `test/i18n-coverage.test.ts`,
`test/i18n-duplicate-demand.test.ts` | 2 files, 27 passed |
| lint `src/validate-predicate-path-refs.test.ts` at `16037890` | `Test
Files 1 passed (1)` · `Tests 54 passed (54)` (2 failed before the pin
move, the two pins above) |

Catalogues: `node scripts/check-i18n-bundles.mjs --write` regenerated
the 46 `en` leaves (23 rows, a label and a help text each). The 138
translated leaves were then authored in zh-CN, ja-JP and es-ES, with no
`en` echo. A second `--write` kept every translated value and left no
source-hash row.

Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 87 commands at the final head
`2bcad436` (change set vs merge base `9801da12`, after the merge
`e809f0bd`: the 14 files of this diff). That is the first round's 86
plus `check:docs-transcript-drift`, which the lint test file brings in.
All 87 ran on that head, and each exit code went to disk before it was
read. In every round `pnpm check:dual-build-cjs-loads` first exited 3
(PREREQUISITE NOT MET: nine packages had no `dist/` in the fresh
worktree); later gates in the same run built them, and the rerun exited
0 (`104 published require entry point(s) across 66 package(s) load`),
which is the code `ran.list` records. `--ran` reports: `87 derived
famil(ies) accounted for — 87 run, 0 NOT-MEASURED (a DERIVED zero — all
87 recorded an exit code and none of them is 3)`.

Reach probe (built `@objectstack/spec` and `@objectstack/lint` of this
tree, never committed): an object with `activityMilestones: [{ field:
'statsu', … summary: 'Done: {titel}' }]` and
`publicSharing.redactFields: ['titel']`, and a child `master_detail`
field with `inlineColumns: [{ name: 'quantiy' }]`.
`ObjectSchema.safeParse` and `FieldSchema.safeParse` both succeed. The
reference-integrity suite, which the publish door and `os validate` run,
returns exactly one finding, `object-field-ref-unknown @
objects[0].publicSharing.redactFields[0]` (the lit control), and none
for the milestone field, the token or the column.

## Acceptance notes

- **`activityMilestones[].type`'s describe says the default is
"completed".** The runtime writes `updated`: the update branch's
`activityTypeFor('update')`, replaced only by a milestone that names a
type. The help text states the runtime. The showcase milestone names
`type: 'completed'` explicitly, so no measured author relies on the
describe. Carrier: none.
- **The `public` audience's TSDoc (`object.zod.ts`) says "search engines
may index; no token check".** `resolveToken` has no branch for `public`:
it redeems like `link_only`, token required. The option label says only
"Public", and the help text says every audience needs the link. Carrier:
none.
- **`maxExpiryDays` does not force an expiry.** A link created without
one never expires. That matches the key's describe ("Reject links with
expiry beyond this many days"), and the help text says it outright.
Whether a capped object should require an expiry is a product question.
Carrier: none.
- **An untouched `userActions` switch reads off** even where the
`managedBy` default offers the entry, a switch having no unset state.
The composite's help text names the defaults. Carrier: none.
- **Existing object-form rows named `field` meet the same `field-ref`
convention.** `lifecycle.ttl.field` has `type: 'text'` and no `widget`,
so by the reading above it renders the "None"-only picker on an object
draft. This is a code reading at `dd3f7e1b`, not browser-run, and it is
outside this flight's rows. (`fields.summaryOperations.field` sits
inside the `fields` row, which the Studio object page hides as
canvas-owned.) Carrier: none.
- **Concurrency.** `origin/main` was merged once, with
`scripts/pm/os-regen-merge.sh`, at `9801da12` (`e809f0bd`), because
objectstack-ai#20456's `e967cbd2` edited three `view` `why` texts in the
reconciliation ledger. It merged without conflict, `main`'s side was
taken for every generated artifact it moved, and `check:generated` then
read all 15 up to date. `origin/main` has moved since (to `3062e500`),
not onto a file of this diff. Seat 2's objectstack-ai#20475 regenerates
`en.metadata-forms.generated.ts` too and is not on `main` yet: ordinary
concurrency.
- **G2a's `indexes.fields` help text went stale when objectstack-ai#20479 landed, and
is corrected here** (Status, second paragraph), under the claim's 17:51Z
amendment. No other G2a row changes.

## Out-of-scope finding (folded into objectstack-ai#20432 by the seat; not filed by
this run)

- **class c · reach: the save door and the publish door, measured (probe
above).** `activityMilestones[].field` and `inlineColumns[].name` name
fields of the owning object, and no authoring door judges them. A
misspelt milestone field silently never fires, and a misspelt column
renders as plain text. `validate-object-field-refs` leaves the first out
by name. Its extension objectstack-ai#20479, landed as `4b2d9041`, reaches four
field-level lists and `indexes[].fields`, but neither of these.
- Dedupe words: `activityMilestones field unknown` · `inlineColumns name
unknown field` · `milestone never fires misspelt field` · `inline grid
column reference integrity`.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…t error and reported in drift (objectstack-ai#20519)

Fixes objectstack-ai#20432
Clause-②: yes (widening)

Step 2 of 2 on this card, and the step that completes it. Step 1, the
lint refusal, landed as PR objectstack-ai#20479.

## What was wrong (measured on `origin/main` 9bf5e67, SQLite)

Take an object whose only column is `status` and that declares `indexes:
[{ fields: ['statsu'], unique: true }]`:

- `initObjects` skipped the whole index and logged one **warn**:
`[sql-driver] skipping declared index on "t_probe" — column(s) not
materialized: statsu`.
- `detectManagedDrift`, which is what `os migrate plan` renders,
returned `[]`, because `expectedIndexes` drops the index from the
expected set.
- A virtual `formula` field named in an index was skipped the same way,
also at warn.

So the declared uniqueness was not enforced, and every instrument said
nothing was wrong. AGENTS.md "Degradation log levels" asks: does the
system still look normal from the outside while something it claims is
persisted has not actually landed? Yes, so the level is `error`. The
duplicate-row arms of the same loop already answered their version at
`error` through `logDurabilityFailure`.

## What changed

**`sql-driver.ts`, `syncDeclaredIndexes`.** The skip now goes through
`logDurabilityFailure`, at **error**, as triage's direction says. One
line per skipped index per sync names:
- the object and the index;
- each missing column with its reason: `not a field of the object`, `a
formula field: computed on read, never stored`, or `a declared field
whose column the table does not have`;
- whether the index is `UNIQUE`.

The line also states the consequence and the fix. A unique index gets
"The uniqueness it declares is NOT enforced: duplicate rows are
accepted…". A plain index gets "The index does not exist…".

The structured meta carries `{ tableName, index, fields, missing, unique
}`. The object's `fields` are threaded in from `syncTableIndexes` and
from the shard path; the drift-op apply paths pass none and get the bare
column names. H5: a skipped non-unique index is also at error, because
it is DDL that was supposed to run and did not. The message tells the
two apart by the `UNIQUE` word and the consequence sentence, and the
meta tells them apart by `unique`.

**`schema-drift.ts`, drift.** A new pure differ,
`diffUnbuildableIndexes`, reports the half of the declared set that
`expectedIndexes` leaves out. It shares `declaredIndexSet` with
`expectedIndexes`, so the two cannot disagree about which indexes
metadata asks for. An entry is emitted only when a missing key column
will NEVER materialize: not a declared field, or a declared virtual
`formula`. A declared, column-materializing field that the table merely
lacks is pending additive work (`add_columns`), and is not reported. The
entry:

- `kind: 'index_mismatch'` (an existing `SchemaDiffEntryKind`, so spec
is unchanged), `actual: '(absent)'`;
- `category: 'needs_confirm'` (the report-only precedent of
`manual_column_type_change`);
- `severity: 'error'` when unique, `'warning'` otherwise (the
`recreate_index` convention);
- op: the new **report-only** `DriftOp` member, `{ type:
'unbuildable_index'; table; column?; indexName; unique; missingColumns
}`.

It sits in `INDEX_DRIFT_OPS`. `applyIndexDriftOp` answers `false` for it
before any read, so apply reports it `skipped` on every dialect and it
never triggers a SQLite rebuild.

**Public surface (H3).** This is the one widening, and the seat
pre-cleared it before the build: amended claim 5877196132, `Clause-②:
yes (widening)`. The changeset is `@objectstack/driver-sql` **minor**.
No CLI source changes.

**The object form's help text (deviation, see below).** In
`packages/spec/src/data/object.form.ts`, the `indexes` → Fields help
said the skip leaves "a warning in the server log". This change made
that false, so it now says an error. The en bundle is regenerated with
`node scripts/check-i18n-bundles.mjs --write`. The zh-CN, ja-JP and
es-ES values are edited by hand, one word each. The changeset adds
`@objectstack/spec` and `@objectstack/platform-objects` as patch, and
(patch round 2) `@objectstack/lint` as patch for the corrected rule
message.

## Consumer census: every in-repo reader of `DriftOp`, `DriftOp['type']`
and `INDEX_DRIFT_OPS`

I searched with `git grep` (outside `dist/`) for `DriftOp`,
`INDEX_DRIFT_OPS`, `isIndexDriftOp`, `ColumnDriftOp`, `IndexDriftOp`,
`op.type` and `ManagedDriftEntry`. No `never`-exhaustiveness check over
`op.type` exists anywhere, and no `Record` is keyed by
`DriftOp['type']`, so the new member breaks no typecheck. Measured:
`driver-sql`, `driver-turso` and `cli` typecheck exit 0 at 7ec990a.

| Consumer | What it does with `unbuildable_index` |
|:---|:---|
| `driver-sql` `applyMigrationEntries` | `isIndexDriftOp` is true, so it
takes the index path, never the SQLite rebuild. **Pinned.** |
| `driver-sql` `applyIndexDriftOp` / `applyDriftOpInPlace` | Explicit
early `return false`, so the entry is reported `skipped`. **Pinned.** |
| `driver-sql` `applyNullSafeUniquePreflight` | Probes `create_index` /
`recreate_index` only, so this entry is untouched. |
| `driver-sql` `reconcileAndWarnDrift` (boot) | Auto-reconciles only
`safe` entries. This one is `needs_confirm`, so it is warned once per
process through the existing `[schema-drift]` line. `driftKey` includes
`indexName`. |
| CLI `schema-migrate.ts` `renderPlan` / `driftTarget` /
`groupByCategory` / `summarize` | Generic: `category`, `op.indexName`,
`op.type`, `message`. `os migrate plan` lists the entry under "Needs
confirmation" as `table [indexName] [unbuildable_index]`. **Pinned.** |
| CLI `migrate/plan.ts` | Renders through the above, and `--json` emits
the entry as-is. The exit code does not depend on drift. |
| CLI `migrate/apply.ts` | The entry counts like any `needs_confirm`
entry (non-TTY without `--yes`: "Confirmation required"), then it is
skipped, and the summary prints "Skipped N change(s)". The only
`op.type` read (line 348) is a `drop_column` / `sys_account` filter,
which this entry does not match. |
| CLI `multi-value-columns.ts` | Selects only
`manual_column_type_change`. Unaffected. |
| CLI `artifact-boot-migration.ts` (the artifact-pinned boot gate) |
Refuses only `category === 'destructive'`. This entry is handed to the
driver, reported `skipped`, and **warned** ("schema change not applied
by the driver"), and the **boot continues**. **Pinned.** |
| `driver-turso` | Extends `SqlDriver`, so the local face inherits the
fix. The remote face already refuses `detectManagedDrift` /
`applyMigrationEntries`. Suite: 76 files, 2037 passed. |

## The two folded boundaries

- **A virtual `formula` column in an index: pinned.** It is not
materialized, and it is skipped. The error names it `'doubled' (a
formula field: computed on read, never stored)`, is not marked `UNIQUE`,
and has meta `unique: false`. Drift reports it at `severity: 'warning'`.
A field-level `unique` on a formula field takes the same route
(pure-differ pin).
- **`objectExtensions` fields: not reached.** Measured at BASE with the
real `SchemaRegistry` (a throwaway probe, not committed):
- Register a base object whose index names an extension's field, plus
two extensions, one of whose indexes names the other's field.
- `getAllObjects()` returns the merged `fields` (base and both
extensions) and the merged `indexes`, and `SqlDriver.syncSchema` of that
merged object built both indexes (`uniq_h4b_account_ext_code`,
`idx_h4b_account_ext_tier`), with no skip line and empty drift.
- `ObjectQLPlugin` syncs from `registry.getAllObjects()`, so the driver
only ever sees merged objects. The lint-graph gap is step 1's boundary,
and sync does not reproduce it.

## Tests (at 7ec990a unless stated)

- New
`packages/drivers/driver-sql/src/sql-driver-20432-unbuildable-declared-index.test.ts`:
- the dialect matrix through `declareDialectCell`: misspelt UNIQUE (log
+ drift + apply-skips), formula plain index (log + drift), and a
buildable control that is enforced, with no error line;
- 5 pure-differ cases: pending column not reported, a mixed index names
only the never-materializing column, field-level unique on a formula,
the split against `expectedIndexes`, and reason kinds.
- SQLite plus live PostgreSQL 16 (local server, `Asia/Shanghai` server
zone, `TZ=America/New_York`): 15 passed, 1 skipped. **Live MySQL: NOT
MEASURED locally** (no server in this container); declared to CI's
`Temporal Conformance (live PG + MySQL)`.
- New
`packages/cli/src/utils/artifact-boot-migration.unbuildable-index.test.ts`
(integration tier, real `SqlDriver` from `dist`, real gate and
renderer): 3 passed.
- `driver-sql` full suite at 2f21af6, the head before the merges (the
merges bring nothing under `packages/drivers`): SQLite plus live
PostgreSQL gave 206 files passed / 3 skipped and 3968 tests passed / 93
skipped. SQLite only gave 198 / 11 and 3198 / 184.
- `cli` unit tier at 2062104: 233 files, 3336 passed. `driver-turso`:
76 files, 2037 passed / 18 skipped.
- `spec` `--project local`, 3 shards, at 797da30: 6070 + 5193 (+1
todo) + 5531 passed. `platform-objects` at 7ec990a: 56 files, 921
passed.
- Typecheck exit 0: `driver-sql`, `driver-turso`, `cli` (including
`check:test-typecheck`) at 7ec990a; `spec`, `platform-objects` at
797da30.
- **Cross-package reverse check.** A throwaway CLI file typed `{ type:
'unbuildable_index', … }` without `missingColumns` got `TS2322 …
Property 'missingColumns' is missing … required in type '{ type:
"unbuildable_index"; …'`, so the CLI reads the rebuilt `.d.ts`. The file
was removed and the tree is clean.
- **Ablations**, from committed state through
`scripts/ablation-replace.mjs` (anchor 1 to 0, blob changed),
trap-restored:
- A, the skip back to `logger.warn`: 4 failed (2 per cell, SQLite and
PG).
  - B, the drift push deleted: 4 failed.
  - C, `columnEverMaterializes` forced false: 3 failed (pure).
- Restore: `git diff HEAD` 0 bytes, blobs equal to HEAD (`1ede8d9d…`,
`50ae8546…`).
- D, a dist ablation for the CLI pin: category mutated to `destructive`,
`driver-sql` rebuilt, and `ablation-dist-preflight` confirmed the marker
in 2 built files (exit 0). CLI test 3/3 failed. After restore and
rebuild, `--absent` exit 0 with a whole-tree clean state, and CLI test
3/3 passed.
- ESLint, a proven narrowing:
- The population is from `eslint.config.mjs` (`files:
['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']`).
  - `--format json` read 4 files with 0 errors and 0 warnings.
- There is no `parserOptions.project` (type-aware linting is off), so no
untouched file's verdict can move. The repo-wide `pnpm lint` is CI's.

## Gates (measured head 7ec990a, after merging `origin/main`
9449512)

- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 90 commands. All 90 were run, and
each exit code was captured before any pipe.
- `--ran`: `90 derived, 90 run, 0 NOT-MEASURED, 0 UNRUN`, a derived zero
(all 90 recorded exit 0).
- Roster gates flagged for this diff's directories, all exit 0:
`check-changeset-fixed`, spec `check:meta-url-spelling`, spec
`check:spec-changes`, `check:authz-resolver`, `check:error-code-casing`,
`check:filter-alias-parity`. Plus `check:durability-log-level`, exit 0.
- `check:driver-conformance`: 50 covered, 0 DEBT, 0 exempt before and
after. The dialect axis is unchanged (8 suites, 0 in the DIALECT
ledger). The ledger did not rise.

## Patch round 1: the g2a note's index-door sentence (deliberate
correction)

Admitted by the seat in the amended claim 5879543809. Text only: this
round changes no code, test, help text or bundle.

**The note:** `.changeset/19332-g2a-fieldgroups-indexes-form-rows.md`
(spec lane, unreleased). One sentence on line 13 carried two clauses
that are false in the same release. Nothing else in the file changes (1
line out, 1 line in).

**Old:**

> `indexes[].fields` is free text, and no authoring door judges its
names: not the schema parse, not the publish door, not `os validate`. A
name that is not a stored column makes the SQL driver skip the whole
index at sync with a warning in the server log, and the help text says
exactly that.

**New:**

> `indexes[].fields` is free text, and the schema parse, so a draft
save, does not judge its names; `os validate`, `os build`, `os lint` and
the publish door refuse a name that is not a field of the object
(`object-field-ref-unknown`, objectstack-ai#20479, in the same release). A name that
is not a stored column, a `formula` field say, makes the SQL driver skip
the whole index at sync with an error in the server log, and the help
text says exactly that; `os migrate plan` reports the skipped index too
(objectstack-ai#20432, in the same release).

**The doors, measured at the patch-round base 7ec990a** on the built
CLI, against a one-object fixture whose UNIQUE index names `statsu`
(bad) or `status` (clean):
- `os validate`: bad exits 1 with `object-field-ref-unknown` at
`objects[0].indexes[0].fields[0]`, clean exits 0.
- `os build`: bad 1 (same rule and path), clean 0.
- `os lint`: bad 1 (same), clean 0.
- The runtime publish door (`publishPackageDrafts`, throwaway probe on
the package's own stub engine): the bad draft is refused,
`INVALID_METADATA` with the same rule at
`objects.idx_ticket.indexes[0].fields[0]`. The clean draft is
`published`.
- The draft save staged the bad object without complaint (the schema
parse does not judge names), which is what the new sentence says.

**`Check Changeset` stays red by design on that one name.** `node
scripts/check-empty-changeset.mjs --base origin/main` exits **1** at
cef89b8. It names only
`.changeset/19332-g2a-fieldgroups-indexes-form-rows.md` ("present on the
merge base and CHANGED by this PR") and prints the DELIBERATE CORRECTION
class, whose remedy is "do NOT restore it -- say so on the PR and get it
confirmed". This section is that statement, and the at-tier review is
its written confirmation. ⛔ No `skip-changeset`.

**Gates re-run at cef89b8** (after merging `origin/main` 0bbe400
with a true merge commit):
- `dispatch-gates --commands` derived 90. All 90 were run with exit
codes captured before any pipe: 89 exited 0, and 1 exited 1
(`check-empty-changeset`, the by-design red above).
- `--ran`: 90 derived, 90 run, 0 NOT-MEASURED, 0 UNRUN.
- Exit 0 on the roster gates flagged for this diff's directories
(`check-changeset-fixed`, spec `check:meta-url-spelling`, spec
`check:spec-changes`, `check:authz-resolver`, `check:error-code-casing`,
`check:filter-alias-parity`), and on `check:durability-log-level`,
`check-adr-0087-registration`, `check-changeset-no-major` and
`check:nul-bytes`.

## Patch round 2: the `object-field-ref-unknown` index message (text
only)

The seat answered patch round 1's open question with **A**. Amended
claim 5879937791 admits
`packages/lint/src/validate-object-field-refs.ts`, text only. No rule
logic, severity, id, code path or test assertion moved.

**The message tail** (the `INDEX_POSITION.consequence` string). The
prescription is unchanged: the hint line reads byte-identically before
and after on the fixture.

> **Old:** The SQL driver skips the WHOLE index at sync with only a
warning, and drift drops it too, so `os migrate plan` never reports it:
a `unique` index is then silently unenforced while everything looks
normal.
>
> **New:** The SQL driver skips the WHOLE index at sync. It logs the
skip at error and `os migrate plan` reports the index as unbuildable,
but the object keeps serving: a `unique` index is then unenforced until
the name is fixed.

**The docblock sentence** is put in the past tense ("the sync's skip was
a `warn` and drift dropped the index") and followed by a parenthesis. It
says that since objectstack-ai#20432 step 2 the skip is logged at `error` through
`logDurabilityFailure` and `os migrate plan` reports the unbuildable
index, both still after the authoring doors.

**The test pin:** measured with `git grep` across `packages/**`,
`examples/**`, `content/**` and `skills/**`. Exactly one test pins this
text beyond the subject: `validate-object-field-refs.test.ts` asserted
``'`unique` index is then silently unenforced'``. Only that pinned
string changed, to ``'`unique` index is then unenforced'``. This is a
test-text change, not an assertion change: the same `toContain` on the
same finding. No other test, doc or skill quotes the message.

**Changeset:** `.changeset/20432-skipped-index-durability.md` gains
`'@objectstack/lint': patch` and one paragraph naming the corrected
message.

**Measured at d159ac9** (`origin/main` has not moved since cef89b8,
so no merge was needed):
- The rule's test file passed 60 of 60. The full `@objectstack/lint`
suite passed 115 files and 5363 tests. `pnpm --filter @objectstack/lint
typecheck` (with `check:test-typecheck`) exits 0.
- `check-changeset-fixed` exits 0, and `check-changeset-no-major` exits
0. `check-empty-changeset` exits **1**, still on exactly
`.changeset/19332-g2a-fieldgroups-indexes-form-rows.md` (the DELIBERATE
CORRECTION class, by design).
- `os validate` on the misspelt-index fixture (built CLI) exits 1, and
the clean fixture exits 0. The new tail, quoted from its output:

> …Did you mean "status"? The SQL driver skips the WHOLE index at sync.
It logs the skip at error and `os migrate plan` reports the index as
unbuildable, but the object keeps serving: a `unique` index is then
unenforced until the name is fixed.
- `dispatch-gates --commands` derived 91 (the new family is
`check:docs-transcript-drift`). All 91 were run after a full
`packages/*` and `examples/*` build: 90 exited 0 and 1 exited 1
(`check-empty-changeset`, the by-design red). `--ran`: 91 derived, 91
run, 0 NOT-MEASURED, 0 UNRUN.
- The roster gates (`check-changeset-fixed`, spec
`check:meta-url-spelling`, spec `check:spec-changes`,
`check:authz-resolver`, `check:error-code-casing`,
`check:filter-alias-parity`) and `check:durability-log-level` all exit
0.

## Deviations

- **File surface.** This PR also edits
`packages/spec/src/data/object.form.ts` (one help-text word and its
comment) and the four `metadata-forms` translation bundles, beyond the
claim's original surface. The os-dev contract makes a published text
that this change turns false a must-fix in the same PR. The breach was
named in the first report instead of chosen silently, and **the seat
admitted it in the amended claim 5879543809** (text only, no logic).
That claim also admits the deliberate correction in patch round 1 below.
- MySQL cells: not measured locally (see Tests).

## Acceptance notes

- **`driver-memory` mirror** (measured, not touched; under its freeze).
`syncSchema` of `indexes: [{ fields: ['statsu'], unique: true }]` logs
nothing at any level (only `Created in-memory table` at info). The index
then constrains only rows that write the undeclared key `statsu`. Rows
duplicating `status` are accepted silently.
- **`driver-turso` remote transport** (`remote-transport.ts`, about line
2410) keeps its own copy of the skip line through `diagnosticSink`. It
is not in this file surface.
- **Release text.** The pending
`.changeset/19332-g2a-fieldgroups-indexes-form-rows.md` said that no
authoring door judges `indexes[].fields`, and that the skip happens
"with a warning in the server log". Both were false in the same release.
Corrected in patch round 1 below, as the seat admitted.
- **A second published text this PR makes false: fixed in patch round
2.** The `object-field-ref-unknown` message tail on an
`indexes[].fields` position, and the rule's docblock sentence, said the
skip was a warning that drift drops. The seat admitted the fix in
5879937791 (see Patch round 2).
- The CLI's `os migrate apply` skip summary reads "(destructive without
--allow-destructive, or unsupported on this dialect)". Neither reason is
this op's. The plan message states the real one. The wording is CLI
source, outside this file surface.

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

---------

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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants