Skip to content

fix(driver-sql): a declared index that can never be built is logged at error and reported in drift - #20519

Merged
objectstack-fleet[bot] merged 13 commits into
mainfrom
claude/issue-20432-skipped-index-durability
Sep 29, 2026
Merged

objectstack-fleet[bot] merged 13 commits into
mainfrom
claude/issue-20432-skipped-index-durability

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #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 #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, #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 (#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 #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

…in drift

A declared index whose key column never materializes (a misspelt name the
Studio save door admits, or a virtual formula field) was skipped by
syncDeclaredIndexes at warn and dropped from drift by expectedIndexes, so a
declared UNIQUE went unenforced while nothing looked wrong.

- syncDeclaredIndexes logs the skip through logDurabilityFailure at error,
  naming the object, the index, each missing column with its reason, and
  whether the index is UNIQUE.
- diffUnbuildableIndexes reports it as a report-only unbuildable_index drift
  entry (index_mismatch, needs_confirm), which apply reports skipped.

Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN
Co-authored-by: Claude <noreply@anthropic.com>
SqlDriver keeps config protected, so the class is not assignable to
SqlDriverLike; bootSchemaStack reaches it by duck type. Caught by the
package typecheck.

Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN
Co-authored-by: Claude <noreply@anthropic.com>
The driver now logs a skipped declared index at error, so the object
form's help text, which said warning, would have been false. The en
bundle is regenerated; the zh-CN, ja-JP and es-ES values are edited by
hand, the one word each.

Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/driver-sql, @objectstack/lint, @objectstack/platform-objects, @objectstack/spec, touching 23 documentable anchor(s).

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

  • content/docs/data-modeling/drivers.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/data-modeling/index.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/data-modeling/indexing.mdx (via recreate_index (literal, a string literal in IndexDriftOp))
  • content/docs/deployment/cli.mdx (via create_index (literal, a string literal in IndexDriftOp), drop_index (literal, a string literal in IndexDriftOp), needs_confirm (literal, a string literal in diffUnbuildableIndexes), recreate_index (literal, a string literal in IndexDriftOp), replace_unique_index (literal, a string literal in IndexDriftOp))
  • content/docs/deployment/seed-tenancy-repair.mdx (via replace_unique_index (literal, a string literal in IndexDriftOp))
  • content/docs/permissions/tenant-audit-census.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/lifecycle.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/objectql/query-syntax.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/objectql/types.mdx (via SqlDriver (symbol, a top-level class))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via SqlDriver (symbol, a top-level class), needs_confirm (literal, a string literal in diffUnbuildableIndexes))
  • content/docs/releases/v17/17-5.mdx (via SqlDriver (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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

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

Which tree this was computed on

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

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

⚠️ 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 fb386074f57b234c98c40938aae7a0486ad50e8b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

… the same release

The pending note said no authoring door judges indexes[].fields (not the
publish door, not os validate) and that the SQL driver skips an index
with a warning. The lint rule refuses a misspelt name at os validate, os
build, os lint and publish, and the skip is now an error that os migrate
plan reports. Measured at 7ec990a.

Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN
Co-authored-by: Claude <noreply@anthropic.com>
…an error that drift reports

The SQL driver now logs a skipped declared index at error and os migrate
plan reports it, so the message tail and the rule's docblock, which said
warning and dropped, would have been false in the same release. Text
only: the rule, severity, id and prescription are unchanged; the one
pinned string in the rule's test follows the new wording.

Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 87f6c0bfdb5cd12a89d60236bfcdbcae85ef108c
Local-runs: none

Inputs read: card #20432 body and all 15 comments (triage 5871296642 and 5875602547; claims 5876902938, 5877196132, 5879543809, 5879937791; reports 5879503827, 5879918322, 5881175217); PR #20519 body and 13-file list; the net diff fb386074f..87f6c0bfd (13 files, +733/−28; merge base is main's fb386074f, the #20444 squash); the check-runs on the head, read twice. Read-only git only (fetch, show, diff, grep, rev-parse); nothing built, run or re-run. The contract is triage's step-2 line (5875602547) plus the three amendments on the newest claim 5879937791, all three read.

① Derived judgments

1. sql-driver.ts syncDeclaredIndexes. RIGHT on every question.

  • The skip goes through this.logDurabilityFailure(msg, meta) (head line 14657), the same method the duplicate-row arms of the same loop use (14767, 14820, 14832, 14850, 14885). logDurabilityFailure is logger.error with a warn fallback only for a logger that has no error (6186), identical for every sibling. The old logger.warn line is gone; the level moved, no second line was added (pinned: logs.filter(warn ∧ 'statsu') === []).
  • A skipped unique names the constraint: the line reads declared UNIQUE index 'NAME' on "TABLE" was NOT created: no column for 'COLUMN' (reason). The uniqueness it declares is NOT enforced: duplicate rows are accepted… (NAME, TABLE and COLUMN stand for the interpolated index, table and column), and the meta carries { tableName, index, fields: columns, missing, unique }. A plain index says The index does not exist… with unique: false. The two are told apart in prose and in meta.
  • No throw: continue after the line; the object serves. Pinned on every dialect cell (control fixture, plus the CLI boot-gate pin that boots on).
  • Nothing else in the function moved: the net diff's hunks are the docblock bullet, the new optional fifth parameter fields?, and the one message/meta. The create/catch arms are untouched. Callers: syncTableIndexes passes fields; the shard path passes obj.fields ?? {}; the drift-op ensure paths pass none and get bare names (documented on the parameter).
  • Merge with filter: the engine's compile surfaces answer $empty by the field's declared type (driver-sql and heirs, turso remote, driver-memory, driver-mongodb, formula, objectql having) — ruling A on #20399 #20444 (fb386074f): CLEAN, nothing lost on either side. The branch's own hunk set on sql-driver.ts (9bf5e67af..d159ac981, 55 ± lines) is byte-identical to the net hunk set (fb386074f..87f6c0bfd); all 305 lines filter: the engine's compile surfaces answer $empty by the field's declared type (driver-sql and heirs, turso remote, driver-memory, driver-mongodb, formula, objectql having) — ruling A on #20399 #20444 added to the file are present at the head (0 missing); no conflict markers; the merge commit's diff is exactly main's 306-line hunk against the branch parent and exactly the branch's 55-line hunk against the main parent.
  • One note, not a defect: the reason text has a third kind, a declared field whose column the table does not have. The sync logs that case at error too (DDL declared, not run, unique unenforced: the durability rule's case) while the differ deliberately leaves it out (pending add_columns work). The split is consistent and the line names which case it is.

2. schema-drift.ts: the unbuildable_index op. RIGHT on every question.

  • Emitted only when a key column is absent AND columnEverMaterializes is false: not a builtin/tenant column, and not a declared field with fieldHasColumn (so a misspelling or a virtual formula). A declared column-materializing field the table merely lacks is excluded. Pinned by pure cases 1 (pending, []), 2 (mixed index names only statsu), 4 (the split against expectedIndexes).
  • Report-only: applyIndexDriftOp answers return false before any read (13882); applyDriftOpInPlace routes index ops there (14020); the SQLite rebuild path takes column entries only. It is in INDEX_DRIFT_OPS and in the IndexDriftOp Extract, so isIndexDriftOp is true, the index path is taken, and the entry lands in skipped with no misleading "unsupported on dialect" line (that warn is on the column arm only). applyNullSafeUniquePreflight filters to create_index/recreate_index (13480), untouched.
  • category: 'needs_confirm', severity: idx.unique ? 'error' : 'warning', the recreate_index convention (2001) and the manual_column_type_change precedent (1138). kind: 'index_mismatch' is an existing SchemaDiffEntryKind, spec unchanged.
  • expectedIndexes keeps its signature; declaredIndexSet is shared with diffUnbuildableIndexes, so the two halves partition one declared set (pinned, case 4). driftKey includes indexName, so boot de-dup keeps one warn per index.
  • Consumer census (git grep at the head, outside dist): no never-exhaustive switch over DriftOp['type'] anywhere; the three switch (op.type) in sql-driver.ts fall through to false/warn; packages/metadata/src/migration/executor.ts:23 switches a different op type. CLI reads op.type only for display (schema-migrate.ts:539, artifact-boot-migration.ts:147) and one drop_column ∧ sys_account filter (apply.ts:348); multi-value-columns.ts selects manual_column_type_change only; driver-turso refuses detectManagedDrift/applyMigrationEntries on the remote face and inherits the local one. No consumer mishandles the member. Type Check (all five contexts) is green on the head.

3. The CLI. RIGHT. No CLI source in the file list; the only packages/cli change is the new test. renderPlan/driftTarget/groupByCategory/summarize read category, op.indexName, op.type, message generically; the pin asserts os20432_boot [uniq_…] [unbuildable_index] under Needs confirmation, the boot gate's one warn and ok: true. The test is test-side only, real driver from dist, integration tier; pnpm --filter @objectstack/cli test is vitest run over BOTH tiers (vitest.config.ts:503), which is what Test Core ran: green.

4. The pins. RIGHT.

  • Dialects: the driver-sql file iterates DIALECT_CELLS (sqlite, pg, mysql) through declareDialectCell; an unprovisioned cell is reported, and fails under OS_EXPECT_LIVE_DIALECT_MATRIX=1. CI's Temporal Conformance (live PG + MySQL) runs pnpm --filter @objectstack/driver-sql test (the whole suite) with OS_TEST_POSTGRES_URL, OS_TEST_MYSQL_URL and OS_EXPECT_LIVE_DIALECT_MATRIX: '1': success on the head, so all three cells were measured, MySQL included (the dev's local gap is closed by CI).
  • Formula boundary: PINNED (matrix cell "a virtual formula field in a plain index": error line names 'doubled' (a formula field, no UNIQUE, unique: false, drift at warning; pure case 3 pins a field-level unique on a formula at error).
  • objectExtensions boundary: explicitly NOT REACHED, with a measured probe in the PR body, and the code agrees: packages/objectql/src/registry.ts:222-236 merges extension fields and indexes into the base before plugin.ts:1928 hands registry.getAllObjects() to syncSchema, so the driver never sees an unmerged object.
  • driver-memory mirror (memory-unique-constraint.ts, [裁决] driver-memory / driver-mongodb 投入冻结 —— 维护者 2026-08-05 口径(跨单锚点) #5499 freeze): untouched (empty diff under packages/drivers/driver-memory on the PR side), measured and reported in Acceptance notes only.

5. The admitted text edits. RIGHT, and each corrected sentence is TRUE at the head.

  • object.form.ts helpText: "with a warning" → "with an error", plus its comment. The en bundle string is byte-equal to the form's helpText (regenerated). zh-CN 警告→错误, ja-JP 警告→エラー, es-ES advertencia→error: one word each. Each clause is true at the head: the schema parse does not judge names; publishing and os validate refuse via object-field-ref-unknown (suite entry runtimeTypes: ['flow','object']); a non-stored column makes the driver skip the whole index with an error in the server log.
  • Lint: INDEX_POSITION.consequence text only; prescription byte-unchanged; docblock put in the past tense with the parenthesis. The one pinned string moved from 'unique index is then silently unenforced' to 'unique index is then unenforced': the same toContain on the same finding, and the rule's id, severity, code path and the hint are unchanged. No assertion weakened. The new tail is TRUE at the head (error via logDurabilityFailure; os migrate plan reports it; the object keeps serving).

6. The DELIBERATE CORRECTION of .changeset/19332-g2a-fieldgroups-indexes-form-rows.md. RIGHT. One line out, one line in (line 13); nothing else in the note changes; it is the ONLY pre-existing changeset the diff touches (the other changeset file is new). The corrected sentence is TRUE at the head: the schema parse (a draft save) does not judge indexes[].fields; os validate, os build, os lint and the publish door refuse a non-field name through object-field-ref-unknown (#20479 merged as 4b2d904190, its note .changeset/20432-field-name-list-refs.md still pending at the head, so "in the same release" holds); a non-stored column, a formula say, makes the driver skip the index with an error in the server log; the help text says exactly that; os migrate plan reports the skipped index (#20432, this PR's pending note).
Check Changeset is red on that one name by design: check-empty-changeset.mjs judges changesets the PR modified or deleted but did not add (--diff-filter=MD), and this diff has exactly one such file, so the gate reds on exactly .changeset/19332-g2a-fieldgroups-indexes-form-rows.md and prints its DELIBERATE CORRECTION class, whose remedy is "do NOT restore it; get it confirmed on the PR" (the job log sits behind a host this session's proxy refuses, so the one-name reading rests on the diff and the gate's rule, not the log). This record is that written confirmation. The note corrected is .changeset/19332-g2a-fieldgroups-indexes-form-rows.md; what changed under it is the one sentence that said no authoring door judges indexes[].fields ("not the publish door, not os validate") and that the skip happens "with a warning in the server log": both clauses were falsified in the same release, the first by #20479 and the second by this PR, and the replacement names both PRs. ⛔ No skip-changeset, correctly.

7. Docs. No statement is made FALSE; one page is now incomplete and can be carried.

  • content/docs/deployment/cli.mdx: the "Index drift" table (lines 858-867) lists create_index, replace_unique_index, recreate_index, drop_index and now lacks unbuildable_index; the needs_confirm row (855) says "except manual_widen_varchar_to_text, which nothing applies", which was already incomplete before this PR (the page's own line 899 has manual_column_type_change never applied) and now has a third exception; line 890's "it isn't the only one" claims no exclusivity. Neither sentence asserts an exhaustive list, so this is drift by omission, not a falsehood. Line 1357-1359 ("these indexes are invisible to os migrate plan by construction") is about runtime-managed sys_* tightenings and is unaffected. It says nothing about a skipped declared index or its log level. Carry: a docs follow-up adding the unbuildable_index row and extending the never-applied list; it need not ride this PR.
  • content/docs/data-modeling/indexing.mdx: no sentence about a skipped declared index, a missing column, or a log level; not falsified.
  • content/docs/deployment/seed-tenancy-repair.mdx: nothing on this subject; not falsified.
  • Also swept: skills/objectstack-data/SKILL.md (names replace_unique_index only) and ADR-0120: nothing falsified. No page in content/, skills/ or docs/ quotes the old skipping declared index … column(s) not materialized line.

Every sentence, changeset and PR body. The changeset .changeset/20432-skipped-index-durability.md: every sentence TRUE at the head (frontmatter, the widening line, "no accept set changes", the two never-materializing cases plus field-level unique on a formula, the before/after behaviour, the error line and its meta, the entry's kind/category/severity/op shape, missingColumns semantics, the consumer paragraph including os migrate apply asking for --yes and the boot gate warning and starting, the help-text and lint paragraphs, the upgrade note). One incompleteness, not a falsehood: the reason parenthetical lists two of the line's three reason kinds. PR body: every behavioural and structural sentence TRUE at the head (What was wrong; What changed; Public surface; help text; the consumer census table row by row; both boundaries; Patch round 1's doors and gate class; Patch round 2's tail, docblock, one-pin measurement and changeset line; Deviations; Acceptance notes). DATED, not false, because they carry an earlier measurement head that the head's check-runs supersede: "Tests (at 7ec990a…)", "Gates (measured head 7ec990a…)", "Gates re-run at cef89b8", "Measured at d159ac9", the ESLint "4 files" narrowing, and "the merges bring nothing under packages/drivers" (true of the round-0 merges it describes; the final merge did bring #20444 under packages/drivers, re-measured in report 5881175217 and by CI). One stale parenthetical: "origin/main has not moved since cef89b8, so no merge was needed" was true when written and is not true of the head (main moved by 397572ed5 and fb386074f before it); disclosed by the dev in 5881175217. No sentence is FALSE.

② Semver level

Clause-②: yes (widening) — RIGHT, and it matches the newest claim 5879937791 and the PR body's second line. The changeset declares @objectstack/driver-sql minor, @objectstack/spec patch, @objectstack/platform-objects patch, @objectstack/lint patch, and each line matches what the diff publishes: driver-sql gains a public union member (DriftOp +unbuildable_index, with its derived faces IndexDriftOp and the exported INDEX_DRIFT_OPS set, and a protected optional parameter on syncDeclaredIndexes that subclasses may ignore) → minor; spec publishes one help-text string in object.form.ts → patch; platform-objects publishes the four generated bundles → patch; lint publishes one message tail and a docblock → patch. packages/cli is test-only and correctly has no line; driver-turso is unchanged. The union member is the only public surface change: describeMissingIndexColumns and diffUnbuildableIndexes are new module exports but packages/drivers/driver-sql/src/index.ts re-exports neither (named exports only; not in the file list), and expectedIndexes keeps its signature. The widening is stated on the changeset's prose face ("Clause-②: yes (widening): the exported DriftOp union gains one member, unbuildable_index."), with what a consumer of op.type now sees. check-changeset-fixed, check-changeset-no-major and check-adr-0087-registration are inside the green Lint & Repo Gates run.

③ Boundary flags

  • Deviation: the two main merges in round 2. Answered, accepted. 10fcdc6dd brought 6 main commits touching none of the PR's 13 paths (empty diff, verified). 87f6c0bfd brought fb386074f (filter: the engine's compile surfaces answer $empty by the field's declared type (driver-sql and heirs, turso remote, driver-memory, driver-mongodb, formula, objectql having) — ruling A on #20399 #20444) and 0e1afe8f4; only sql-driver.ts overlaps, and the merge is clean with neither side's hunks lost (①.1). The head's own gates are the check-runs below, all green but the by-design red.
  • Deviation: the PR body's "Measured at d159ac9" against the head. DATED, not FALSE. The lint rule (5464b2ce55), its test (27ebce485c), both changesets (df9c6b6ad4, 58eeaee764) and schema-drift.ts (50ae854684) are blob-identical between d159ac981 and the head; only sql-driver.ts differs, by main's filter: the engine's compile surfaces answer $empty by the field's declared type (driver-sql and heirs, turso remote, driver-memory, driver-mongodb, formula, objectql having) — ruling A on #20399 #20444 hunks. The measurements hold for the blobs they measured; the head's verdicts are CI's. The parenthetical "origin/main has not moved since cef89b8" is stale at the head and was disclosed in 5881175217. The seat may amend the body line; nothing in it misleads about the head's behaviour.
  • Dev flag: live MySQL not measured locally. Closed by CI: Temporal Conformance (live PG + MySQL) runs the whole driver-sql suite with both URLs under OS_EXPECT_LIVE_DIALECT_MATRIX=1, success on the head.
  • Dev flag: file-surface breach (form help text + four bundles). Admitted by the seat in 5879543809 (text only); verified text only (①.5).
  • Dev flag: H3, the widening. Pre-cleared in 5877196132 before the build; the changeset and body carry it (②).
  • Dev flag: report-only entry classified needs_confirm. The manual_column_type_change precedent; consequence pinned (the boot warns and starts; apply reports skipped).
  • Dev flag: the lint test's one pinned string. Inside the seat's option A ("the lint test pins no message text beyond the subject, to be measured"): one pin measured, changed as a test-text edit, same assertion (①.5).
  • open_questions. Round 0: none. Round 1: one (the lint message tail): answered A by the seat in 5879937791 and delivered in round 2. Round 2: none. All answered.
  • out_of_scope_findings / acceptance notes. (a) the g2a note: fixed in round 1 (①.6). (b) driver-memory mirror: measured, untouched, under freeze; carrier none, accepted as recorded. (c) driver-turso remote-transport.ts:2453-2461 keeps its own copy of the skip (skipping declared index … column(s) not materialized) through diagnosticSink, whose level this PR does not set and the dev did not measure; that face also refuses drift detection, so os migrate plan cannot show it there either. ESCALATED to the seat as a follow-up candidate for the turso lane (a mirror of this card's defect on the remote face), outside this claim's surface and not blocking. (d) os migrate apply's skip summary wording names neither report-only reason: CLI source, outside the surface; the plan line states the real reason; carry with the docs follow-up in ①.7.

Check-runs on 87f6c0bfd, final read (2026-09-29T00:27Z): 35 runs, all completed. 31 success: Auto Label, Build Core, Check Documentation Links, Check PR Size, Dogfood Regression Gate (+3 shards), Dogfood Verify CLI, Flag docs affected by code changes, Governed Surface Queue Guard, Lint & Repo Gates, the three PR-claim guards, Part-of PR must not also close its card, Spec property liveness, Temporal Conformance (live PG + MySQL), Test Core (+6 shards), TypeScript Type Check with its four Type Check contexts, filter. 3 skipped by roster (Build Docs, Console Pin Gate, Packed-tarball smoke opt-in). 1 failure: Check Changeset, the expected by-design red on the one corrected foreign note, confirmed above. No other red.

Implemented-by: claude/issue-20432-skipped-index-durability
Reviewed-by: session_01N8TPEsoJxPsdSdNKGnNGEN

VERDICT: PASS

Merged via the queue into main with commit c7ad16f Sep 29, 2026
36 of 37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20432-skipped-index-durability branch September 29, 2026 00:54
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…its that decided them (stage 3) (objectstack-ai#20533)

Part of objectstack-ai#20234
Clause-②: no

## What changed

This is stage 3 of the staged sweep. It covers
`packages/spec/src/data/**` and nothing else. It leaves out the files an
open PR or an in-flight claim holds: `data-engine.zod.ts`,
`data-engine.test.ts`, `hook.form.ts`, `analytics*.ts`,
`cube-member-inner-name-retirement.test.ts`, `driver/turso.zod.ts` and
`filter-subtree-provenance.ts`, as the claim names them. It also leaves
out four files that open PRs started editing after the claim:
`driver/turso.test.ts` (PR objectstack-ai#20504, objectstack-ai#20437's, opened 2026-09-28T20:08Z),
`object.form.ts` (PR objectstack-ai#20519, objectstack-ai#20432's, 21:55Z), `object.zod.ts` (PR
objectstack-ai#20521, objectstack-ai#20494's, 22:10Z) and `filter-logic-conformance.ts` (PR objectstack-ai#20523,
objectstack-ai#20444's, 22:39Z). See Acceptance notes. Later stages cover the other
areas, so this PR says `Part of`.

Every comment or docblock site in scope that cited a tracker number
answering 404 has been rewritten in ruling C+D's form C (comment
5749154545 on objectstack-ai#19123). That is **163 sites on 161 lines in 44 files,
covering 40 numbers**. Each rewritten line now cites the commit in
`origin/main` history that decided what the line describes, and it says
in its own words what that commit decided.

No ADR or ruling-record file in `docs/adr/` or `scripts/adr-anchors/`
records the decision behind any of the 43 dead numbers in scope.
ADR-0104 names objectstack-ai#12380 only as a reference, and ADR-0055 states the rule
that objectstack-ai#8772's ruling enforced, not the ruling itself. So every anchor is
a commit: **38 distinct shas**. One number was dropped rather than
anchored: objectstack-ai#17286, a tracking card that recorded an axis as undecided,
under which no commit landed. The sentence keeps its reason in words.

Three comment sites in scope are left on purpose (see Acceptance notes).
Two are the `[objectstack-ai#6259]` marker in `api-derivation.ts:163`, which a test
string reads, and the test comment that names that marker. The third is
`field.zod.ts:370`, whose `objectstack-ai#6111` is objectui's number.

Only comments changed. Every source file keeps its line count (174 lines
out, 174 in, over 45 files), so no line citation into these files moves.
Thirteen of those 174 lines held no dead citation. Eleven are the other
half of a sentence that had to be reflowed or rewritten. One is a table
header (`value-roundtrip-conformance.ts:20`, 「card」 to 「card or commit」,
because its row now holds a commit). One is `api-derivation.ts:164`,
which now carries the `[objectstack-ai#6259]` sentence's commit. No code token moves
(see the guard below). The 41 string-literal sites that carry a dead
number are tokens, so they are left as they were and listed below.

**No citation number is added.** Every tracker number on an added line
was already on the line it replaces. No PR number stands on an added
line.

Two more kinds of file change, both mechanical:
- **One regenerated reference page.** Two of the rewritten docblock
lines (`feed.zod.ts:15`, `:18`) project into
`content/docs/references/data/feed.mdx`. `check:docs` proved that page
stale, and `pnpm --filter @objectstack/spec check:generated --fix`
regenerated only it. The diff is two lines, each the same substitution
as its source line. No page a held file projects into (`analytics.mdx`,
`data-engine.mdx`, `hook.mdx`, `driver-turso.mdx`) moved.
- **A `patch` changeset** for `@objectstack/spec` (see Changeset below).

## Census: `data/`, before and after

**Instrument.** This is the instrument of stages 1 and 2. It sends REST
`GET /repos/objectstack-ai/objectstack/issues/N` without following
redirects, for every distinct number cited in `packages/spec/src/data`.
The population is:
- the citation gate's own exported `CITATION_RE` and
`NON_CITATION_HEADS`, kept when the qualifier is none, `objectstack`,
`objectstack-ai/objectstack`, `framework`, `pre-` or `post-`;
- widened here to the capitalised spellings of those qualifiers (`Pre-`,
`POST-`, `Framework`: 7 sites, one of them dead), which stage 2's
case-sensitive set did not read;
- N of 100 or more, excluding `summon` heads.

Each site is classified by the TypeScript parser as a line comment, a
docblock, a block comment or a string.

**Controls.** The lit controls were `objectstack-ai#16862`, `objectstack-ai#16847` and `objectstack-ai#17698`. The
dead controls were `objectstack-ai#16714`, `objectstack-ai#16715` and `objectstack-ai#16697`. They were probed at
the start, after every 100 numbers and at the end. They read 24 of 24
lit (200) and 24 of 24 dead (404) over 8 checkpoints in both runs.

| reading | tree | numbers probed | 200 | 404 | 301 or other | dead
sites, all of `data/` | in scope | excluded (held files) | in-scope
lines | in-scope files | dead numbers in scope |
|---|---|---|---|---|---|---|---|---|---|---|---|
| before | base `9bf5e67af`, probed 2026-09-28T19:32Z to 19:36Z | 618 |
571 | 47 | 0 | **240** | 207 | 33 | 204 | 47 | 43 |
| after | head `96fd49caa2`, probed 2026-09-28T23:19Z to 23:23Z | 600 |
571 | 29 | 0 | **77** | 44 | 33 | 43 | 16 | 21 |

**Before, in scope, by class.** 92 non-test docblock sites and 13
non-test line comments. 16 test docblock sites and 45 test line
comments. 39 test string sites. 2 non-test string sites.

**After, in scope.** 41 string sites and 3 comment sites remain, all
three deliberate. The head probe found no number newly dead since the
base probe: the same 571 numbers answer 200.

PR objectstack-ai#20226's area table read `data` 239 at an earlier base; this census
reads 240 at `9bf5e67af`. The 33 excluded sites sit in `object.zod.ts`
(15), `analytics.zod.ts` (3), `analytics-strictness-batchd.test.ts` (2),
`analytics-date-range-two-bound-window.test.ts` (1),
`driver/turso.zod.ts` (2), `driver/turso.test.ts` (3),
`filter-subtree-provenance.ts` (3), `filter-logic-conformance.ts` (3)
and `object.form.ts` (1). `data-engine.*` and `hook.form.ts` carry none.

## Per-number table

The counts are in-scope sites and files at the base. `rewritten / left`
gives comment sites rewritten and sites left. Every anchor was read in
its diff or message, not only in its subject: it is the commit that made
the change the line now describes, and its own diff or message names the
number it replaces.

| number | sites / files | rewritten / left | anchor: what it decided |
|---|---|---|---|
| `objectstack-ai#6111` (objectui) | 1/1 | 0/1 | objectui's number, left: see
Acceptance notes |
| `objectstack-ai#6259` | 5/2 | 1/4 | `6968885ef`: retires the producer-less `batch:
'bulk'` row of `DATA_ACTION_TO_API_OPERATION` and the prose calling
`batch` a runtime action. The marker and 2 test strings stay (see
Acceptance notes) |
| `objectstack-ai#6345` | 18/5 | 17/1 | `e2798fab7`: one driver vocabulary; both boot
hosts read the shared table; `mongo` to `mongodb`; turso a builtin; the
fork-1 and fork-2 refusals |
| `objectstack-ai#6571` | 10/2 | 8/2 | `2f3e79351`: `$between` endpoints accept the
ISO/clock strings the platform produces, as a bare string (rider ①) |
| `objectstack-ai#8495` | 9/2 | 6/3 | `4bfe1a539`: refuses `${…}` placeholders in
memory `persistence.path` / `persistence.key` at publish |
| `objectstack-ai#8656` | 1/1 | 0/1 | a test title only |
| `objectstack-ai#8696` | 20/8 | 17/3 | `90a12fb18`, the card's mongodb arm: a bound
secret rides beside an unmodified url as MongoClient `auth`. Its own
pins carry the multi-host form `new URL()` cannot parse and the bound
secret outranking `options.auth` |
| `objectstack-ai#8772` | 3/2 | 3/0 | `75b7c240a`: Direction 2 of the 2026-08-16
maintainer ruling. The builder forces `required: true` on a
`master_detail` under `controlled_by_parent`, and raw parse stays
tolerant. ADR-0055 stays cited beside it |
| `objectstack-ai#8778` | 1/1 | 1/0 | `7901b2dd2`: stamp-only
`tenancy.organizationField`, declared by `sys_api_key` |
| `objectstack-ai#8794` | 2/1 | 2/0 | `1850ebbb0`: corrects the reuse-safety claim on
the filter-subtree mark from the survey's measurement, and routes a
mechanism change to a spec-seat ruling (stage 1's anchor too) |
| `objectstack-ai#8836` | 2/1 | 2/0 | `1850ebbb0`: the same commit, which pins the
invariant (one line carries both numbers) |
| `objectstack-ai#8873` | 6/3 | 6/0 | `096106522`: a bound `credentialsRef` reaches
the postgres server on the DSN branch. Its diff records that `pg` sends
a password only when the server asks |
| `objectstack-ai#8874` | 1/1 | 1/0 | `d70428ae7`: a declared mysql `ssl` reaches
`mysql2` as its own TLS options object, because `mysql2` rejects a bare
boolean |
| `objectstack-ai#8876` | 9/5 | 6/3 | `d634e665b`: exports `urlUserinfoUsername`, and
its diff states the asymmetry that a username is not credential material
|
| `objectstack-ai#9040` | 20/6 | 14/6 | `24206416a`: refuses a credential in the mongo
options passthrough at publish, and redacts the passthrough secret paths
on read |
| `objectstack-ai#9041` | 22/2 | 17/5 | `d491625c1`: refuses a bound `credentialsRef`
with a user-less mongo `config.url`, with the triage's fences |
| `objectstack-ai#10165` | 5/1 | 1/4 | `801296050`: `ttl.onlyWhen` with the canonical
null predicate (maintainer ruling 2026-08-20, option A) |
| `objectstack-ai#10274` | 1/1 | 1/0 | `d1ba685ec`: re-measures the objectui pin
citations and gates the class |
| `objectstack-ai#10329` | 6/2 | 6/0 | `15d58dbf1`: retires the import lookup
transform's steering params (ADR-0049) |
| `objectstack-ai#10347` | 2/1 | 2/0 | `530c1df65`: the Archiver honours a declared
`ttl` (maintainer ruling 2026-08-20) |
| `objectstack-ai#10527` | 2/1 | 1/1 | `5649efbf9`: refuses a diverging retention +
ttl + archive triple at parse time |
| `objectstack-ai#11065` | 7/3 | 5/2 | `20950404c`: a boolean aggregand counts as 1 or
0 in `avg` and `sum`, the first face aligned. No commit message names
the card; this is where the number first entered the tree |
| `objectstack-ai#11195` | 3/1 | 2/1 | `b37231883`: `UserActionsConfigSchema` adopts
`group` / `hideFields` / `rowColor` |
| `objectstack-ai#11215` | 1/1 | 1/0 | `42a117b88`: documents
`NoSQLIndexSchema.unique`'s deliberate scope-vocabulary omission |
| `objectstack-ai#11350` | 1/1 | 1/0 | `ece4dad31`: records the 2026-08-23 maintainer
ruling on entry nameability (stage 1's anchor too) |
| `objectstack-ai#11408` | 2/1 | 1/1 | `f11fc61c5`: declares `editMode` (maintainer
ruling 2026-08-24) |
| `objectstack-ai#11507` | 5/2 | 5/0 | `88b9d749a`: declares `sys_activity.type` an
open, author-extensible vocabulary (maintainer ruling 2026-08-24,
direction 4) |
| `objectstack-ai#11658` | 1/1 | 1/0 | `1a6a19c31`: opens `RecordActivityProps.types`
to author-contributed kinds |
| `objectstack-ai#12380` | 4/2 | 4/0 | `4045b954d`: makes the SQLite `Field.json`
codec injective; its message carries the measured boundary |
| `objectstack-ai#12868` | 1/1 | 0/1 | a test title only. Its comment site sits in
`object.form.ts`, now held by PR objectstack-ai#20519; its deciding commit is
`c459da6bc` (see Acceptance notes) |
| `objectstack-ai#13156` | 1/1 | 1/0 | `fd289be45`: strips tracker ids from
function-declaration-built refusal prose (the card's A half) |
| `objectstack-ai#13644` | 3/2 | 2/1 | `34ce8e7db`: declares
`ctx.referentialFieldClear` on `HookContextSchema` |
| `objectstack-ai#14426` | 2/2 | 1/1 | `40a44b91b`: the undefined-comparand refusal
prescribes the null predicate by its ruled spellings, position-safe |
| `objectstack-ai#14676` | 1/1 | 1/0 | `13c48c2a5`: retires `connector.errorMapping`;
its test states the same assertion-set reasoning |
| `objectstack-ai#16126` | 2/2 | 2/0 | `859ded3ec`: refuses a whitespace-only
`reference` on lookup / master_detail |
| `objectstack-ai#16685` | 4/2 | 4/0 | `ed7243d52`: accepts boolean / toggle for sum /
avg / min / max (decision batch objectstack-ai#80) |
| `objectstack-ai#16867` | 3/2 | 2/1 | `0ee32edef`: `notNull` / `not_null` prescribe
`storage.notNull`, not `required` |
| `objectstack-ai#17014` | 3/2 | 2/1 | `80aef8032`: the one-day date-range presets
prescribe a one-day window, and the table states its end-token
convention |
| `objectstack-ai#17286` | 1/1 | 1/0 | dropped: a tracking card with no landing. The
sentence now says the card is gone and to measure `driver-memory` for
the open set |
| `objectstack-ai#17348` | 1/1 | 1/0 | `51efbf116`: pins the `driver-memory` temporal
text-operator divergence by name in that driver's conformance suite |
| `objectstack-ai#17590` | 1/1 | 1/0 | `e04a0aff2`: `$contains` on a JSON column is a
per-dialect membership test (director-seat ruling 2026-09-12) |
| `objectstack-ai#18012` | 8/3 | 7/1 | `176b03582`: `$between` requires two non-blank
endpoints (decision batch objectstack-ai#146 item 5, letter A) |
| `objectstack-ai#19377` | 6/2 | 6/0 | `a60c913de`: refuses a `{ $field }` reference
as a `$between` endpoint at the runtime filter door |

Every cited sha matches exactly one commit (`git rev-parse
--disambiguate`, count 1), and every one is an ancestor of the base
(`merge-base --is-ancestor`, exit 0). That is 38 distinct shas.

Wordings to check, each true of its commit:
- `datasource.zod.ts:352` names only the card's mongo arm (`90a12fb18`)
for "the defect class … closed", because the paragraph is about mongo.
The card's mysql arm (`72050cc47`) is not cited anywhere in this stage.
- `datasource.zod.ts:354`: 「the triage's, as commit d491625 landed
them」. `d491625c1`'s message lists the fences as "per triage".
- `filter.zod.ts:1021-1025`: the `objectstack-ai#17286` pointer becomes 「was measured
on a tracking card … That card is gone: measure `driver-memory` for the
open set, ⛔ not this text.」 The warning that this paragraph is not the
authority is kept.

## The 41 string sites left as tokens

- **Test titles and test-code strings (39 sites).**
`driver/driver-credential-refusal.test.ts` 14, `object.test.ts` 6,
`datasource-credential-redaction.test.ts` 3,
`driver/driver-placeholder-refusal.test.ts` 3, `filter.test.ts` 3,
`api-derivation.test.ts` 2 (the `split('[objectstack-ai#6259]')` literal and its
message), `field.test.ts` 2, and 1 each in `date-range-presets.test.ts`,
`driver/postgres.test.ts`, `field-rows-option-description.test.ts`,
`filter-comparand-type.test.ts`, `hook.test.ts` and
`object-strictness-batch20.test.ts`.
- **Non-test strings (2 sites).** `aggregation-conformance.ts:398` and
`:407`, the `note` of two exported `AGGREGATION_CASES` rows (`objectstack-ai#11065`,
`objectstack-ai#11151`). They ship as data. Their only readers are driver conformance
suites, which print a `note` as the assertion message when a case fails,
to a driver developer and never to a metadata author. So they are
neither comments nor form D author-shown text. This is the same
disposition stage 1 gave the two `why` strings and stage 2 the
`PROVENANCE_WAIVERS` reason.

No author-shown text in `data/` carries a dead number, so nothing here
is objectstack-ai#20233's form D.

## Mechanical guard: no code token moves

The check compares leaf tokens with comments stripped, base `9bf5e67af`
against head `96fd49caa2`. It uses the TypeScript parser's leaf tokens,
so template literals are scanned in context, and it excludes JSDoc
nodes. It ran over all 45 touched `.ts` files.

- Real run: 140,379 base tokens, **0 files with a token change** (exit
0).
- Comment-insertion control: 0 files changed, as expected (exit 0).
- Positive control (a declaration inserted into `feed.zod.ts`): 1 file
reads DIFFER (exit 1).
- Positive control (one digit changed inside the `split('[objectstack-ai#6259]')`
string in `api-derivation.test.ts`): 1 file reads DIFFER (exit 1).

## Changeset

This change ships bytes, so a `patch` changeset for `@objectstack/spec`
is included. It says only that the provenance comments were re-anchored.

Measured on the built package: 14 of the touched sources are
`src/**/*.zod.ts`, which `files[]` ships verbatim. The rewritten
docblocks also reach `dist`. `88b9d749a`, `e2798fab7` and `24206416a`
each appear in 1 declaration file. `24206416a` appears in 20 bundled
`.js` files and `2f3e79351` in 28. The positive control, a pre-existing
`feed.zod.ts` docblock sentence, appears in `dist/data/index.d.ts`.

## Gates (head `96fd49caa2`)

- **Citation judging pass, run as CI runs it:** `pnpm
check:issue-citations && node scripts/check-issue-citations.mjs` exits
0. The self-test passes 73 cases in 7 batteries. The live run judged 11
citations across 25 files, and all 11 resolve.
- **Doc authoring:** `pnpm check:doc-authoring` exits 0.
- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` at the final head derived 108
families, and all 108 exit 0. `--ran` reports 108 run, 0 NOT MEASURED, 0
unrun, and exits 0. (`check:i18n` was derived at the earlier heads from
`object.form.ts`, and left the set when that file went back to base.)
- At an earlier head, four gates first exited 3 (PREREQUISITE NOT MET)
because the workspace was unbuilt: `check:doc-formula-expressions`,
`check:doc-security-posture`, `check:skill-examples` and
`check:docs-transcript-drift`. At the final head a full `turbo run
build` of `./packages/*` ran first (71 tasks, exit 0, under the shared
verify lock), and every gate exited 0 on its first run.
- `check:generated` was run under the lock against that build: all 15
artifacts are up to date.
- **Build, tests, typecheck and lint:**
  - `pnpm --filter @objectstack/spec build` exits 0.
- `vitest run --maxWorkers=2 src/data` in `packages/spec` at the final
head: 107 files and 3,517 tests pass (1 todo), covering every touched
test file.
- The 12 spec suites outside `src/data` that read `data/` source text
pass at the final head: 12 files, 503 tests. These are
`scripts/{file-description,root-index,skill-map-guards,strictness-ledger}.test.ts`,
`src/api/api-entry-graph.pin.test.ts`,
`src/contracts/scoped-context.test.ts`,
`src/shared/{alias-integrity,evaluated-slot-population,retired-key-migrate-sentence}.test.ts`,
`src/system/constants/platform-object-names.test.ts`,
`src/type-alias-convention.pin.test.ts` and `src/ui/dashboard.test.ts`.
- `pnpm --filter @objectstack/spec typecheck` at the final head exits 0,
including `check:test-typecheck` (53 files, 251 errors, 138 pinned
signatures held).
- Lint, as a proven narrowing at the final head: `eslint
--no-inline-config --format json` over the 45 touched `.ts` files gives
45 files, 0 errors and 0 warnings. All 45 are in eslint's own population
(`isPathIgnored` is false for each). `eslint.config.mjs` never enables
type-aware linting (no `parserOptions.project`, which its own line 328
states), so a comment edit here cannot move the verdict on any untouched
file. The repo-wide `pnpm lint` is CI's run.

## Acceptance notes

- **The `[objectstack-ai#6259]` marker.** `api-derivation.test.ts:236` splits
`DATA_ACTION_TO_API_OPERATION`'s TSDoc on the literal `[objectstack-ai#6259]`, and a
test string may not change here. So the marker line
`api-derivation.ts:163` is byte-identical to the base, and the test
comment at `:232` that names the marker stays too. The sentence's
deciding commit sits on the next line instead: 「(both by commit
6968885)」. A first attempt wrote the commit onto the marker line
itself. The diff-scoped `check-issue-citations` then read the kept
`objectstack-ai#6259` as an added citation and exited 1, so it was moved one line down
(commit `b93f08f8d0`).
- **objectui's `objectstack-ai#6111`.** `field.zod.ts:370` reads 「objectui#6110 +
objectstack-ai#6111 (section)」. The qualifier covers only the first number, so the
citation grammar reads `objectstack-ai#6111` as this repository's (404 here). It is
objectui's number: its introducing commit `f887e5249` writes
`(objectui#6111)` in the same diff, and `objectstack-ai/objectui`
answers REST 200 for objectstack-ai#6111 to this session (and for objectstack-ai#6110 and objectstack-ai#10264).
objectui has no `refs/pull/6111/head`, so it is an issue there, not a
PR. The line is left unchanged. This is objectstack-ai#20330's grammar family, the
same as stage 2's `objectui PR objectstack-ai#10264`, and it is noted there, not
filed.
- **Capitalised qualifiers.** `CITATION_RE` classes `Pre-#N`, `POST-#N`
and `Framework#N` (7 sites in `data/`) as cross-repo and never judges
them. This census read them as this repository's. One was dead and is
rewritten here (`object.test.ts:223`, `POST-objectstack-ai#10347`). This is the same
objectstack-ai#20330 family as stage 1's `pre-` / `post-` finding.
- **Four files held after the claim.** Each joined the exclusions and
went back to the base bytes (hypothesis 2 of the dispatch). Each PR's
hunks were disjoint from this PR's lines, but the dispatch's rule is
file-level.
- `driver/turso.test.ts`: PR objectstack-ai#20504 (objectstack-ai#20437's) opened at
2026-09-28T20:08Z and edits it. Its two comment sites (`:4`, `:58`, both
`objectstack-ai#6345`) went back to blob `7fe99ebf9` in commit `86463ed0a1`. A
no-driver `merge-tree` of that head with PR objectstack-ai#20504's head `5dfa45e9f`
exits 0.
- `object.form.ts`: PR objectstack-ai#20519 (objectstack-ai#20432's) opened at 21:55Z and edits it.
Its one comment site (`:256`, `objectstack-ai#12868`, whose deciding commit is
`c459da6bc`) went back to blob `60713e06f` in commit `3479600dda`.
- `object.zod.ts`: PR objectstack-ai#20521 (objectstack-ai#20494's) opened at 22:10Z and edits one
line at `:2123`. Its 15 comment sites (`objectstack-ai#8772`, `objectstack-ai#10165`, `objectstack-ai#10347`,
`objectstack-ai#10527`, `objectstack-ai#11195`, `objectstack-ai#11408`, `objectstack-ai#13608`) went back to blob `befde04ca` in
commit `96fd49caa2`. Their deciding commits are `75b7c240a`,
`801296050`, `530c1df65`, `5649efbf9`, `b37231883`, `f11fc61c5` and
`fc9ba76a5`, all read for this stage.
- `filter-logic-conformance.ts`: PR objectstack-ai#20523 (objectstack-ai#20444's) opened at 22:39Z.
Its 3 comment sites (`objectstack-ai#13195`) went back to blob `c9b32acba` in the same
commit. Their deciding commit is `9dac1ae01`, with `PR objectstack-ai#13529` as the
link.
- **What stays for later stages.**
- The 33 dead sites in the held files listed above. The later stage can
reuse the deciding commits named for them here.
  - The 41 string sites and the 3 deliberate comment sites above.
- The `data/` numbers that also appear in
`packages/spec/src/migrations/**`. Those are objectstack-ai#20233's form D, or the
migrations stage.
- **The rung.** Several anchored changes also have ADR-0087 entries in
`packages/spec/src/migrations`. Examples are
`cbp-master-detail-required-forced` for objectstack-ai#8772,
`filter-between-blank-endpoint-refused` for objectstack-ai#18012, the `datasource-*`
entries for objectstack-ai#9040, objectstack-ai#9041 and objectstack-ai#8873, and the
`mapping-lookup-params-removed` conversion for objectstack-ai#10329. This PR takes the
commit rung, as stages 1 and 2 did, so it is precedent-consistent. The
D3 id is the more durable in-repo record, if the ruling's first rung is
later read to include those entries.
- **The citation gate's reach.** It defers `packages/**/*.test.ts`, so
20 of the 45 touched `.ts` files never enter its judging population. The
added-minus-removed count over the whole diff covers them: 0 numbers
added.
- **Base.** The branch is 22 commits behind `origin/main` (`1378ec7c0c`,
read at 2026-09-29T00:18Z). Four of those commits touch `data/`, all in
excluded files: objectstack-ai#20475's `hook.form.ts`, objectstack-ai#20487's `data-engine.*`, and,
since this stage excluded them, PR objectstack-ai#20521's `object.zod.ts`
(`9e1689f8e2`) and objectstack-ai#20444's `filter-logic-conformance.ts`
(`fb386074f5`). None touches a file in this diff, and a no-driver
`merge-tree` of the head onto `1378ec7c0c` exits 0. So there was no
merge. The open-PR file lists were re-read at 00:18Z: 11 open PRs, none
touching a file in this diff.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…-applied set (objectstack-ai#20563)

Fixes objectstack-ai#20538
Clause-②: no

`content/docs/deployment/cli.mdx` now documents the report-only
`unbuildable_index` drift op (added by objectstack-ai#20519) and names the complete
never-applied set.

## Before / after

`needs_confirm` row (category table)

- before: `os migrate apply` — except `manual_widen_varchar_to_text`,
which nothing applies
- after: `os migrate apply` — except `manual_column_type_change` (only
`os migrate multi-value-columns --apply` runs it), and
`manual_widen_varchar_to_text` and `unbuildable_index`, which nothing
applies

New Index-drift row (after `drop_index`)

- after: `unbuildable_index` — a declared index whose key column can
never exist (name is not a field of the object, or a virtual `formula`
field). Report-only, category `needs_confirm`; severity `error` for a
UNIQUE index, `warning` for a plain one. `os migrate apply` never
performs it and reports it `skipped`. A column merely not added yet is
pending `add_columns` work and is not reported.

`os migrate multi-value-columns` prose

- before: "it isn't the only one: `manual_widen_varchar_to_text` (...)
is also never applied, but has no `os migrate` subcommand of its own.
This section covers the op that does."
- after: names `manual_widen_varchar_to_text` and `unbuildable_index` as
also never applied, with no subcommand of their own; the section covers
`manual_column_type_change`.

Command table row for `os migrate multi-value-columns` (same
never-applied set)

- before: "one of two drift ops `apply` never reconciles"
- after: "one of three drift ops"

## Code anchors measured on origin/main 6154165

- `packages/drivers/driver-sql/src/schema-drift.ts:294`
`unbuildable_index` member of `DriftOp` (fields `table`, `column?`,
`indexName`, `unique`, `missingColumns`); `:373` listed in
`INDEX_DRIFT_OPS`; `:378` in `IndexDriftOp`.
- `schema-drift.ts:1970-1975` doc: classified `needs_confirm`, `os
migrate apply` reports it skipped; UNIQUE is `error`, plain is
`warning`.
- `schema-drift.ts:1976-2020` `diffUnbuildableIndexes`: `severity:
idx.unique ? 'error' : 'warning'` (`:2001`), `category: 'needs_confirm'`
(`:2002`); qualifies only when a key column is absent AND never
materializes (misspelt name or virtual `formula`, `:1958-1963`); a
not-yet-added column is excluded (`:1965-1968`).
- `packages/drivers/driver-sql/src/sql-driver.ts:13884`
`applyIndexDriftOp`: `if (op.type === 'unbuildable_index') return
false;`, so the entry is reported `skipped` on every dialect (call site
`:13865`, dispatch `:14022`).
- `manual_column_type_change` never applied: `schema-drift.ts:166-180`
(no reconciler arm, "skipped, never applied ... the intended
behaviour"), emitted at `:1137-1138` as severity `error`, category
`needs_confirm`; `sql-driver.ts:14131` (no reconciler arm on any
dialect, by decision).
- `manual_widen_varchar_to_text`: `schema-drift.ts:212`, emitted
`:1341-1342` as `error` / `needs_confirm`.

The code agrees with the card on every point, with one correction found
in review: `manual_column_type_change` is never applied by `os migrate
apply` but IS applied by `os migrate multi-value-columns --apply`
(`packages/cli/src/commands/migrate/multi-value-columns.ts:105-107`
selects only that op; `:315-318`, `:338-339` the `--apply` path), so the
row separates it from the two ops nothing applies. Follow-up commit
b982ca1. Nothing was copied from the card without a read.

## Acceptance notes

- CLI source and `os migrate apply`'s skip-summary wording are untouched
(other lane).
- Gates (re-run on b982ca1, 36 of 41 derived, all exit 0 except the
one below): docs-relevant families derived by `dispatch-gates.mjs
--commands` run in the foreground; all 0 except `check:skill-examples`,
which exits 3 (PREREQUISITE NOT MET: needs a `@objectstack/client-react`
build; not a finding, NOT MEASURED). Neither `check:docs` nor
`check:docs-transcript-drift` nor `check:nul-bytes` flags the change.
- Docs-only; no changeset (`content/docs/**` is not shipped in a package
`files[]`).
- Commit trailers are the model-free pair.

## 维护者速读(草稿)

Not applicable: no managed path (`.claude/**`) is touched.

---
🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01VDtqoecgES7ScQYGbFVDRv

---------

Co-authored-by: Claude <noreply@anthropic.com>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…ng key column is logged at error on the durability sink (objectstack-ai#20650)

Fixes objectstack-ai#20537
Clause-②: no

The remote Turso face now reports a declared index it skips, because a
key column never materializes, at `error` on its existing durability
sink. It used to report it at `warn` on the diagnostic sink. This is the
remote half of what PR objectstack-ai#20519 did for the local face.

## What was wrong (read on `cd901d7a5`)

`RemoteTransport.buildDeclaredIndexDDL`
(`packages/drivers/driver-turso/src/remote-transport.ts`) plans no DDL
for a declared index whose key column is not a stored column. That
covers a misspelt name (`statsu` on a table whose column is `status`)
and a virtual `formula` field. Skipping it is right, because DDL naming
a missing column would fail the whole sync. But the skip was reported
through `this.diagnosticSink`, which `TursoDriver` wires to
`logger.warn`:

[RemoteTransport] skipping declared index "NAME" on "TABLE" — column(s)
not materialized: statsu

For a UNIQUE index, every write keeps succeeding and duplicates are
accepted, and the only trace was a `warn`. The same transport already
has a second sink for this class, `durabilitySink` (wired to
`logger.error`). Its retrofit arm uses it for a unique AND a plain index
it could not create. The local face (`SqlDriver.syncDeclaredIndexes`)
logs this same skip through `logDurabilityFailure` for unique and plain
indexes alike.

## What changed

- **`remote-transport.ts`, `buildDeclaredIndexDDL`.** The
not-materialised arm now calls `this.durabilitySink?.(…)`, not
`this.diagnosticSink?.(…)`. That is triage's direction: no new sink and
no second rule. It writes one line per skipped index per sync, as the
local face does. The line gives the index name, the object and each
missing column, and says whether the index was UNIQUE. It follows the
AGENTS.md "Degradation log levels" shape:
- the consequence: for UNIQUE, "the uniqueness it declares is NOT
enforced: duplicate rows are accepted, and nothing looks broken from the
outside"; for plain, every query the index serves is a full scan while
results stay correct;
- the fix: make every key column a stored field of the object, or remove
the index.
- The doc comments on `durabilitySink` and `buildDeclaredIndexDDL` now
name this arm.
- `turso-driver.ts` is **not** edited. Both sinks were already wired
there.
- No DDL, accept set or refusal changes. The same indexes are created
and the same ones are skipped.
- Changeset: `.changeset/20537-remote-skipped-index-durability.md`,
`@objectstack/driver-turso` **patch**.

## The dispatch's hypotheses, measured

- **H1 held**, with one correction. The skip is in
`buildDeclaredIndexDDL`, the planner, not the sync itself, and all four
sync paths call it: `syncSchema` (new and existing table) and
`syncSchemasBatch` (new and existing table). One edit covers all four,
and the pins drive all four.
- **H2 held.** `turso-driver.ts` has `setDiagnosticSink` going to
`this.logger.warn` and `setDurabilitySink` going to `(this.logger.error
?? this.logger.warn)`, at `:1656` and `:1667` on this base. No edit was
needed.
- **H3 held.** The local text is in `SqlDriver.syncDeclaredIndexes`,
through `logDurabilityFailure`, for unique and plain alike. The remote
line uses the same consequence-then-fix order.
- **H4: the plain index goes on the durability sink too.** There are two
pieces of evidence:
- the local face routes the plain skip through `logDurabilityFailure`,
and its doc says "A plain index is DDL that was supposed to run and did
not, so it takes the same channel";
- the remote retrofit arm already reports a plain index it could not
create on `durabilitySink`, with the objectstack-ai#17609 rationale: queries answer
correctly by scanning, nothing looks wrong, and the cost arrives as read
volume.
- **H5: `check:durability-log-level` does not see this site, and no
vocabulary entry belongs there.** Its header says it judges
`try`/`catch` blocks whose `try` calls a declared durability-critical
operation. This arm has no `catch` and runs no operation: it plans no
DDL. It reports through a sink receiver, which `LOGGER_RECEIVERS` does
not cover by a recorded decision. So no `DURABILITY_CRITICAL_CALLEES`
entry can make it visible. The gate reads 38 seams, all loud, on this
head (exit 0).

## Tests


`packages/drivers/driver-turso/src/remote-transport-unbuildable-declared-index.test.ts`
has 18 cases. They use the real `@libsql/client` over `file::memory:`,
as the declared-index parity suite does:

- **The card's matrix (16 cases).** {misspelt `statsu`, `formula`
column} × {UNIQUE, plain}, each over `syncSchema` and
`syncSchemasBatch`, against a new table and an existing one. Each case
asserts:
- exactly one durability-sink line names the index, with the table and
the column quoted;
- `UNIQUE` is present for the unique cells and absent for the plain
ones;
  - no diagnostic-sink line names the column or the index;
  - the sync resolves;
  - the index is absent from `sqlite_master`.
- **End to end (2 cases).** `TursoDriver` in remote mode (`libsql://…`
with a supplied client), through `initObjects`. The line lands on
`logger.error` and never on `logger.warn`.

The prose is not pinned beyond the named subjects and the `UNIQUE` word,
as in the local objectstack-ai#20432 suite.

Commands, run on `90e8f132f`:

- `pnpm --filter @objectstack/driver-turso exec vitest run
--maxWorkers=2 src/remote-transport-unbuildable-declared-index.test.ts`:
1 file, **18 passed**.
- `pnpm --filter @objectstack/driver-turso exec vitest run
--maxWorkers=2` (whole package): **79 files passed, 2126 passed, 22
skipped**, verdict `command-exit 0`.
- `pnpm --filter @objectstack/driver-turso typecheck`: `command-exit 0`.
The package `tsconfig` includes `src/**/*`, and `tsc --noEmit
--listFiles` lists the new test file once.

**Reverse verification (ablation).** The fix was committed first. The
mutation went through `scripts/ablation-replace.mjs` in WRAP mode, with
its own trap and restore. It turned the new arm's
`this.durabilitySink?.(` back into `this.diagnosticSink?.(`, and nothing
else.

- **Landed on disk:** anchor count went 1 to 0, and `durabilitySink?.(`
/ `diagnosticSink?.(` counts went 2/3 to 1/4. The blob went
`1c7cb45f2828` to `672bd32cd8c6`.
- **Result:** predicted all 18 red. Observed **18 failed of 18**, on a
comparison (`expected [] to have a length of 1 but got +0`).
- **Restored:** blob equals HEAD (`1c7cb45f2828`), `git diff HEAD` is
empty, and the tree is clean.
- **No `dist` step:** the suite imports the subject by a relative path
(`./remote-transport.js`, `./turso-driver.js`), so vitest reads `src`.

## Gates

- **Derived gates.** Derived after the final commit with `node
scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` at `90e8f132f`: 61 commands. The dispatch-time list had 48;
the 13 added come from the changeset and test-file families. All 61 ran
and exited 0. `--ran` with the exit codes recorded reads: "61 derived,
61 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero)".
- **Three gates needed a full build first.**
`check:dual-build-cjs-loads`, `check:lean-entry-closure` and
`check:type-check-debt` first exited 3 (`PREREQUISITE NOT MET`, not
measured). After a full build (`turbo run build --filter='./packages/*'
--filter='./packages/*/*'`, 71 of 71 tasks, with the tree clean
afterwards), each exited 0.
- **`pnpm check:driver-conformance`, before and after:** "50 covered
cell(s), 0 in the DEBT ledger, 0 exempt" both times.
- **`pnpm check:durability-log-level`:** not in the derived set; run for
H5. Exit 0.
- **Lint**, narrowed to the two touched `.ts` files with `eslint
--no-inline-config --format json`: 2 files, 0 errors, 0 warnings.
  - Both files are matched by the config and neither is ignored.
- `eslint.config.mjs` never enables type-aware linting (no
`parserOptions.project`), so this diff cannot move the verdict on any
file it does not touch.
  - The full `pnpm lint` run is CI's.

## Acceptance notes

- **The reason for a missing column is not given per column.** The local
line says, per column, why it has no column ("not a field of the object"
or "a formula field"), through `describeMissingIndexColumns`. That
helper is exported from `schema-drift.ts` but not from
`@objectstack/driver-sql`'s package entry. Reusing it would widen that
package's public surface, outside this card's claimed files. A copy here
would be the second copy this file's shared-normalizer imports exist to
prevent. So the remote line names the missing columns and gives both
possible reasons in one clause. Carrier: none.
- **With no durability sink set, the skip is not logged at all.**
Before, it went to the diagnostic sink. `TursoDriver` wires both sinks
together at construction, so no composition in this repo changes. The
retrofit arm already works this way, and the `durabilitySink` doc says
what still surfaces a missing UNIQUE (the enveloped `conflictKeys`
refusal).
- **Remote drift detection is not in this card.** The remote face
refuses it by design, so `os migrate plan` still cannot show the skipped
index on a remote datasource. Only the log line changes here.
- **The branch is two commits behind `main`** (`89801cd96`, `0cb72cfc7`:
service-automation and spec migration text). Neither touches
`packages/drivers`, so no merge was made. CI validates the merge ref.

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

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

2 participants