Skip to content

fix(driver-turso): a declared index the remote face skips for a missing key column is logged at error on the durability sink - #20650

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-20537-remote-skip-durability-sink
Sep 29, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-20537-remote-skip-durability-sink

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #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 #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:
  • 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 #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

… through the durability sink

A declared index whose key column never materializes (a misspelt name, a
virtual formula field) is skipped by RemoteTransport.buildDeclaredIndexDDL.
The skip was reported through the diagnostic sink (logger.warn); it now
goes through the existing durability sink (logger.error), stating the
consequence and the fix, for unique and plain indexes alike, as the local
face's syncDeclaredIndexes does through logDurabilityFailure.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

What this run could not see
  • 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 — 7 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 0cb72cfc72ff6bc15b5e6374c2c818b9d9ead534 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 0cb72cfc72ff6bc15b5e6374c2c818b9d9ead534

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 90e8f132f70c6f1e72eb8de113560af647d96655
Local-runs: none

PR #20650 (card #20537), the net diff against main at this head: 3 files, +268/-8. Inputs read: the card body and all three comments (triage 5882218115, the dispatch Claim 5890821367, the os-dev-report 5891472582), the PR body and file list, the diff, and the head's check-runs. Nothing was built, run or re-run.

① Derived judgments

  • Accept set: unchanged — right. In RemoteTransport.buildDeclaredIndexDDL the diff replaces one sink call and its message; the missing computation, the continue, the emitted dedupe and the planned list are byte-for-byte what main has. The same indexes are planned and the same ones are skipped; no DDL text moves. The changeset's "No DDL, accept set or refusal changes" sentence is true.
  • Public surface: unchanged — right. RemoteTransport is a package export (src/index.ts), and setDiagnosticSink / setDurabilitySink keep their signatures. The only behavioural change is which registered sink the skip reaches. A transport driven standalone with only a diagnostic sink registered now hears nothing for this skip; the file's own docs call that composition unsupported, TursoDriver registers both sinks at construction (turso-driver.ts lines 1656 and 1667 on main), and the PR's acceptance notes say so. Patch-level behaviour, no API motion.
  • Channel and level — right. The new arm calls this.durabilitySink?.(...), which TursoDriver wires to (this.logger.error ?? this.logger.warn); that is the same channel the retrofit arm (retrofitDeclaredIndexes) already uses for an index it could not create, and the same level the local face's SqlDriver.syncDeclaredIndexes uses through logDurabilityFailure for this same skip (sql-driver.ts around line 14659 on main). The warn fallback on an error-less injected logger is pre-existing and shared with the local face, not introduced here. On head the file has 2 durabilitySink?.( sites and 3 diagnosticSink?.( sites (was 1 and 4), matching the dev's ablation counts.
  • Coverage of the four sync doors — right. On main all four paths call the one planner: syncSchema on a new table (line 2058) and an existing one (2086), syncSchemasBatch on a new table (2168) and an existing one (2214). One edit covers all four, and the new suite's 16-case matrix drives all four; the skipped index never enters planned, so the retrofit leg cannot report it a second time.
  • Runtime log message, sentence by sentence — every one true against main and the diff. (a) declared UNIQUE index "NAME" on "TABLE" was NOT created: no column for 'x' — true. (b) "a key column must be a stored field of the object — a name that is not a field, or a virtual formula field computed on read, has no column" — true and, for the remote face, exhaustive: materializedColumns (line 2101 on main) admits the builtin columns plus every declared field except type: 'formula', so those two are the only ways a key column can be missing here. (c) UNIQUE consequence: "The uniqueness it declares is NOT enforced: duplicate rows are accepted, and nothing looks broken from the outside" — true; this is the owed part ① (what is not durable, and that the system keeps looking healthy). (d) Plain consequence: "Every query this index exists to serve is answered by scanning the whole table instead: nothing looks broken from the outside and results stay correct" — true, and the retrofit arm's plain text on main says the same. (e) Fix: "Fix the metadata so every column in the index's fields is a stored field of the object, or remove the index ("os validate" refuses a name that is not a field)" — true; packages/lint/src/validate-object-field-refs.ts on main judges indexes[].fields[] at error on the three commands and the publish door, and its own header says a formula key is judged as existing and left to the sync's skip — so the prescription's scoping is exactly right. This is the owed part ②. Both owed parts sit in the one string the sink receives, so they are in the first line printed. It is said once per skipped index per sync (a boot-time act), not per write. The string carries no tracker number; the [#20537] citations are in code comments, the convention this file already follows.
  • Changeset prose, sentence by sentence — true. "In remote mode (a libsql:// URL)" — true; the parenthetical is one example of remote mode (turso-driver.ts also routes https://, http://, wss://, ws:// without a syncUrl there), not a false claim. "skips a declared index whose key column is not a stored column: a name that is not a field of the object (a misspelling), or a virtual formula field, which is computed on read and has no column" — true (see (b)). "The skip itself is unchanged, since DDL naming a missing column would fail the whole sync" — true, and main's own doc on the planner says the same. "It used to be reported through the driver's warn diagnostics, so a skipped UNIQUE index left duplicates accepted while the log said warn" — true (diagnosticSink to logger.warn, line 1656). "now logged at error, on the same channel the remote face already uses for a declared index it could not create, and the local face uses for the same skip" — true. "one line per skipped index per sync" — true per planned entry; a same-named entry declared twice with a missing column prints twice, which is the pre-existing loop order on both faces and not this card's matter. "names the object, the index and the missing column, says whether the index was UNIQUE, states what is not enforced ... and says how to fix it" — true against the string in the diff. "No DDL, accept set or refusal changes" — true.
  • "Do not over-apply it" — the plain index belongs at error here. AGENTS.md's durability class names "DDL that was supposed to run did not" outright; the local face already routes the plain skip through logDurabilityFailure ("A plain index is DDL that was supposed to run and did not, so it takes the same channel"), and the remote durabilitySink doc's [driver-turso: remote mode never materializes object-level indexes — every declared secondary index is absent on production Turso tenant databases, so hot polling queries full-scan #17609] paragraph already grades a plain index it could not create the same way. Putting the plain skip on the diagnostic sink would have split one class across two levels on one face. Right.
  • Tests — right shape. remote-transport-unbuildable-declared-index.test.ts: 16 matrix cases on the real @libsql/client over file::memory: assert exactly one durability line naming the index, table and column, UNIQUE present or absent, zero diagnostic lines naming either, the sync resolving, and the index absent from sqlite_master; 2 cases drive TursoDriver in remote mode through initObjects and read logger.error versus logger.warn. The file reads nothing outside its package (@objectstack/driver-sql's buildIndexName is on that package's entry, src/index.ts line 91), so the cross-package-inputs gate has nothing to see. Prose is pinned only on the named subjects and the UNIQUE word, as the local [finding] a misspelt field name in a field's relatedListColumns, lookupColumns, lookupFilters[].field or dependsOn passes every authoring door, and fails only at view or picker time #20432 suite does.
  • Doc comments — consistent with the code. The durabilitySink doc gains a paragraph naming this arm; the planner's doc widens "(a virtual formula field)" to include the misspelt name and states the sink. No governed surface, no CHANGELOG.md, no turso-driver.ts edit (the Claim's read-only constraint held).
  • Check-runs on this head, as read at 2026-09-29T13:47Z: 31 runs; 12 success (Governed Surface Queue Guard, Check Changeset, Check PR Size, Type Check · source gates, the four claim/card guards, Auto Label, Check Documentation Links, Flag docs affected by code changes, filter); 3 skipped (Console Pin Gate, Build Docs, Packed-tarball smoke); 16 in_progress, among them every remaining required context: Lint & Repo Gates, the three Type Check jobs, Test Core 1 to 6, Dogfood Regression Gate 1 to 3, Build Core, Temporal Conformance (live PG + MySQL); 0 failure. Not polled. in_progress is not a pass: the landing seat reads those green before arming.

② Semver level

  • .changeset/20537-remote-skipped-index-durability.md declares '@objectstack/driver-turso': patch. The diff publishes a bug fix in a released package (a log level and message on an existing seam) with no export, key or config motion: patch is the level AGENTS.md's checklist requires, and skip-changeset would have been wrong. Check Changeset is success on this head.
  • Clause-②: no — present in the changeset body and in the PR body, both bare with no (widening) / (narrowing) arm. Correct: nothing an author can write is accepted or refused differently. No ADR-0087 marker is owed and none is written.
  • The package sits in the changesets fixed group, so the group bumps together at release; the declared level for this change is still the right input.

③ Boundary flags

The report's open_questions is empty. Its four flags, each answered:

  1. The plain (non-unique) skip also goes to the durability sink — right, not escalated. Triage left it to "whichever sink the durability rule assigns it"; the rule assigns undone declared DDL to error, and both existing conventions on main (local syncDeclaredIndexes, remote retrofit arm and the durabilitySink doc) already do so. See ①.
  2. check:durability-log-level does not see this site — right, and no vocabulary entry was owed. The gate's first rule judges a catch whose try calls a DURABILITY_CRITICAL_CALLEES name; this arm sits in a planner with no try/catch and performs no operation. Its receiver, this.durabilitySink?.(, is outside LOGGER_RECEIVERS (logger / log / console) by the header's recorded limitation 3. No DURABILITY_CRITICAL_CALLEES entry can make a non-catch site visible, so adding one would have been noise. The AGENTS.md invariant "what this checker cannot see, that test holds" is met by the 18-case suite pinning the channel and the level. Not escalated.
  3. The branch was not merged with origin/main — verified, not escalated. Merge-base cd901d7; main is ahead by 89801cd (service-automation, core/security, service-messaging) and 0cb72cf (spec migrations text, docs, changeset). Neither touches packages/drivers; GitHub reports the PR mergeable. The queue rebuilds on the merge ref, which is the §10 re-verification.
  4. describeMissingIndexColumns was not shared — right, noted where it belongs. On main, packages/drivers/driver-sql/src/index.ts re-exports buildIndexName from schema-drift.js but not describeMissingIndexColumns; sharing it would widen a published package's surface outside the card's claimed files. Because the remote face's only two missing-column causes are the two the message already names (①, (b)), the operator loses no information they need to act. Recorded in the PR's acceptance notes with carrier none, which is what Prime Directive chore: version packages #10 asks for a note that is neither a defect, a contract violation nor a trap. If a later card wants byte-parity with the local line, it is a driver-sql export with an api-surface regeneration, not a driver-turso change.

Other deviations in the report (the extra check:durability-log-level run, model-free commit trailers, no labels written, worktree cleanup) are conforming and need no answer.

Implemented-by: claude/issue-20537-remote-skip-durability-sink
Reviewed-by: session_01DEvba2nBuD4tWzfq8r8NFY

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 29, 2026 13:55
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit 19fc8d6 Sep 29, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20537-remote-skip-durability-sink branch September 29, 2026 14:13
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…o the commits that decided them, and the source-hashes header at its producer (objectstack-ai#20656)

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

## What changed

This is stage 3 of the `domain:cli` lane of the dead-citation sweep:
`packages/cli/src/**`, plus the generated-header producer the
`domain:services` pointer on the card hands this lane. Every comment or
docblock site in scope that cited a tracker number answering 404 now
cites, in ruling C+D's form C (comment 5749154545 on objectstack-ai#19123), the commit
in this repository's history that decided what the line describes, and
says in its own words what that commit decided. PR objectstack-ai#20533 is the method;
PR objectstack-ai#20624 (stage 1, `packages/runtime`) and PR objectstack-ai#20632 (stage 2,
`packages/rest`) are the precedents this follows. Later stages cover
`types` and the rest of the lane, so this PR says `Part of` and the card
stays open.

That is **313 comment sites on 304 lines in 64 files, covering 63
numbers**: the census's 170 rewritable sites (of its 174), 142 more in
test comments (which the census defers), and one site whose dead number
is the second half of a slash-joined pair the citation grammar does not
read (`serve.ts:1173`, `objectstack-ai#10943/objectstack-ai#11157`). Each rewritten line cites one
of **62 distinct commits**, except the six `objectstack-ai#15041` sites, which cite
ADR-0104's 2026-09-05 addendum: that ADR records the maintainer ruling
the lines describe, and the ruling allows the ADR to be cited instead of
a commit.

Only comments changed in `packages/cli/src`, apart from the two string
literals this stage declares (next section). Every touched file keeps
its line count (319 lines out, 319 in, over 65 files), so no line
citation into these files moves. Thirteen of the 319 lines held no dead
site; each is the other half of a sentence that had to change:
`create.ts:326`, `doctor-organizations-message-spelling.test.ts:11`,
`environments.test.ts:162`, `generate.ts:153`,
`serve-cluster-host-resolution.test.ts:816`,
`serve-host-fallback-base.test.ts:5`, `validate.ts:336`,
`validate.ts:813`, `validate.ts:815`, `hook-body-lowering.test.ts:10`,
`format.ts:1132`, `format.ts:1435` and `i18n-extract.ts:34` (mostly
「that card」 to 「that commit」 once the antecedent became a commit).

**No citation number is added.** Every tracker number on an added line
was already on the line it replaces. One PR number stands on an added
line, and it was there before:
`doctor-organizations-message-spelling.test.ts:9` read 「PR objectstack-ai#12463
(objectstack-ai#12151) single-sourced」 and now reads 「Commit 27b6902 (PR objectstack-ai#12463)
single-sourced」, keeping the live PR as a convenience link beside the
commit, as the ruling allows (objectstack-ai#12463 is that commit's own PR). No code
token moves (see the guard below).

Four dead comment sites are left on purpose, listed under "The sites
left". Two more files outside `packages/cli`: a `patch` changeset for
`@objectstack/cli`, and the shrink-only `check:doc-authoring` prose-id
ledger (see Deviations).

## The source-hashes producer and its 27 generated companions

The claim declares these as the only strings this stage moves, and the
regeneration of the files they write.

- **The producer.** `packages/cli/src/utils/i18n-extract.ts:2294` is the
string literal `renderSourceHashModule` writes as line 8 of every
`LOCALE.source-hashes.generated.ts`. It read 「bundles (objectstack-ai#11671,
maintainer ruling objectstack-ai#12069 Option A, extending objectstack-ai#8765 Option B).」 and now
reads 「bundles (commit 09b4f4e, maintainer ruling objectstack-ai#12069 Option A,
extending objectstack-ai#8765 Option B).」. `09b4f4e4e` is the commit that extended the
objectstack-ai#8765 Option B source-hash mechanism to the generated bundles per
maintainer ruling objectstack-ai#12069 Option A; its message says exactly that, and it
is the anchor stages 1 and 2 of objectstack-ai#20596 gave `objectstack-ai#11671`. objectstack-ai#12069 and objectstack-ai#8765
answer 200 (REST and web) and stay. The comment at `:249` beside
`previousSourceHashes` cites the same commit.
- **The flag's help text**
(`packages/cli/src/commands/i18n/extract.ts:227`, author-shown CLI
help), in form D: the lesson in words, no number. It read 「the
provenance companion that lets a stale fill be told from a translation
(objectstack-ai#11671).」 and now reads 「the provenance companion that records which
source revision each generated leaf is still a copy of, so a stale fill
can be told from a translation.」. The rest of the description is
unchanged.
- **The regeneration.** `node scripts/check-i18n-bundles.mjs --write`,
which runs each package's documented `os i18n extract` command from its
`i18n-extract.config.ts` (every one of the nine carries the
source-hashes flag), on a CLI built from `47241dd80e`. Before it, the
bundle check reported the nine packages DRIFTED, 3 bundles each, against
the new producer. After it:
- exactly 27 files changed, `+27 −27`: 3 locales in
`packages/platform-objects/src/apps/translations`,
`plugins/plugin-{approvals,audit,security,sharing,webhooks}` and
`services/service-{messaging,realtime,storage}`;
- every hunk is `@@ -8 +8 @@`, and the one removed and one added line
are the same in all 27 files;
- each file with line 8 deleted hashes identically at base and head (27
of 27), so every hash entry is byte-identical;
- `git status` shows nothing else in the nine packages, tracked or
untracked;
- `node scripts/check-i18n-bundles.mjs` then reads all nine packages in
sync (7 bundles each), and `check:i18n-stale-fill` serves 27 of 27
companions with 0 stale fills.
- **H3 holds.** `git grep -n "objectstack-ai#11671" -- '*.source-hashes.generated.ts'`
answers 0 hits at head; the same grep at `04b202e5cb` answers 27 (the
positive control).
- **Not published by the nine.** The header is a comment their bundlers
strip: after the build, none of the nine packages' `dist` holds
`09b4f4e4e` or the header's own phrase 「Each entry is the digest of the
SOURCE REVISION」, while the export name `…GeneratedSourceHashes` (the
positive control) is in each `dist`. So the regeneration changes no
published byte of those packages, and no changeset is owed for them. The
other lanes' stages can keep leaving their generated copies alone, as
the pointer asked.

## Held files

`packages/cli/src/utils/sdui-manifest.ts` and `sdui-manifest.test.ts`
stay at their base blobs (`41c4549ffe` and `3a242b54e0`, equal at base
and head), because PR objectstack-ai#20589 edits them. They carry four citations
(`objectstack-ai#4409` once, `objectstack-ai#20113` three times), and all four answer 200, so no
dead site is held and there is no anchor to list for a follow-up.

## Census: `packages/cli`, before and after

**Instrument.** The gate's own `node scripts/check-issue-citations.mjs
--census --json`, read-only and unchanged, run with the fleet token. Its
surface is comment prose in `packages/**/src/**/*.ts` with string
literals blanked, and it defers `*.test.ts`. The count is its
`allocated-but-absent` findings under `packages/cli/`. Every run
enumerated the whole board (185 pages), so none read a truncated board.

| reading | tree | board | whole-repo `allocated-but-absent` | cli sites
| lines | files | numbers |
|---|---|---|---|---|---|---|---|
| before | base `04b202e5cb`, run 2026-09-29T12:35:39Z to 12:43:03Z |
enumerated, 185 pages, frontier objectstack-ai#20642, 18,469 numbers | 1,707 | **174**
| 167 | 30 | 50 |
| after | head `6bb4d3b531`, run 13:46:09Z to 13:54:25Z | enumerated,
185 pages, frontier objectstack-ai#20652, 18,479 numbers | 1,510 | **4** | 4 | 3 | 2 |

The before count equals the card's 174 at `f11b5f20a2`. The 4 left are
the deliberate sites below. The whole-repo drop is 197: this diff's 170
cli sites plus the 27 generated headers (3 in each of the nine packages;
nothing else moved in any of them). The two merges of `origin/main`
moved no count. An earlier after-run at `d1e09a7eed` (13:19:18Z to
13:29:41Z, frontier objectstack-ai#20649) read the same 4 and 1,510; `packages/cli`
and the 27 companions are byte-identical between the two heads. One more
attempt at `6bb4d3b531` (13:35:15Z) exited 3, PREREQUISITE NOT MET, on a
malformed board page, and measured nothing; the run in the table is its
retry.

**Supplementary instrument, the whole scope.** The census does not read
test files or strings, and this stage's scope includes test comments. So
a second reading runs the gate's own exported `extractCitations`
(whole-file and comment-prose projections) and `classifyCitation` over
every `.ts`/`.tsx` file under `packages/cli/src` (298 files), against a
board enumerated through the gate's own `enumerateBoard`. The lit
controls objectstack-ai#20594, objectstack-ai#19123 and objectstack-ai#20632 answered 200 and are on both boards;
the dead controls objectstack-ai#11671, objectstack-ai#10514 and objectstack-ai#14828 answered 404 and are on
neither.

| reading | tree | board | citations | dead | src comment | test comment
| src string | test string |
|---|---|---|---|---|---|---|---|---|
| before, 12:43Z | `04b202e5cb` | 185 pages, frontier objectstack-ai#20642 | 3,029 |
**368** | 174 | 142 | 4 | 48 |
| after, 13:39Z | `6bb4d3b531` | 185 pages, frontier objectstack-ai#20650 | 2,715 |
**54** | 4 | 0 | 2 | 48 |

Its src-comment column equals the census's 174 and 4, which is the
control on the second instrument. The resolving citations (1,468 and 755
in comments, 50 and 53 as pull requests, 32 cross-repo) are the same in
both readings, so no live citation was lost; the drop of 314 is exactly
the dead sites removed (312 grammar-read comment sites and the two
`objectstack-ai#11671` strings). The one slash-joined dead number the grammar never
reads (`objectstack-ai#11157` in `objectstack-ai#10943/objectstack-ai#11157`) is gone too: a separate scan for
dead `#N` tokens the grammar skips answers 1 before and 0 after.

## Per-number table

Sites and files are the dead comment sites in scope at the base, test
sites counted in brackets. `left` is a site with no deciding commit (see
below). `strings kept` counts string-literal sites, which are tokens and
stay as they were. Every anchor was read in its message or its diff, not
only in its subject: it is the commit that made the change the line
describes, and its own message or diff names the number it replaces or
adds the citation the line carries.

| number | comment sites / files | rewritten | left | strings kept |
anchor |
|---|---|---|---|---|---|
| `objectstack-ai#6217` | 12/8 (1 test) | 12 | 0 | 0 | `2b641ddd4` |
| `objectstack-ai#6238` | 2/1 (2 test) | 2 | 0 | 2 | `c8d6f6e08` |
| `objectstack-ai#6265` | 1/1 (1 test) | 1 | 0 | 0 | `cfb549db8` |
| `objectstack-ai#6268` | 6/2 (2 test) | 6 | 0 | 1 | `68f5eccb1` |
| `objectstack-ai#6293` | 2/2 (1 test) | 2 | 0 | 0 | `c39a911ae` |
| `objectstack-ai#6344` | 2/2 (1 test) | 2 | 0 | 0 | `cfb549db8` |
| `objectstack-ai#6345` | 21/6 (11 test) | 21 | 0 | 4 | `e2798fab7` |
| `objectstack-ai#6535` | 1/1 | 1 | 0 | 0 | `a92b1793c` |
| `objectstack-ai#8692` | 5/2 (3 test) | 5 | 0 | 3 | `712e185db` |
| `objectstack-ai#10326` | 1/1 | 1 | 0 | 0 | `675ab574e` |
| `objectstack-ai#10359` | 2/2 | 2 | 0 | 0 | `15b63e85a` |
| `objectstack-ai#10398` | 1/1 (1 test) | 1 | 0 | 0 | `0681a76b8` |
| `objectstack-ai#10485` | 1/1 | 1 | 0 | 0 | `35ad101bc` |
| `objectstack-ai#10499` | 1/1 | 1 | 0 | 0 | `6d441e41f` |
| `objectstack-ai#10504` | 8/1 | 8 | 0 | 0 | `ff5733e03`, `0d4bd93e7` |
| `objectstack-ai#10514` | 16/2 (16 test) | 16 | 0 | 2 | `5359a9b4c` |
| `objectstack-ai#10763` | 1/1 (1 test) | 1 | 0 | 0 | `c2b97c2a1` |
| `objectstack-ai#10769` | 9/2 (6 test) | 9 | 0 | 0 | `3d7deb700` |
| `objectstack-ai#10908` | 10/3 (6 test) | 10 | 0 | 5 | `9cc6777d3` |
| `objectstack-ai#10909` | 2/1 | 2 | 0 | 0 | `5a90c56d1` |
| `objectstack-ai#10917` | 1/1 | 1 | 0 | 0 | `7940de5e0` |
| `objectstack-ai#10926` | 1/1 | 1 | 0 | 0 | `d173125fb` |
| `objectstack-ai#10943` | 5/3 (2 test) | 5 | 0 | 0 | `46d34ab7c` |
| `objectstack-ai#10944` | 9/3 (6 test) | 9 | 0 | 3 | `e598b1cbc` |
| `objectstack-ai#10952` | 5/1 | 5 | 0 | 0 | `0d4bd93e7`, `ff5733e03` |
| `objectstack-ai#10953` | 1/1 (1 test) | 1 | 0 | 0 | `be7262e72` |
| `objectstack-ai#10967` | 6/2 (6 test) | 6 | 0 | 1 | `e4a71d418` |
| `objectstack-ai#11022` | 1/1 (1 test) | 1 | 0 | 0 | `21756b325` |
| `objectstack-ai#11025` | 3/2 | 3 | 0 | 0 | `1c3a46f87` |
| `objectstack-ai#11048` | 1/1 | 0 | 1 | 0 | — |
| `objectstack-ai#11071` | 3/2 | 3 | 0 | 0 | `50fb191dc` |
| `objectstack-ai#11157` | 15/4 (10 test) (1 slash-joined) | 15 | 0 | 2 | `a4cb7817f`
|
| `objectstack-ai#11172` | 5/1 | 5 | 0 | 0 | `05181e8cc` |
| `objectstack-ai#11174` | 2/1 (2 test) | 2 | 0 | 1 | `ab23c67ab` |
| `objectstack-ai#11221` | 3/1 (3 test) | 3 | 0 | 1 | `e278a2970` |
| `objectstack-ai#11331` | 3/2 | 0 | 3 | 0 | — |
| `objectstack-ai#11671` | 1/1 | 1 | 0 | 0 (2 moved) | `09b4f4e4e` |
| `objectstack-ai#12125` | 11/3 | 11 | 0 | 0 | `79cf692b0` |
| `objectstack-ai#12151` | 3/2 (3 test) | 3 | 0 | 3 | `27b690272` |
| `objectstack-ai#12162` | 2/1 (2 test) | 2 | 0 | 0 | `c0f5e8f21` |
| `objectstack-ai#12181` | 4/2 (3 test) | 4 | 0 | 0 | `cf71d73f8` |
| `objectstack-ai#12297` | 3/1 | 3 | 0 | 0 | `9fd45a952` |
| `objectstack-ai#12943` | 2/1 (2 test) | 2 | 0 | 0 | `090f2302e` |
| `objectstack-ai#12961` | 1/1 | 1 | 0 | 0 | `901355c3b` |
| `objectstack-ai#13109` | 2/1 | 2 | 0 | 0 | `8b236c826` |
| `objectstack-ai#13193` | 6/2 (3 test) | 6 | 0 | 0 | `faff497fd` |
| `objectstack-ai#13218` | 1/1 | 1 | 0 | 0 | `c45d8e6b4` |
| `objectstack-ai#13347` | 3/3 (2 test) | 3 | 0 | 3 | `098a08ffa` |
| `objectstack-ai#13651` | 12/7 (4 test) | 12 | 0 | 0 | `ada3834ad` |
| `objectstack-ai#14192` | 2/2 | 2 | 0 | 0 | `4d0d9445a` |
| `objectstack-ai#14336` | 4/1 | 4 | 0 | 0 | `79c71d29d` |
| `objectstack-ai#14397` | 1/1 | 1 | 0 | 1 | `957f7bb45` |
| `objectstack-ai#14657` | 20/2 (7 test) | 20 | 0 | 1 | `431979e67` |
| `objectstack-ai#14667` | 1/1 | 1 | 0 | 0 | `dc7c226b9` |
| `objectstack-ai#14824` | 3/1 | 3 | 0 | 0 | `cf6b67164` |
| `objectstack-ai#14828` | 22/3 (7 test) | 22 | 0 | 3 | `08706f0e0` |
| `objectstack-ai#14829` | 9/3 (6 test) | 9 | 0 | 3 | `ee370d318` |
| `objectstack-ai#14902` | 1/1 | 1 | 0 | 0 | `61821e54c` |
| `objectstack-ai#15040` | 6/3 (3 test) | 6 | 0 | 2 | `8644d1d33` |
| `objectstack-ai#15041` | 6/5 (4 test) | 6 | 0 | 2 | ADR-0104 (2026-09-05 addendum,
landed as 932acc3) |
| `objectstack-ai#15045` | 2/2 (1 test) | 2 | 0 | 0 | `288fe9c34` |
| `objectstack-ai#16887` | 2/1 (2 test) | 2 | 0 | 0 | `9cdffbe36` |
| `objectstack-ai#17080` | 1/1 (1 test) | 1 | 0 | 0 | `8b4890343` |
| `objectstack-ai#17081` | 8/3 (4 test) | 8 | 0 | 3 | `f721ef0ff`, `24d622b94` |
| `objectstack-ai#17883` | 10/3 (5 test) | 10 | 0 | 3 | `b06b2db5c` |
| **total** | **317** | **313** | **4** | **49** | **62 distinct commits
+ ADR-0104** |

Every cited sha matches exactly one object (`git rev-parse
--disambiguate`, count 1 for each of the 62), is a commit, has one
parent, and is an ancestor of the base (`merge-base --is-ancestor`, exit
0 for all 62). The checkout is not shallow (`--is-shallow-repository`
false); the control leg `13a6cb4ad` exits 0 and the negative control
(this branch's own `47241dd80e`, not on `main`) exits 1. ADR-0104's
2026-09-05 addendum landed as `932acc3df`, which passes the same four
checks. Where an earlier stage gave a number an anchor and the cli site
describes the same decision, the same anchor is reused (17 numbers,
objectstack-ai#17081 for its application half only; for example `e2798fab7` for objectstack-ai#6345,
`68f5eccb1` for objectstack-ai#6268, `35ad101bc` for objectstack-ai#10485, `09b4f4e4e` for objectstack-ai#11671
and `61821e54c` for objectstack-ai#14902), so each number carries one anchor across
the tree. Several numbers are the PR number of their own anchor commit
(objectstack-ai#6344, objectstack-ai#10398, objectstack-ai#14667, objectstack-ai#16887), so the sha is the same object the
number named.

**Numbers with more than one anchor, by site:**
- `objectstack-ai#10504` / `objectstack-ai#10952` (`format.ts`): `ff5733e03` added the opt-in zero
row for `UI:` alone and `0d4bd93e7` made it required for every section,
so lines naming both now name both commits. `format.ts:1573` read
「(objectstack-ai#10504, objectstack-ai#10952, objectstack-ai#11172)」 and now reads 「(commits ff5733e, 0d4bd93,
05181e8)」.
- `objectstack-ai#10943` / `objectstack-ai#11157` (`serve.ts`): `46d34ab7c` made the host importer's
fallback base a caller-supplied parameter, and `a4cb7817f` made `serve`
pass its own base and collapsed `importConfigPlugin` from three branches
to two. The slash-joined `serve.ts:1173` 「(objectstack-ai#10943/objectstack-ai#11157)」 now reads
「(commits 46d34ab and a4cb781)」; `serve.ts:1459` 「(objectstack-ai#10908 → objectstack-ai#11157)」
reads 「(commits 9cc6777 → a4cb781)」.
- `objectstack-ai#17081` (8 sites): one card with two halves. `f721ef0ff` took the
platform's half (the `Dev admin` banner line says what the account sees)
for 7 sites; `format.ts:1132` describes the application half and now
reads 「[objectstack-ai#17556 — commit 24d622b]」, the spelling the spec stage used at
`dev-login.zod.ts:10`, with `format.ts:1133` naming objectstack-ai#17081 in words
(「its parent card」).
- `objectstack-ai#15041` (6 sites): the decision is a maintainer ruling recorded
verbatim in ADR-0104's 2026-09-05 addendum, whose Sequencing section
names the three steps the lines cite. So the lines cite the addendum
(「The ruling in ADR-0104's 2026-09-05 addendum decided it」, 「sequencing
step 2 of ADR-0104's 2026-09-05 addendum」), not a commit.

**Wordings to check, each true of its commit:**
- A commit does not rule. Where a line said a number ruled, it now says
what the commit did with the ruling: 「semantics by the ruling commit
68f5ecc landed」, 「Ruled at triage, landed as commit e598b1c」, 「the
triage commit 9cc6777 landed requires this text be CHOSEN」, 「Rulings
objectstack-ai#5728 and objectstack-ai#14412, and the ruling commit d173125 landed,」, and
`resync.ts:96` keeps the ruling's date from `712e185db`'s message:
「commit 712e185 (the 2026-08-15 ruling)」.
- A line that named a DEFECT by its number now says so: 「the defect
commit 79cf692 fixed」, 「the defect commit 9cc6777 fixed」, 「the exact
defect commit 08706f0 closed」, 「the hard-failure class of the `22P02`
commit 8644d1d fixed」, 「Before commit 5359a9b (raw)」 / 「Since commit
5359a9b (masked)」.
- **A stale claim, corrected by its anchor.** `validate.ts:813-815` said
「`ManifestSchema` is not `.strict()` and drops unknown keys with nothing
said (objectstack-ai#14192)」, but `ManifestSchema` has been `strictObject` since
`4d0d9445a`, which landed before the commit that wrote the sentence.
Citing that commit in a present-tense sentence would contradict itself,
so the three lines move to the past tense: 「was not `.strict()` and
dropped unknown keys with nothing said until commit 4d0d944, so acting
on that inference produced a manifest that looked fine」.
- **Anchors found by diff, not by subject.** `objectstack-ai#10326` has no commit
message naming it; `675ab574e` took the 1.7.1 measurement the line
cites, and its own changeset and test name objectstack-ai#10326. `objectstack-ai#12162` is named by
no message either; `c0f5e8f21` is the only commit that ever added the
number, and its message states the point the lines make. `objectstack-ai#14397`'s
citation was added by `957f7bb45`'s own diff, which is the change the
heading describes. `objectstack-ai#10763` is added three times by `c2b97c2a1`'s diff.
- `objectstack-ai#10499` (`init.ts:978`): the line uses the earlier drift between the
two scaffold paths as precedent; that drift was about pnpm build
approvals, and `6d441e41f` gated the two paths against each other, so
the line reads 「already drifted once (closed by commit 6d441e4)」.
- `objectstack-ai#6293`: `c39a911ae` is the commit that found the 「headless husk」
`JSON.stringify(stack)` leaves where a declaration was; `bf4ebe2f3`,
which wrote the cli lines, only cites it.
- Quoted text: `generate-field-type-vocabulary.pin.test.ts:92` sits
inside a verbatim quotation of the file's former clause, so the commit
stands in an editorial bracket (「([commit ee370d3]'s pin argues this
in full)」), as PR objectstack-ai#20624 did.
- Headings with a dash rule (`serve.ts:1125`, `:1459`, `:1521`, `:3276`,
`serve-cluster-host-resolution.test.ts:831`,
`generate-field-type-vocabulary.pin.test.ts:260`,
`files-to-references.ts:274`) trim their trailing dashes to hold the
width; `serve-cluster-host-resolution.test.ts:893` is a trailing comment
whose code part is byte-identical.

## The sites left

**No deciding commit (4 sites, all visible to the census):**
- `init.ts:267` (objectstack-ai#11048): 「whether to admit that band at all is objectstack-ai#11048」
names an open support decision (pnpm 10.0 to 10.4). The only commit
naming it, `568de194e`, files it unassigned; no later commit decides it,
and the floor is still `>=10.15` at the base.
- `plugin/publish.ts:118`, `osplugin.ts:21`, `osplugin.ts:49` (objectstack-ai#11331):
the parenthetical points at the unpack-time integrity re-verification
leg, which was never built (`b60f48b52`: 「The enforce leg points at
objectstack-ai#11331」). No commit decides it; the ownership clause on the same lines
comes from `f89812e4d`, but the number is not about that clause.

**String sites kept as tokens (49).** 48 are test titles and test-code
strings in 22 files. One is a non-test string: the `os meta resync` skip
explanation at `commands/meta/resync.ts:71`, 「on installs from before
objectstack-ai#8692, the platform's own seeded defaults carry that same stamp」, which
an operator reads (see Acceptance notes).

## Mechanical guard: no code token moves, and exactly two string
literals do

The check compares the TypeScript parser's leaf tokens (TypeScript
6.0.3, JSDoc nodes excluded, comments being trivia) of each touched file
at base `04b202e5cb` against the working tree, over all 65 touched files
in `packages/cli/src`, and lists EVERY differing token, not only the
first. Controls mutate the head text in memory only, so nothing on disk
moved for them.

- Real run: 156,633 base tokens, token counts equal in every file,
**exactly 2 differing tokens**, both `StringLiteral`:
`commands/i18n/extract.ts:227` (the help text) and
`utils/i18n-extract.ts:2294` (the header line). No other token in any
file differs.
- Comment-insertion control (`serve.ts`): still exactly those 2 (exit 1,
no new difference).
- Code-insertion positive control (a declaration in `serve.ts`): the
count differs (17,815 to 17,820) and a third difference appears at token
0.
- String positive control (one character added inside the first string
literal past offset 2000 of `serve.ts`): a third difference appears, a
`StringLiteral` at token 145.
- The 27 generated companions: 2,940 base tokens, 0 differing tokens
(their only change is a JSDoc line).

Line balance: every touched file is +N/−N, and every line count is equal
at base and head (94 files). A raw scan of the 92 changed source and
generated files for control bytes finds none (its positive control, a
scratch file holding a U+0001 byte, matches).

## Changeset

This change ships bytes, so a `patch` changeset for `@objectstack/cli`
is included, in PR objectstack-ai#20632's form and level. Unlike stage 2's, it names
the two strings, because 「Comments only」 would not be true here.

Measured on the built package: `files[]` is `dist`, `README.md` and
`CHANGELOG.md`. After the build, the rewritten docblocks reach `dist`:
142 `commit SHA` citations in 39 of its `.js` / `.d.ts` files. For
example `storage-driver.ts:91`'s rewritten line 「(commit 68f5ecc).
These are the」 is in both `dist/utils/storage-driver.d.ts` and `.js`,
beside the unchanged next line of the same docblock 「runtime's
declarations, not copies of them — in particular」 (the positive
control); a negative control phrase appears nowhere. The new help text
is in `dist/commands/i18n/extract.js`, the header literal with
`09b4f4e4e` is in `dist/utils/i18n-extract.js`, and `objectstack-ai#11671` appears
nowhere in the package's `dist`.

## Gates (head `6bb4d3b531`)

This host has no `flock`, so `os-verify-lock.sh` ran in its declared
unlocked mode. Its disclosure, verbatim, from each locked run at this
head:

```text
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 110s (1m50s) · declare it in the PR body · pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=4
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 12s · declare it in the PR body · pnpm --filter @objectstack/cli typecheck
os-verify-lock: VERDICT command-exit 1 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 150s (2m30s) · declare it in the PR body · pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 5s · declare it in the PR body · pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2 test/published-subpath-console.pin.test.ts test/published-subpath-hook-body.pin.test.ts
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 9s · declare it in the PR body · pnpm --filter @objectstack/cli exec vitest run --project integration --maxWorkers=2 src/commands/generate-declared-column-default.pin.test.ts src/commands/generate-string-family-width.pin.test.ts src/commands/meta/delete-reset-carriers.test.ts 
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 30s · declare it in the PR body · pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 src/commands/validate-json-strict-exit.e2e.test.ts
```

The regeneration ran earlier against the same unlocked lock, on a
closure build at `47241dd80e`:

```text
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 102s (1m42s) · declare it in the PR body · pnpm --workspace-concurrency=2 --filter '@objectstack/cli...' --filter '@objectstack/platform-objects...' --filter '@objectstack/plugin-approvals...' --filter '@objectstack/plugin-audit...' --filter '@objectstack/plugin-security...' --filter '@objectstack/plugin-sharing...' --filter '@objectstack/plugin-webhooks...' --filter '@objectstack/service-messaging...' --filter '@objectstack/service-realtime...' --filter '@objectstack/service-storage...' build
```

The branch merged `origin/main` twice, as the dispatch orders
(`d1e09a7eed` merging `cd901d7a5f`, and `6bb4d3b531` merging
`0cb72cfc72`); neither touched `packages/cli`, a translations directory
or the prose-id ledger. After each merge: `pnpm install
--frozen-lockfile`, then the whole workspace (`turbo run build
--filter='./packages/*' --filter='./packages/*/*'`, 71 tasks, 71
successful).

- **Tests** (unit tier, then every touched file outside it):
- `vitest run --project unit`: 234 files, 3,342 tests; **232 files and
3,337 tests pass, 5 tests in 2 untouched files fail on this host**:
`test/published-subpath-console.pin.test.ts` and
`test/published-subpath-hook-body.pin.test.ts` compare a path under
`os.tmpdir()` with the realpath the resolver answers, and on macOS
`/var` is a symlink to `/private/var`. With `TMPDIR` set to its realpath
the same two files pass, 29 of 29 (the fourth line above). Neither file
is in this diff; the cli change is comments and two strings.
- The unit tier holds 32 of the 36 touched test files. The other four
ran by name: the three integration-tier files (`--project integration`:
3 files, 60 tests pass) and the nightly-tier
`validate-json-strict-exit.e2e.test.ts` (`OS_TEST_TIERS=nightly`: 1
file, 7 tests pass). So every touched test file ran.
- The producer's own tests and the companions' readers:
`test/i18n-extract-source-hashes.test.ts`,
`test/i18n-extract-companion-orphan.test.ts` and
`test/i18n-extract-generated-apps-leaf-provenance.test.ts` (3 files, 25
tests); `@objectstack/platform-objects`'s `src/apps/translations` (24
files, 430 tests); `@objectstack/plugin-sharing`'s `src/translations` (3
files, 13 tests). All pass, at `d71ab0e27e`, whose `packages/cli` and
companions are byte-identical to this head.
- **Typecheck:** `pnpm --filter @objectstack/cli typecheck` exits 0.
`tsc --listFiles` counts 298 `src` files under `tsconfig.json`, all 158
`src` test files among them, so every touched test file is type-checked;
`check:test-typecheck` holds its ledger (3 files, 28 errors, 6 pinned
signatures).
- **Lint:** the repo-wide `pnpm lint` (`eslint . --no-inline-config`)
exits 0 at `6bb4d3b531` (2026-09-29T13:44:40Z to 13:45:11Z). Not
narrowed.
- **Citation judging:** `node scripts/check-issue-citations.mjs --base
origin/main` exits 0 after the second merge: 67 citations judged across
56 files (66 resolve, 1 cross-repo). These are the live numbers that
stay on rewritten lines, 54 of them the objectstack-ai#12069 and objectstack-ai#8765 pair in the 27
headers. It defers `*.test.ts`, so the added-minus-removed count over
the whole diff covers the rest: 0 numbers added.
- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` at `6bb4d3b531` derived 76
families (68 before the prose-id ledger commit put a `scripts/` path in
the change set). All 76 ran, and `--ran` over a record carrying each
exit code reads 「76 derived, 76 run, 0 NOT-MEASURED, 0 UNRUN」 (a derived
zero).
- 75 exit 0. One exits 1 for this host, not for this diff: `pnpm
check:bash32-floor` runs its self-test first, and 7 of its 179 cases
assert that each bash-4 probe is shell THIS host can parse; the only
bash here is `/bin/bash` 3.2.57, which cannot. The gate's real-tree
half, run alone (`node scripts/check-bash32-floor.mjs`), exits 0: 32
tracked shell files, 0 findings. The self-test half is NOT MEASURED
here, reason: no bash 4+ on this host; CI's bash measures it. This diff
touches no shell file.
- Among them: `check:doc-authoring` (809 pinned sibling prose-id sites,
no growth, no unrecorded burn-down), `check:i18n` (9 packages in sync),
`check:i18n-coverage` (13 configs, none new), `check:i18n-stale-fill`
(27 of 27 companions served, 0 stale), `check:i18n-walk-parity`,
`check:nul-bytes` (9,283 files, no raw control bytes),
`check:published-files`, `check:cli-command-ids` and
`check:issue-citations` (self-test).
- **Artifact rosters:** 35 of the 38 non-self-test roster rows exit 0 at
`6bb4d3b531`, `check-changeset-fixed` (its roster sits under
`.changeset/`), `check:error-code-casing`, `check:filter-alias-parity`
and `check:authz-resolver` (the four whose rosters share a directory
with this diff) among them. The other three,
`check-closing-target-claim`, `check-partof-closing-keyword` and
`check-single-claim-paths`, need a pull request's context; they are run
against this PR once it exists and reported on the card. The 17
checker-health-only rows were not run.

## Hypotheses (measured first)

- **H0 holds.** The filtered census answers 174 dead sites at
`04b202e5cb` (167 lines, 30 files, 50 numbers), equal to the card's
count at `f11b5f20a2`: no drift.
- **H1 holds, with the listed exceptions.** After the rewrite the
filtered census answers 4, all for an unfound anchor: `objectstack-ai#11048` (an open
support decision) and `objectstack-ai#11331` three times (an enforce leg never built).
No site is held for an open PR: the two held files carry no dead
citation. The claim's read and this stage's two reads of the open PRs'
file lists (12:38:15Z, 7 open PRs; 13:58:56Z, 9 open PRs) found only PR
objectstack-ai#20589 in `packages/cli/src` and none touching a companion or the
prose-id ledger. PR objectstack-ai#20652, opened after the claim, edits
`packages/platform-objects`'s three `LOCALE.objects.generated.ts`
bundles beside the companions: no file overlap.
- **H2 holds, by the token guard.** A comment-stripped comparison of
every touched file (the parser's leaf tokens, JSDoc excluded, every
difference listed) finds exactly the two declared `StringLiteral` tokens
and nothing else, and its code and string controls each add a
difference. The emitted `dist` is not byte-identical, because docblocks
and the two strings ship, which is why the changeset is `patch`.
- **H3 holds.** `git grep -n "objectstack-ai#11671" --
'*.source-hashes.generated.ts'`: 0 hits at head, 27 at `04b202e5cb`.

## Acceptance notes

- **Form D, not touched here.** 49 dead numbers stand inside string
literals: 48 in test titles and test-code strings (22 files, 21
numbers), and one an operator reads: the `os meta resync` skip
explanation at `commands/meta/resync.ts:71`, 「on installs from before
objectstack-ai#8692, the platform's own seeded defaults carry that same stamp」. The
comments beside it (`resync.ts:55` and `:96`) now cite `712e185db`.
Ruling D (no number, the lesson in words) is a string change outside
this stage's two declared strings; the card already carries a form-D
stage for the lane (ACCEPT 5888034755), and this string is its
author-shown first.
- **`objectstack-ai#11671` outside this stage's surface.** The number still stands in
other lanes' files: the nine `scripts/i18n-extract.config.ts` docstrings
(outside the census surface), six `src/translations/index.ts` files
(`plugin-approvals`, `plugin-audit`, `plugin-security`,
`plugin-webhooks`, `service-realtime`, `service-storage`), six sites in
`packages/platform-objects/src` (`source-hash.ts` three times,
`setup.translation.ts`, `metadata-translations/index.ts`,
`source-hash.test.ts`),
`packages/cli/test/i18n-extract-source-hashes.test.ts:3`, and 13 in
`scripts/**` and `.github/workflows/lint.yml`. The anchor for all of
them is `09b4f4e4e`. Noted for those lanes' stages, not touched.
- **Outside the scope and the census surface.** `packages/cli` outside
`src/**` holds 242 dead citations: `test/` 185, `scripts/` 27, `bin/`
10, `vitest.config.ts` 15, `vitest-tiers.ts` 2,
`vitest-tiers.fixtures.ts`, `tsconfig.test.json` and
`test-typecheck-debt.json` 1 each (whole-file projection, the before
board). They stay for a later stage of this card.
- **A host-dependent pin.** `test/published-subpath-console.pin.test.ts`
and `test/published-subpath-hook-body.pin.test.ts` fail 5 tests on
macOS, where `os.tmpdir()` is a symlink, and pass with a realpath
`TMPDIR`. CI's Linux runners do not see it. Noted, not filed.
- **A hex colour in the whole-file reading.** `serve.ts:5621` holds the
CSS colour `#141417` in a string. The census blanks strings, so it never
sees it; the supplementary whole-file projection reads it as a citation
beyond the frontier (`never-issued`). It is not a citation; it is the
second of the two `src string` sites left in the supplementary table,
beside `objectstack-ai#8692`.
- **The slash-joined grammar gap, again.** `CITATION_RE` refuses a `#`
preceded by `/`, so the second number of `#A/#B` is never judged. In
`packages/cli/src` one such dead number stood (`serve.ts:1173`,
rewritten here). The same shape PR objectstack-ai#20624 and PR objectstack-ai#20632 reported, for
the grammar family PR objectstack-ai#20533 names.

## Deviations

- **One file outside the claim's surface.**
`scripts/doc-authoring-prose-id.baseline.json`, the shrink-only ledger
of `check:doc-authoring`'s sibling-package prose-id leg, pinned the two
`objectstack-ai#11671` string sites this stage removes, so the gate went red (「the
prose-id baseline is STALE — pinned entries exceed the tree」) and
prescribed regenerating it in the same PR. It was regenerated with its
own command (`node scripts/check-doc-authoring.mjs --census-ledger`,
which refuses to grow the ledger): 4 lines removed, the two `objectstack-ai#11671`
pairs and nothing else. The claim's file surface did not name this file;
it is the gate's own remedy for the two strings the claim does name.
- One site beyond the census's read grammar (the slash-joined `objectstack-ai#11157`)
is rewritten, and thirteen more lines are the other half of a rewritten
sentence (listed under What changed).
- `validate.ts:813-815` moved to the past tense, because the claim they
carried was false before this change (see Wordings to check).
- Anchor research for 64 of the 65 numbers ran in four read-only
research subagents; every proposal was checked here against the commit's
message or diff and every changed line was reviewed, and eight were
reworded by hand (the four lines two subagents shared, 「that commit's
to」, the kept PR link, and two companions).
- Commit trailers are AGENTS.md's model-free pair (`Claude-Session` plus
`Co-authored-by: Claude`), and the pre-push trailer check passed on
every push; the harness's attribution reminder asked for a model-named
trailer, which AGENTS.md overrides. The two merge commits carry git's
default message.

---
_Generated by [Claude
Code](https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289)_

---------

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

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants