Skip to content

fix(cli): os migrate recorded-by, resume, account-issuer and apply rethrow oclif's exit signal, so a completed --json run prints one document and exits 0 - #21495

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-21434-json-exit-signal
Oct 3, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-21434-json-exit-signal

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21434

Clause-②: no

What was wrong

this.exit(n) throws oclif's exit signal (code: 'EEXIT', oclif.exit: n). When it runs inside a try, the try's own catch sees the signal first. Four migrate commands had a catch that reported whatever it caught, so the signal came back out as an error.

Measured at the public door on base aa4632235 (CLI run from source through bin/run-dev.js, a sqlite file with one sys_metadata_history row whose recorded_by is system):

command before (aa4632235) after (34a0cd121e)
os migrate recorded-by --apply --yes --json row converted; result document, then {"error":"EEXIT: 0","duration":1020}; exit 1 row converted; one document (JSON.parse of the whole stdout succeeds); exit 0
os migrate resume --run RUN_ID --json (run already concluded) refusal-shaped document, then {"error":"EEXIT: 0",…}; exit 1 one document; exit 0
os migrate resume --run RUN_ID --yes --json (journal row run_done removed) "no loaded package registers" document, then {"error":"EEXIT: 1",…}; exit 1 one document; exit 1

The merge of main after 34a0cd121e touched none of the four command files.

The fix

The existing idiom, if (isExitSignal(error)) throw error; (packages/cli/src/utils/format.ts), as the first statement of the swallowing catch in:

  • migrate/recorded-by.ts. 5 this.exit sites in the try, the completed-apply this.exit(0) among them.
  • migrate/resume.ts. 8 sites: resumed run, already-concluded run, unknown run id, plan not loaded, confirmation required, failed run.
  • migrate/account-issuer.ts. 2 sites: the refused pre-flight on the JSON face (second document {"error":"EEXIT: 1"}) and on the text face (extra EEXIT: 1 line).
  • migrate/apply.ts. 2 sites, text face only: the sys_account.issuer pre-flight refusals. Its JSON face returns before them.

There is no second helper and no producer elsewhere. The swallowing happens in each command's own catch, and nothing outside packages/cli/src/commands/migrate/ changes at runtime.

Census: every command that takes a JSON face, at 88ae5769c6

Derived from the oclif declarations. Each module under src/commands (the src/ twin of oclif.commands = pattern, ./dist/commands, **/*.js) is imported, and its default export's static flags is read, inherited flags included. A member declares a boolean json flag (32 commands) or a flag whose options include 'json' (--format json, 14 commands). That makes 46 commands with 105 this.exit-in-try sites.

  • In-family (4, fixed here): migrate recorded-by (5 leaking sites), migrate resume (8), migrate account-issuer (2), migrate apply (2, text face).
  • Already correct (14): cloud whoami (1 site), compile (21), build (inherits compile's 21), environments bind (2), environments create (1), migrate audit-metadata-bodies (2), migrate files-to-references (2), migrate multi-value-columns (4), migrate summary-nulls (2), migrate value-shapes (3), secret orphans (7), secret rewrap (6), validate (15), verify (1).
  • Not affected, no this.exit inside a try (28): cloud login, cloud logout, data create, data delete, data get, data query, data update, diff, environments list, environments show, explain, i18n check, i18n extract, info, lint, login, logout, meta delete, meta get, meta list, meta register, meta resync, migrate, migrate meta, migrate plan, register, storage orphans, whoami.

secret rewrap, which landed in #21469 while this branch was open, is already correct. Its 6 sites sit in the try at rewrap.ts line 195, whose catch (line 310) opens with the rethrow. #21469 added that line, so nothing here edits rewrap.ts. This PR adds no driven case for it: it is not in-family, and the structural half covers its exit path. json-stdout-purity.e2e.test.ts is not touched.

this.error(…) also raises a signal isExitSignal recognises. Measured: no JSON-capable command calls it inside a try. The only this.error calls inside a try are 2 in init.ts, which has no JSON face.

The enumeration pin: packages/cli/test/json-exit-signal.pin.test.ts (unit tier)

  1. Analyzer fixtures (15 cases). The detector is shown to fail on each shape it must catch: no rethrow, a catch with no binding, the rethrow not first, a second helper, isExitSignal imported from somewhere other than utils/format.js, an exit through a same-class helper or an arrow-function property, nested tries, an exit in an inner catch, an exit in a callback. It also passes the idiom, an unconditional rethrow, and try/finally.
  2. Structural, over the whole discovered population (46 cases + 3 meta). Every this.exit inside a try, direct or through a same-class method, must have the isExitSignal rethrow as the first statement of every enclosing catch. The check covers the command's own source and its superclasses' sources. The meta checks are: package.json's oclif command strategy is the one the walk mirrors; every walked module is a command; the population floor (46) and site floor (105); the named anchors; and that os build's chain reaches compile.ts.
  3. Driven, in-family completed paths (13 cases). recorded-by, resume and account-issuer run in-process through oclif with a preloaded Config. bootSchemaStack, the journal runner, the sentinel scan and the collision probe are replaced through vi.mock. Each case asserts one JSON document on stdout (a bare JSON.parse of the whole of it) and the exit status. The cases are recorded-by --apply --yes completed → 0, compensated → 1, failed → 1, and the confirmation refusal → 1. For resume: --run --yes completed → 0, compensated → 0, failed → 1; already concluded → 0; unknown id → 1; confirmation → 1; plan not loaded → 1. For account-issuer: ok → 0, refused → 1.

How a later command enters: there is no roster. A module under src/commands that declares either flag is in the population on the day it lands. secret rewrap is the first to have entered that way, and no line names it. The floors are the only hand-edited numbers. They catch a discovery or analyzer that silently returns zero.

Tier: unit, measured with tierOfFile: signals none. Nothing is spawned and nothing boots. The boot seam is replaced through vi.mock and never value-imported. Cost: about 23 to 26 s of import at collection on a shared box, outside any clocked window. A single-command file in the same package (recorded-by.test.ts) imports in 15.7 s on the same box, so the all-commands discovery adds about 10 s.

Evidence

  • Pin at 88ae5769c6: vitest run --project unit test/json-exit-signal.pin.test.ts gives 77 passed (77), VERDICT command-exit 0.
  • Ablations at 9490dd3d75, before the merge. Both mutations went through scripts/ablation-replace.mjs in a trap-restoring script, and the expected direction was red.
    • recorded-by.ts rethrow replaced. The tool's literal count went 1 → 0 for the anchor and 0 → 1 for the marker, and the blob went e74172d2 → 31085626. Result: 5 failed / 71 passed. The failures are the structural member (5 sites "swallowed by the catch at line 196") and the 4 driven recorded-by cases, which show stdout carried 2 JSON documents … {"error":"EEXIT: 0","duration":1}. Restored: blob == HEAD and git diff HEAD empty.
    • resume.ts rethrow replaced. Anchor 1 → 0, blob 6e2c7de0 → 2cfe0d4c. Result: 8 failed / 68 passed (structural member + all 7 driven resume cases). Restored: blob == HEAD.
    • The hand grep -c of the first marker inside the wrapped command read 0. That marker contains *, which grep reads as a regex, so the hand count is void. The tool's literal before/after counts and blob hashes are the evidence that the mutation landed.
  • Unit tests that reach the changed commands, at 88ae5769c6: the pin, recorded-by.test.ts, multi-value-columns.no-auto-run.test.ts, artifact-boot-migration.test.ts, format.exit-code.test.ts and schema-migration-plugins.test.ts gave 6 files, 134 tests passed, VERDICT command-exit 0.
  • Typecheck at 88ae5769c6: pnpm --filter @objectstack/cli typecheck gave VERDICT command-exit 0. tsc --noEmit passed. check:test-typecheck is OK with its ledger unchanged (3 files / 28 errors / 6 pinned signatures).
  • Gates at 88ae5769c6: all 65 families from node scripts/pm/dispatch-gates.mjs --commands exit 0. --ran reconciliation: 65 derived, 65 run, 0 NOT-MEASURED. That zero is derived, because every line carries its exit code. check:i18n, check:i18n-coverage and check:i18n-walk-parity first answered exit 3 (prerequisite: CLI not built) and are green after building their declared closure. check:dual-build-cjs-loads first answered exit 3 (6 unrelated packages had no dist/) and is green after building them.
  • Lint, as a proven narrowing. ESLint over the 5 changed .ts files with --no-inline-config --format json read 5 files, 0 errors, 0 warnings. The population comes from ESLint itself: isPathIgnored is false for all 5 and calculateConfigForFile resolves rules for each. Invariance: eslint.config.mjs never enables type-aware linting (no parserOptions.project, no typed rules, stated at line 327), so this diff cannot move the verdict on any untouched file. The full pnpm lint is CI's.

NOT MEASURED

  • packages/cli full unit tier, not measured locally. Two attempts at vitest run --project unit were cut off: exit 137, then a container restart mid-run. It is narrowed to the 6 unit files above, which import or reach the four changed commands. CI runs the whole tier.
  • packages/cli integration tier, declared to CI. The diff touches no integration-tier file and no spawn entry. artifact-boot-migration.unbuildable-index.test.ts and sqlite-occupancy.test.ts reach the changed commands and live in that tier.
  • Nightly e2e (json-stdout-purity, migrate-exit-code): not run. They drive only the bare --json forms, which never reached the defect.
  • Public door, os migrate account-issuer --json refusal. It needs a stack that registers sys_account (plugin-auth's object) with colliding rows. The plain project stack does not register it, so the door answers a read refusal before the path is reached. Measured at 88ae5769c6: {"error":"Cannot enumerate sys_account: …"}, one document, exit 1. The driven unit case covers the refusal path.
  • Public door, os migrate resume --run … --yes completed. It is unreachable at the door today; see the first acceptance note. The driven unit case covers it.

Acceptance notes

  • os migrate resume --run RUN_ID --yes cannot resume any run at the public door. MigrationRecoveryPlugin, which owns the migration-plans registry, is exported from @objectstack/runtime but composed nowhere in packages/cli. So every interrupted run answers "belongs to plan …, which no loaded package registers … Load the package that owns this migration". That holds even for metadata.recorded-by-sentinel-to-null, whose owner @objectstack/metadata-protocol the CLI itself loads. recorded-by's in-process plans.register(plan) lands in the no-registry catch for the same reason. Measured above, and reported to the seat as a separate finding.
  • On an already-concluded run, os migrate resume --run RUN_ID --json exits 0 but puts its message under the error key. That is unchanged here, and noted only.
  • The same catch shape sits on three commands with no JSON face: os package install, os package publish and os plugin sign. Measured: os package install ./does-not-exist.json prints ✗ Cannot read artifact: …, then ✗ EEXIT: 1, and exits 1. The exit status is right and the extra line is wrong. These files are outside this claim's declared file surface, which covers JSON-face commands, so they are not touched here. They are reported to the seat as a finding.

Generated by Claude Code

claude added 6 commits October 2, 2026 22:50
…reporting it

`os migrate recorded-by --apply --yes --json` printed its result, then a
second document `{"error":"EEXIT: 0"}`, and exited 1 after a completed run:
the `this.exit(…)` inside the command's `try` throws oclif's exit signal, and
the `catch` reported it as an error. `os migrate resume --run` (resumed or
already-concluded run), `os migrate account-issuer --json` (refusal) and the
text face of `os migrate apply` (account-issuer pre-flight refusal) had the
same catch. Each catch now opens with the existing idiom,
`if (isExitSignal(error)) throw error;`.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
@github-actions github-actions Bot added the size/l label Oct 3, 2026
@github-actions

github-actions Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 7 documentable anchor(s).

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

  • content/docs/ai/skills-reference.mdx (via os migrate apply (command, read off packages/cli/src/commands/migrate/apply.ts))
  • content/docs/data-modeling/drivers.mdx (via os migrate apply (command, read off packages/cli/src/commands/migrate/apply.ts))
  • content/docs/deployment/cli.mdx (via os migrate apply (command, read off packages/cli/src/commands/migrate/apply.ts))
  • content/docs/deployment/index.mdx (via os migrate apply (command, read off packages/cli/src/commands/migrate/apply.ts))
  • content/docs/deployment/self-hosting.mdx (via os migrate apply (command, read off packages/cli/src/commands/migrate/apply.ts))
  • content/docs/kernel/services-checklist.mdx (via os migrate apply (command, read off packages/cli/src/commands/migrate/apply.ts))
  • content/docs/protocol/kernel/lifecycle.mdx (via os migrate apply (command, read off packages/cli/src/commands/migrate/apply.ts))
  • content/docs/upgrading.mdx (via os migrate apply (command, read off packages/cli/src/commands/migrate/apply.ts))

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

  • content/docs/releases/v17/17-3.mdx (via os migrate apply (command, read off packages/cli/src/commands/migrate/apply.ts))
  • content/docs/releases/v17/17-5.mdx (via os migrate account-issuer (command, read off packages/cli/src/commands/migrate/account-issuer.ts), os migrate apply (command, read off packages/cli/src/commands/migrate/apply.ts))
  • content/docs/releases/v17/17-6.mdx (via os migrate apply (command, read off packages/cli/src/commands/migrate/apply.ts))

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

Coarse fallback — 27 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 9ff74285f14b7b60699546211e53e9ccc13c0d61 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 9ff74285f14b7b60699546211e53e9ccc13c0d61

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 3, 2026
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 3, 2026 00:47
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 3, 2026 00:48
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 3, 2026
Merged via the queue into main with commit 2ee8383 Oct 3, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21434-json-exit-signal branch October 3, 2026 01:14
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…DRs that decided them (stage 8 of objectstack-ai#20595) (objectstack-ai#21506)

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

## What changed

Stage 8 of the `domain:engine` lane of the dead-citation sweep:
`packages/core/**`, comment and docblock prose only, per the claim
(`5963404083`). Stages 1 to 7 landed as `a7d9768ec`, `d150c3039`,
`4bf4e7e70`, `13a24ece2`, `db0cf2231`, `85986144c` and `48fa7a381`.
objectstack-ai#20595 stays open: the other half of this lane is the packages this
stage does not touch (`metadata-core` 19, `drivers/driver-turso` 14,
`drivers/driver-mongodb` 9, `formula` 4, `metadata-fs` 2 on the census
after this stage, 48 in all), plus the test-string sites the card
carries for a widened stage.

Every comment or docblock site in the package that cited a tracker
number answering 404, and the one comment-id citation that answers 404,
is rewritten in ruling C+D's form C (record `5749154545` on objectstack-ai#19123), in
the form objectstack-ai#20234 applies it to the spec tree: the ADR when one records
the decision, otherwise the commit in this repository's history that
made it, otherwise the fact in words. That is **81 sites on 80 lines in
34 files, covering 24 numbers and 1 comment id**:

- **40 census sites** (39 lines, 17 files under `src/`): the whole
`allocated-but-absent` population of the gate's own census in this
package at the base. `plugin-permission-enforcer.ts:119` carries two
numbers on one line;
- **6 sites outside the census glob, inside the claimed surface**:
`vitest.config.ts:21` (objectstack-ai#17853, a `//` line), and five `//` lines in
three JSON-with-comments files, `tsconfig.examples.json:1`, `:2` and
`:22` (objectstack-ai#14613, objectstack-ai#14386 twice), `tsconfig.json:30` and
`tsconfig.test.json:1` (objectstack-ai#14613). These follow stage 1's
`tsup.config.ts`, stage 5's `tsconfig.typecheck.json` and stage 7's
`tsconfig.scripts.json` precedents;
- **34 test-comment sites** (34 lines, 13 test files), which the census
defers. They carry 11 numbers: 6 the census itself reads as dead in this
package's `src` (objectstack-ai#6216, objectstack-ai#8734, objectstack-ai#13279, objectstack-ai#14919, objectstack-ai#16721, objectstack-ai#17147), 1 it
reads as dead elsewhere (objectstack-ai#17590, at
`packages/spec/src/data/filter.zod.ts:1067`), and 4 it never judges on
this tree because they stand only in test files or outside the census
glob (objectstack-ai#10978, objectstack-ai#11330, objectstack-ai#13179, objectstack-ai#14613), which the board and a single read
each settle;
- **1 dead comment-id citation**:
`granted-permissions-not-enforced.pin.test.ts:9` named maintainer ruling
`5486840233`, a comment that answers 404 (see Census).

**Anchors: 22 numbers by commit, 1 by ADR, 1 by words, and the comment
id by ADR; 24 distinct shas.** Two numbers are split by subject:
`objectstack-ai#14386` (`7cbe705b0` for the plugin-auth program's widening,
`c49007a7c` for the finding the sentence describes, see Wordings) and
`objectstack-ai#17147` (`aaacf1d5c` for the pin and the seam, `65481183b` for the
pin's follow-up). `objectstack-ai#11333` and `objectstack-ai#17147` share one line. 19 numbers reuse
the anchor another lane or stage already used for them; measured here
are 5 commits (`fd289be45` for objectstack-ai#13179, `cc00df2f7` for objectstack-ai#14919,
`7cbe705b0` and `c49007a7c` for objectstack-ai#14386, `65481183b` for objectstack-ai#17147's
follow-up half) and 2 ADR anchors (ADR-0131's 2026-09-17 amendment for
objectstack-ai#16682, ADR-0025 §3.7 for the comment id). The plugin-security lane's
`objectstack-ai#16682` anchor (`9b9581b11`) was not taken: it is a different subject
(see the table). `objectstack-ai#11331` takes words, as the spec lane gave it (see
Wordings).

Only comments changed. Every file keeps its line count (81 lines out, 81
in, plus the changeset), so no line citation into any of them moves. One
changed line carries no number (`plugin-artifact-integrity.ts:12`, see
Wordings). No code token moves (the guard below). **No citation number
is added**: on every changed line the numbers on the new text are a
subset of those on the old (the only numbers on `+` lines are objectstack-ai#3984,
objectstack-ai#5286, objectstack-ai#5881, objectstack-ai#6551, objectstack-ai#10869, objectstack-ai#11974, objectstack-ai#16404 twice and objectstack-ai#17978, each
already on its line and each answering 200).

**A `patch` changeset**: 21 of the 40 rewritten non-test lines are in
the published `dist` (the `.d.ts` keeps JSDoc on exported members, and
esbuild keeps some comments in the JS), and `dist` is not byte-identical
with the base text (see Changeset).

## H0: the package and its size

The gate's own `node scripts/check-issue-citations.mjs --census --json`
at base `fd96a8473` (the before run below), `allocated-but-absent` per
remaining `domain:engine` package:

| package | before | after this stage |
|---|---|---|
| `core` | **40** | **0** |
| `metadata-core` | 19 | 19 |
| `drivers/driver-turso` | 14 | 14 |
| `drivers/driver-mongodb` | 9 | 9 |
| `formula` | 4 | 4 |
| `metadata-fs` | 2 | 2 |
| `metadata-protocol`, `objectql`, `metadata`, `drivers/driver-sql`,
`drivers/driver-memory`, `drivers/driver-sqlite-wasm`,
`plugins/plugin-pinyin-search`, `platform-objects` | 0 each | 0 each |

The lane total goes 88 to 48. `core` is the largest remaining package
and reads 40, as at stage 7's census (`e5d9a5d85`), so the stage went
ahead.

## Census: `core`, before and after

**Instrument (A1).** The gate's own `node
scripts/check-issue-citations.mjs --census --json`, read-only and
unchanged. The count is its `allocated-but-absent` findings under
`packages/core/`.

| reading | tree | board | whole-repo `allocated-but-absent` | sites |
lines | files | numbers |
|---|---|---|---|---|---|---|---|
| before | base `fd96a8473`, run 00:07:44Z to 00:11:10Z | enumerated,
194 pages, frontier objectstack-ai#21494, 19,315 records (newest number read before
and after the run: objectstack-ai#21494) | 203 | **40** | 39 | 17 | 17 |
| after | `195aa6bdb`, run 00:27:00Z to 00:30:25Z | enumerated, 194
pages, frontier objectstack-ai#21498, 19,319 records (newest objectstack-ai#21495 before, objectstack-ai#21498
after) | 163 | **0** | 0 | 0 | 0 |

The whole-repo drop is 40, and the two finding sets differ by exactly
the 40 rows of this package, removed; none was added. `resolves`
(35,428), `resolves-as-pull-request` (2,388) and `cross-repo-unjudged`
(1,243) did not move.

The head's later commits are the changeset, one merge of `main`, and one
comment line in `resolve-authz-context.ts` (the ADR re-anchor of `:925`,
which adds and removes no number). The census was run a third time at
the head `a4c483901` (00:50:57Z to 00:54:06Z, 194 pages, frontier
objectstack-ai#21504, 19,325 records, newest objectstack-ai#21504 before and after): whole-repo 163,
`core` 0, and its `allocated-but-absent` finding set is identical to the
after run's (0 removed, 0 added). Its `resolves` reads 35,441, 13 more
than above, from the merged `main` commits outside this package.

**Supplementary instrument, the whole package.** The census reads
neither test files nor strings nor files outside `src`. A second reading
runs the gate's own exported `extractCitations` (whole-file and
comment-prose projections) over every tracked file in the package (175)
and classifies each citation with the gate's `classifyCitation` against
one board enumerated by the gate's `enumerateBoard` (194 pages, frontier
objectstack-ai#21494, 19,315 records, 00:11:24Z to 00:14:37Z), the same board for both
readings. Every one of the 24 numbers in the population was then read on
its own over the issues endpoint (00:19:57Z): **all 24 answer 404**; the
lit controls `objectstack-ai#5286` and `objectstack-ai#12624` answer 200, and so do the numbers that
stay on changed lines (objectstack-ai#3984, objectstack-ai#5881, objectstack-ai#6551, objectstack-ai#10869, objectstack-ai#11974, objectstack-ai#16404,
objectstack-ai#17978).

| reading | citations | dead | src comment | test comment |
`vitest.config.ts` comment | other files (`tsconfig*.json` / rest) |
test string | changelog |
|---|---|---|---|---|---|---|---|---|
| before, `fd96a8473` | 2,048 | **125** | 40 | 34 | 1 | 5 / 2 | 22 | 21
|
| after, `195aa6bdb` | 1,968 | **45** | 0 | 0 | 0 | 0 / 2 | 22 | 21 |

The citation count drops by exactly the 80 rewritten tracker-number
sites (the 81st site is the comment id, which the citation grammar does
not read). The live counts did not move (src comment: 755 resolve, 19 as
pull requests, 3 cross-repo; test comment: 382, 12 and 4). A third, raw
reading (every `#` followed by 2 to 6 digits, whatever surrounds it)
counts 2,069 before and 1,989 after: also a drop of 80. The two `other
files` rows left are a JSON string and a markdown line (see Sites left).

**Comment ids.** Every ten-digit run under `packages/core` (its
`CHANGELOG.md` aside) was read. Three distinct comment ids are cited, on
four lines: `5257880748` (`auth-gate.test.ts:195`, inside a verbatim
quotation of a ruling) and `5394453215` (`platform-admin.ts:5`,
`resolve-authz-context.ts:1076`) answer 200; `5486840233`
(`granted-permissions-not-enforced.pin.test.ts:9`) answers 404 and is in
this stage's population; the control `5963404083` (the claim) answers
200. The other runs are decimals, epochs and fixtures in tests, and two
CI run ids in `kernel.ts:33` and `:34`, which are not citations of this
tracker. An `issuecomment` / `discussion_r` grep finds no line (exit 1).
After the rewrite, `5486840233` stands in 0 files under `packages/core`.

## Per-number table

`census` counts census sites, `outside` the six sites outside the census
glob, `test` the test-comment sites. Every sha matches exactly one
commit (`git rev-parse --disambiguate`, count 1) and is an ancestor of
the base `fd96a8473` (`git merge-base --is-ancestor`, exit 0 for all 24;
the clone is not shallow). The `+` lines carry exactly these 24 nine-hex
spans as new ones (the one other span on a `+` line, `abc4b83ce`, was
already on its line). Each commit names the number it replaces, in its
message, its diff or both, except `c49007a7c`, whose message names
`objectstack-ai#10869` (see Wordings); each ADR anchor names its number or id in the
cited section. `git blame` at the base puts 52 of the 76 commit-anchored
lines on their anchor; the other 24 were written by a commit that cites
the number as an earlier decision (for example `07150b33a` citing
objectstack-ai#16721's convergence, `baf974527` citing objectstack-ai#16649's registration,
`82da264e1` citing objectstack-ai#6216's closed field set), and in each case the
anchor is the commit that made the change the sentence credits to the
number. `source` says whether another lane or stage already used this
anchor for this number (`reused`) or it was measured here (`measured`).

| number | census | outside | test | anchor | kind | source | what it
decided |
|---|---|---|---|---|---|---|---|
| `objectstack-ai#6206` | 2 | 0 | 0 | `8e13ca876` | commit | reused (the rest,
plugin-security and plugin-sharing lanes) | share-link enforcement takes
the whole authz envelope, so `accessible_org_ids` is no longer dropped |
| `objectstack-ai#6216` | 8 | 0 | 8 | `f586f1a89` | commit | reused (the rest,
runtime, mcp, spec, plugin-hono-server and plugin-security lanes) | one
ExecutionContext assembler with two named anonymous entries; the frozen
parity pins and the closed entry field set. Its message does not record
the 2026-08-08 ruling, so the sites that name the ruling keep its date |
| `objectstack-ai#6241` | 1 | 0 | 0 | `83a3b1f2e` | commit | reused (the rest lane) |
normalize the `:type` segment once per handler, closing the third
plural-spelling bypass of the audience gate |
| `objectstack-ai#6725` | 1 | 0 | 0 | `1507ba356` | commit | reused (stage 3; the spec
lane) | `MetadataFacade` object writes reach the map its reads use |
| `objectstack-ai#8734` | 2 | 0 | 1 | `f8eb73601` | commit | reused (the plugin-auth
lane) | bind the last-admin standing-key lists to the authz resolver's
measured read surface |
| `objectstack-ai#10978` | 0 | 0 | 5 | `4c9780c7a` | commit | reused (stage 2; the
runtime lane) | authorization ObjectQL doubles enforce `limit`, by
presence |
| `objectstack-ai#11330` | 0 | 0 | 2 | `a9ee98992` | commit | reused (the spec lane) |
the `manifest.runtime` trust-tier text states publish-gate-only
enforcement truthfully: the tier half of the same sentence |
| `objectstack-ai#11331` | 2 | 0 | 0 | none | words | the spec lane's form
(`21ab410417`: 「The enforce leg is unbuilt.」) | it tracked the
unpack-time integrity re-verification leg, which was never built; no
commit or ADR decides it |
| `objectstack-ai#11333` | 1 | 0 | 0 | `ea4d16420` | commit | reused (the runtime
lane) | option A phase 1 of that card, binding an artifact's granted
permissions at load (its diff says so); the site names the phase after
it |
| `objectstack-ai#13179` | 0 | 0 | 2 | `fd289be45` | commit | measured | strip the
tracker ids from `HotReloadManager`'s author-facing refusal messages and
re-pin the twins (the 2026-08-29 family adjudication's member half); it
wrote both test lines |
| `objectstack-ai#13279` | 7 | 0 | 2 | `6a180e42d` | commit | reused (stages 4 and 6;
the service-settings, plugin-hono-server and cloud-connection lanes) |
fail loud when a permission-store read fails; its message records the
2026-08-30 ruling verbatim |
| `objectstack-ai#13324` | 1 | 0 | 0 | `4cda78c9b` | commit | reused (stages 1, 4 and
6; the types lane) | a missing-table error must name the table that was
read (`readObject`) |
| `objectstack-ai#13644` | 1 | 0 | 0 | `34ce8e7db` | commit | reused (stage 3) |
declare `HookContext.referentialFieldClear` and populate it on every
set-null cleanup write |
| `objectstack-ai#14192` | 1 | 0 | 0 | `4d0d9445a` | commit | reused (the cli and spec
lanes, for the same 「strictObject since」 sentence) | `ManifestSchema`
goes strict |
| `objectstack-ai#14386` | 0 | 1 | 0 | `7cbe705b0` | commit | measured | put three
more package-root plugin manifests inside a tsc program; it widened
plugin-auth's `tsconfig.examples.json` |
| `objectstack-ai#14386` | 0 | 1 | 0 | `c49007a7c` | commit | measured | declare
`plugin-hono-server` and put plugin-auth's published example in a tsc
program: the example that 「could not resolve, compile or run for anyone
who copied it」 |
| `objectstack-ai#14613` | 0 | 3 | 2 | `81208086a` | commit | reused (the rest lane) |
`@objectstack/core` declares a `typecheck` script; the test and examples
layers enter the ratchet. It wrote all three `tsconfig` lines |
| `objectstack-ai#14919` | 1 | 0 | 1 | `cc00df2f7` | commit | measured | retire
`PluginSecurityScanner` under ADR-0049; its message records the
2026-09-05 ruling |
| `objectstack-ai#16649` | 2 | 0 | 0 | `613bfbd3d` | commit | reused (stage 4; the
runtime and spec lanes) | register the fourteen remaining `door: 'none'`
codes in `ERROR_CODE_LEDGER`, `PLUGIN_CONTRACT_VIOLATION` and
`SERVICE_NOT_REGISTERED` among them |
| `objectstack-ai#16682` | 1 | 0 | 0 | ADR-0131's 2026-09-17 amendment | ADR |
measured (the plugin-security lane's `9b9581b11` is the `single`-posture
selection repair, a different subject) | the amendment's 「What the
ruling did NOT decide」 section quotes the 2026-09-08 ruling verbatim and
untranslated, the sentence this site quotes (「retiring the walled write
must not retire the `single` one」). `74832b68f`, which wrote the line,
quotes it too; the ADR comes first |
| `objectstack-ai#16721` | 7 | 0 | 8 | `51ae73123` | commit | reused (the cli and
plugin-hono-server lanes) | `LiteKernel.use()` enforces the declared
plugin contract, converged with `ObjectKernel` (step 2). Its message
does not record the 2026-09-08 ruling, so the sites that name it keep
its date; step 1 was a measurement with no commit of its own |
| `objectstack-ai#17124` | 1 | 0 | 0 | `86c505286` | commit | reused (the
service-analytics lane) | a `dateRange` array that is not a two-bound
window is refused once, instead of meaning three different things on
four faces |
| `objectstack-ai#17147` | 1 | 0 | 1 | `aaacf1d5c` | commit | reused (the spec and
runtime lanes) | the install-time granted permission set is registered
at load and refuses nothing: say so, and pin the measurement. It created
the pin file |
| `objectstack-ai#17147` | 0 | 0 | 1 | `65481183b` | commit | measured | the pin's
follow-up: sweep the retracted phrasing repo-wide instead of reading one
file. It wrote the line |
| `objectstack-ai#17590` | 0 | 0 | 1 | `e04a0aff2` | commit | reused (stages 4 and 5;
this package's own `json-membership-sql.ts`) | compile `$contains` on a
JSON column as a per-dialect membership test, the construct that later
moved here |
| `objectstack-ai#17853` | 0 | 1 | 0 | `08f5f0e5a` | commit | reused (stage 3; the
cli, rest, runtime, types and dogfood lanes, for the same line) | a
vitest filter that selects no test file says so |
| comment `5486840233` | 0 | 0 | 1 | ADR-0025 §3.7 | ADR | measured |
the ruling that fences per-plugin context construction to ADR-0025's
install-flow design work; §3.7 records that fence |

No ADR or ruling record decides any of the 22 commit-anchored numbers:
`git grep` over `docs/adr` and `scripts/adr-anchors` names only two of
the 24 numbers, `objectstack-ai#16682` (ADR-0131, taken as that number's anchor) and
`objectstack-ai#17147` (ADR-0025 §3.7, only as the tracker of the unbuilt seam).

## Wordings to check

Most rewrites swap a tag in place (`[#N]` to `[commit SHA]`, `(#N)` to
`(commit SHA)`, `since #N` to `since commit SHA`, `pre-#N X` to `X
before commit SHA`). These say more than the tag:

- **The sentence credits a finding to the wrong number**
(`tsconfig.examples.json:22`): 「That is the same shape objectstack-ai#14386 found in
plugin-auth's example (a published example that could not resolve,
compile or run for anyone who copied it)」. That finding is
`c49007a7c`'s, word for word in its message (「the example could not
resolve, compile or run for anyone who copied it」, 「Part of objectstack-ai#10869」),
and plugin-auth's own `tsconfig.examples.json` credits it to objectstack-ai#10869;
`7cbe705b0` (objectstack-ai#14386) widened that program to the package-root manifest.
So the line reads 「the same shape commit c49007a found」, the anchor
that does not name its number, as stage 3's `objectstack-ai#10629` exception did. Line
2 of the same file pairs the program's origin and its widening, 「(objectstack-ai#10869
/ commit 7cbe705)」, and keeps the live objectstack-ai#10869.
- **By words, the spec lane's form** (`security/index.ts:41`,
`plugin-artifact-integrity.ts:12` and `:13`): `objectstack-ai#11331` tracked the
unpack-time integrity re-verification leg, which was never built, and no
commit decides it (`b60f48b52` and `f89812e4d` kept the pointer; the cli
lane left its three sites for that reason). The spec lane's stage 1
(`21ab410417`) wrote 「Enforce leg tracked on objectstack-ai#11331.」 as 「The enforce
leg is unbuilt.」. Here 「not by the cloud control plane (objectstack-ai#11331)」 became
「(that leg is unbuilt)」, and 「(tracked on objectstack-ai#11331, NOT discharged by this
module)」 became 「(that leg is unbuilt, and NOT discharged by this
module)」. That second sentence wraps, so line 12, which carries no
number, changed with line 13: 「(tracked on」 to 「(that leg is」. It is the
only line without a number that changed.
- **The two numbers on one line** (`plugin-permission-enforcer.ts:119`):
「the ADR-0025 materialize seam (objectstack-ai#17147, Phase 1b of objectstack-ai#11333)」 became
「(measured and recorded in commit aaacf1d; the phase after commit
ea4d164)」. The first half is the spec lane's wording for the same
seam; `ea4d16420`'s diff calls itself 「objectstack-ai#11333 option A phase 1」.
- **A defect, not a change**: 「bypasses (objectstack-ai#3984, objectstack-ai#5881, objectstack-ai#6241)」 became
「bypasses (objectstack-ai#3984, objectstack-ai#5881, the one commit 83a3b1f closed)」
(`metadata-service-contract.ts:44`); 「(silent loss, the objectstack-ai#6725 family)」
became 「(silent loss, the same family as the defect commit 1507ba3
fixed)」 (`:68`, the parenthesis opening on `:67`); 「MEASURED on
`abc4b83ce` (objectstack-ai#17124)」 became 「(the defect commit 86c5052 fixed)」
(`analytics-date-range-conformance.ts:124`). Stage 7's form.
- **Rulings the anchor's message does not record keep their date**: 「The
maintainer ruling of 2026-08-08 on objectstack-ai#6216 (Option A)」 became 「The
maintainer ruling of 2026-08-08 (Option A, landed as commit f586f1a)」;
「(objectstack-ai#16721, maintainer ruling 2026-09-08, option A)」 became 「(maintainer
ruling 2026-09-08, option A, landed as commit 51ae731)」; 「objectstack-ai#16721,
maintainer ruling 2026-09-08, option A under …」 became 「commit 51ae731
landed maintainer ruling 2026-09-08, option A under …」
(`lite-kernel.ts:41`, line 42 unchanged); 「(objectstack-ai#6216 Option A)」 became
「(commit f586f1a, the ruled Option A)」, the plugin-hono-server lane's
spelling.
- **The quoted ruling** (`resolve-authz-context.ts:925`): 「maintainer
2026-09-08 on objectstack-ai#16682, verbatim: "The rest of Choice 4A …"」 became
「maintainer 2026-09-08, recorded in ADR-0131's 2026-09-17 amendment,
verbatim:」. The quotation is untouched, including its live `objectstack-ai#11974`. The
first cut cited `74832b68f`; the ADR amendment that quotes the same
sentence was found before the PR opened and replaced it in its own
commit (`a4c483901`), ruling C's order.
- **Step 1 of objectstack-ai#16721** was a measurement with no commit of its own:
「(objectstack-ai#16721 step 1)」 became 「(step 1, before commit 51ae731)」
(`lite-kernel.ts:48`), and 「the wiring objectstack-ai#16721 step 1 measured with」
became 「the wiring step 1 (before commit 51ae731) measured with」
(`plugin-contract-enforcement.test.ts:599`). 「The two inputs objectstack-ai#16721 was
filed on」 became 「The two inputs behind commit 51ae731」 (`:472`). 「the
convergence objectstack-ai#16721 / ruled for the other eight keys」 became 「the
convergence landed in commit 51ae731 as / ruled for the other eight
keys」 (`plugin-contract.ts:155`, line 156 unchanged).
- **`pre-#N`**: 「pre-objectstack-ai#6216 assembly」, 「pre-objectstack-ai#6216 transcriptions」,
「verbatim, pre-objectstack-ai#6216」 (twice), 「Pre-objectstack-ai#6216 these two」, 「The pre-objectstack-ai#6216
dispatcher」 and 「the pre-objectstack-ai#13279 `return []`」 became 「… before commit
f586f1a」 / 「… before commit 6a180e4」, stage 1's form.
- **The comment id**: 「fenced by maintainer ruling `5486840233`, which
assigns the per-plugin context to the ADR-0025 install-flow design
effort」 became 「fenced by the maintainer ruling ADR-0025 §3.7 records,
which assigns …」. §3.7's note (written by `c1078a559`) states that the
ruling assigns that construction to the ADR's install-flow design work
and forbids improvising it elsewhere; `aaacf1d5c`'s message carries the
same sentence.
- **The moved construct** (`json-membership-sql.test.ts:6`): 「moved here
from `driver-sql` (objectstack-ai#17590)」 became 「moved here from `driver-sql`, where
commit e04a0af wrote it」, so the commit is not read as the move (which
is `58a77dbde`, under the live objectstack-ai#20987 on line 4).
- **Follow-up and rule lines**: 「[objectstack-ai#17147 follow-up]」 became 「[commit
6548118, the follow-up]」. `assemble-execution-context.test.ts:33`,
`security-scanner-retirement.pin.test.ts:8` and
`granted-permissions-not-enforced.pin.test.ts:4` are decorated rules;
their dashes were not trimmed, so the change on each line is the
citation alone.
- No line was reflowed, so some are longer than their block's wrap
(`eslint.config.mjs` declares no line-length rule, and a reflow would
move neighbouring lines and every line citation into the file).

## Sites left

- **In comments (src, test, outside the glob): none.**
- **String literals: 22 test-string sites, 8 numbers, 7 files**
(`describe` and `it` titles, assertion arguments): `objectstack-ai#6216` 8
(`assemble-execution-context.test.ts`), `objectstack-ai#13279` 7
(`authz-store-unavailable.test.ts`), `objectstack-ai#17147` 2
(`granted-permissions-not-enforced.pin.test.ts:120`, `:140`), `objectstack-ai#8734`,
`objectstack-ai#10978`, `objectstack-ai#13324`, `objectstack-ai#14919` and `objectstack-ai#16721` 1 each. Every one of the 8 is
in this stage's table. Strings are outside this stage's surface;
non-test strings cite none.
- **Outside comment prose**: `test-typecheck-debt.json:3` names objectstack-ai#14613
inside the authored `_note` string (a JSON string value, not a comment);
`PHASE2_IMPLEMENTATION.md:282` names objectstack-ai#14919 in markdown prose (not a
comment or docblock, and not in `files[]`). Both anchors are in this
stage's table (`81208086a`, `cc00df2f7`).
- **Outside `src`:** the release-owned `CHANGELOG.md` names dead numbers
on 21 sites; left.

## Mechanical guard: no code token moves

The guard (stages 2 to 7's) compares base `fd96a8473` against the tree
over all 34 touched files, with TypeScript 6.0.3; the three
`tsconfig*.json` files are parsed with `ts.parseJsonText`. It ran at
`195aa6bdb` with the controls below, and again at the head `a4c483901`:

- **Reading 1**: the parser's leaf nodes, from a `forEachChild` walk.
Comments are trivia there, and JSDoc is never visited. A leaf that is
not itself a token is re-scanned with trivia skipped.
- **Reading 2**: the full token stream in parser context, from a
`getChildren` walk, JSDoc nodes skipped. String, template and numeric
literals are compared in full on both readings.

Results:

- Real run: 50,680 base tokens, **0 files with a token change** (exit
0), at both trees.
- Comment control (「distinguish the two」 to 「DISTINGUISH the two」,
`resolve-authz-context.ts`): 0 files changed (exit 0).
- Positive control, an identifier (`PLUGIN_CONTRACT_VIOLATION_CODE` to
`…CODEX`, `plugin-contract.ts`): DIFFER on both readings (exit 1).
- Positive control, a string literal (`'SERVICE_NOT_REGISTERED'` to
`'SERVICE_NOT_REGISTEREDX'`, `service-not-registered.ts`): DIFFER on
both readings (exit 1).
- Positive control, a numeric literal (`'b'.repeat(24)` to `25`,
`api-key.test.ts`): DIFFER on both readings (exit 1).
- Positive control, a JSON value (`"noEmit": true` to `false`,
`tsconfig.test.json`): DIFFER on both readings (exit 1).

Each mutation went through `scripts/ablation-replace.mjs` (wrap mode,
anchor hit 1 to 0, blob changed) under a shell trap that restores by
absolute path from `HEAD`. Each restore was proven equal to its `HEAD`
blob (`ca0fbe39fd18`, `362e8a56a1b9`, `d36529c990a5`, `5ebcb9050807`,
`d51f5f214b09`), with `git diff HEAD` empty and a clean tree afterwards.

## Changeset: `patch` (`dist` measured)

`files[]` is `dist`, `README.md` and `CHANGELOG.md`, and the package is
not private. In one script under the shared verify lock (VERDICT
command-exit 0, held 355s), at `195aa6bdb`: the workspace was built
first (`turbo run build --filter='./packages/*'
--filter='./packages/*/*' --concurrency=2`, 71 of 71 tasks, 1 cached),
then the package's own `build` (tsup, `check-dts-emitted` and the
dev-prereqs stamp) ran three times:

- **Leg 1**, the head text: 14 `dist` files hashed. Of the 40 rewritten
non-test lines, 21 appear verbatim in `dist` (docblocks on exported
members in `index.d.ts` / `index.d.cts`; the `lite-kernel.ts`,
`plugin-loader.ts` and `plugin-permission-enforcer.ts` ones also in
`index.js` / `index.cjs`).
- **Leg 2**, the base text put back in the 17 non-test touched files (17
of 17 proven equal to their base blob): 6 of the 14 files differ from
leg 1 (`index.js`, `index.cjs`, `index.d.ts`, `index.d.cts`, and the two
build-input hash stamps), and `scripts/ablation-dist-preflight.mjs`
finds the base marker 「[objectstack-ai#13279] This function used」 in 2 built files
(`index.d.ts`, `index.d.cts`; exit 0).
- **Leg 3**, after the proven restore (17 of 17 equal to their `HEAD`
blob, `git diff HEAD` empty, porcelain empty): all 14 files are
byte-identical to leg 1, and the preflight's `--absent` reading exits 0
with a clean tree, so the build is deterministic and the difference is
the rewrite.

So the rewrite ships, and `.changeset/20595-core-provenance-anchors.md`
declares a `patch` for `@objectstack/core`, comment text only, with the
claim's `Clause-②: no` line. The changeset commit touches no file under
`packages/core`. The one line `a4c483901` changed after the legs is a
`//` comment inside a function body: its text as built in leg 3 is in no
`dist` file (grep exit 1), while a docblock line of the same build is in
all four `index` files (`since commit 51ae731 it is ONE statement`,
the control), so that commit moves no shipped byte.

## Gates (head `a4c483901`)

- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` at `a4c483901` (35 paths against
merge base `6f17d1d36`, 179 changed lines) derived 65 commands. All 65
ran, each exit code captured before any pipe: 65 exit 0. `--ran` reports
「65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN」 (a derived zero) and exits
0. The PM's lead derivation (55 commands, tree `6210f887`) is a subset:
the extra 10 are `check:authz-resolver` and
`check:dispatcher-error-vocabulary`, which this package's paths add, and
the 8 changeset families.
- **Roster families under touched directories**, run as well: `node
scripts/check-changeset-fixed.mjs`, `pnpm check:error-code-casing`,
`pnpm check:filter-alias-parity`, `pnpm check:tenant-chokepoint` and
`pnpm check:object-def-param-keys`: 5 exit 0.
- **Named readings:** `node scripts/check-issue-citations.mjs` exits 0
(「every citation this change adds resolves (or is a declared cross-repo
reference)」: 6 judged across 17 files, all 6 resolve; they are the live
numbers kept on changed lines); `pnpm check:issue-citations` exits 0
(self-test); `pnpm check:doc-authoring` exits 0 (the sibling-package
prose-id baseline holds, no growth); `pnpm check:nul-bytes` exits 0
(9,851 files, no raw control bytes), and a control-byte grep over the 35
changed files finds none (exit 1).
- **Tests and typecheck, under the verify lock, at `a4c483901`**
(VERDICT command-exit 0, held 64s): `pnpm --filter @objectstack/core
test` (vitest project `local`): 76 test files pass (76), 2,156 tests
pass (2,156); `pnpm --filter @objectstack/core exec vitest run --project
repo --maxWorkers=2` (the three repo-reading tests
`vitest.repo-tests.json` lists, two of them touched here): 3 files pass,
48 tests pass; `pnpm --filter @objectstack/core typecheck` (`tsc
--noEmit`, `tsc --noEmit -p tsconfig.examples.json`, then
`check:test-typecheck`: 「4 file(s) / 4 error(s) / 4 pinned signature(s)
held」) exits 0. The same three were green at `67ef07870`, before the
last commit. `tsc --listFilesOnly` puts all 79 tracked test files in
`tsconfig.test.json`'s program and the 17 changed non-test `src` files
in `tsconfig.json`'s.
- **Lint, as a proven narrowing, at `a4c483901`:** eslint with inline
config disabled, over the 31 touched `.ts` files plus `dist/index.js` as
the control: 32 results, 0 errors and 1 warning, the control's ignore
notice; none of the 31 is reported ignored. The three `tsconfig*.json`
files answer 「File ignored because no matching configuration was
supplied」 (not eslint targets). `eslint.config.mjs` never enables
type-aware linting (its lines 327 and 328 say so), so a comment edit
cannot move the verdict on an untouched file. The repo-wide `pnpm lint`
is CI's run.

## Acceptance notes

- **Base and merge.** The branch was cut at `fd96a8473` and merges
`main` once, pinned to `6f17d1d36` (merge `67ef07870`, no conflict). The
two commits it brought (`9ff74285f`, CI test-shard scripts; `6f17d1d36`,
`plugin-approvals`) touch neither `packages/core`,
`check-issue-citations.mjs` nor `dispatch-gates.mjs`; `plugin-approvals`
was rebuilt after the merge, before the tests and gates. The net diff
against `main` is the 34 rewritten files (+81/−81) and the changeset
(+17).
- **The census frontier moved during the after run** (newest objectstack-ai#21495
before, objectstack-ai#21498 after; the board's frontier is objectstack-ai#21498). Nothing in this
package's reading depends on numbers above objectstack-ai#21494, and the third run at
the head saw no movement.
- **The same dead numbers outside this package**, each left to its own
carrier: `scripts/check-meta-type-normalized.mjs` names objectstack-ai#6241 in its
header and its failure text (outside the census surface, and a gate
script, which a citation stage does not edit);
`docs/adr/0025-plugin-package-distribution.md` §3.7 names objectstack-ai#17147 and the
comment id `5486840233`, and ADR-0131's amendment names objectstack-ai#16682 and its
comment `5587754690`, which also answers 404 (both governed, Tier H; the
amendment's quotation is what this stage anchors to, and it stays
legible whatever the links answer); `packages/cli` keeps its three
objectstack-ai#11331 sites, which the cli lane left for want of a deciding commit, and
this stage's wording is there for whoever takes them; objectstack-ai#17590 stands at
`packages/spec/src/data/filter.zod.ts:1067` in the spec lane's
population, where `e04a0aff2` is the anchor.
- **Wording only:** apart from `plugin-artifact-integrity.ts:12`, no
line without a number was changed.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…error line per refusal; the exit-signal pin covers every command (objectstack-ai#21522)

Fixes objectstack-ai#21496

Clause-②: no

This is the TEXT-face half of the exit-signal family. The JSON-face half
landed as objectstack-ai#21495 (`2ee8383f4e`); its card, objectstack-ai#21434, is done.

## What was wrong

`this.exit(1)` does not end the process. It throws oclif's exit signal
(`code: 'EEXIT'`). When the call sits inside a `try`, that `try`'s own
`catch` sees the signal first. In `os package install`, `os package
publish` and `os plugin sign`, the `catch` reported whatever it caught,
so the signal came back out as a second error line. The exit status was
right every time; the extra line was the defect.

Measured at the public door: the CLI run from source through
`bin/run-dev.js`, from a scratch directory.

| command | before (`f9a8eb889e`) | after (`2b562ed58c`) |
|---|---|---|
| `os package install ./does-not-exist.json` | `✗ Cannot read artifact:
ENOENT …`, then `✗ EEXIT: 1`; exit 1 | `✗ Cannot read artifact: ENOENT
…` only; exit 1 |
| `os package publish ./does-not-exist.json --token t --server URL` | `✗
Cannot read artifact: ENOENT …`, then `✗ EEXIT: 1`; exit 1 | the first
line only; exit 1 |
| `os package publish ./artifact.json --token t --server STUB
--icon-file ./icon.bmp` (a local stub answering the package registration
with 200) | THREE lines: `✗ Cannot infer image type from '…icon.bmp'…`,
then `✗ Cannot read --icon-file '…icon.bmp': EEXIT: 1`, then `✗ EEXIT:
1`; exit 1 | the first line only; exit 1 |

All six runs wrote nothing to stderr.

`os plugin sign` has one exit inside a `try`: the self-verification
refusal. No real key reaches it at the public door. I signed with RSA
and Ed25519 keys through the CLI, and with Ed25519, Ed448, RSA, RSA-PSS,
EC and DSA keys through `node:crypto` directly; every signature verified
against its own key. So that refusal is measured in-process, with
`verifyPayload` replaced by a seam (below). Before the fix it printed `✗
Self-verification of the produced signature failed.` and then `✗
Self-verification error: EEXIT: 1`. After the fix it prints the first
line only. The exit status is 1 both times.

## The fix

The ruled idiom (objectstack-ai#21434, `5957176280`): each affected `catch` opens with
`if (isExitSignal(error)) throw error;`, the predicate in
`src/utils/format.ts`. No second helper, and `format.ts` is not edited.

- `packages/cli/src/commands/package/install.ts`: the outer `catch` (was
`:248`).
- `packages/cli/src/commands/package/publish.ts`: the icon step's
`catch` (was `:667`) and the outer `catch` (was `:796`).
- `packages/cli/src/commands/plugin/sign.ts`: the self-verification
`catch` (was `:101`).

## Closing the class: the pin's population is now every command

The pin's analyzer is shape-based. Before this PR its population was
"declares a boolean `json` flag, or a flag whose `options` include
`'json'`". Now there is no member predicate: every module under
`src/commands`, the `src/` twin of oclif's `pattern` command table, is a
member. A later command of any face enters by existing. A new assertion
holds the population equal to the walk, so a filter that comes back goes
red.

**The widened population's red list, measured BEFORE the fix** (the
widened pin run against the unfixed commands): **3 members**, exactly
the three the card named. No further command was flagged.

| member | `this.exit`-in-`try` sites | (site, swallowing catch) pairs |
|---|---|---|
| `os package install` | 6 (`:115`, `:123`, `:155`, `:161`, `:194`,
`:210`) | 6, all in the catch at `:248` |
| `os package publish` | 15 | 17: 15 in the catch at `:796`, plus `:649`
and `:662` in the icon catch at `:667` as well |
| `os plugin sign` | 1 (`:98`) | 1, the catch at `:101` |

The site counts match the card's 6, 15 and 1. The widened population is
65 commands: the 46 JSON-capable ones from the first population, and 19
with a text face only. It holds 127 `this.exit`-in-`try` sites: 105 from
before, and 22 in the three commands above. The floors move to 65 and
127. The first population's 46 is kept as a separate floor on the face
labels.

**Name (A4): renamed.** `git mv
packages/cli/test/json-exit-signal.pin.test.ts
packages/cli/test/exit-signal.pin.test.ts`. The population is no longer
JSON-only, so the old name would have described a filter that no longer
exists. Nothing in the tree referenced the old path (`git grep
json-exit-signal` returned 0 hits). The header now describes the widened
population. Its account of how a new command enters is rewritten: the
module exists under `src/commands`, whatever faces it has. The face is
still read off `static flags`, but only to label each case (`--json`,
`--FLAG json` or `text`).

## Text-face pins

A fourth `describe` drives the three commands in-process through oclif,
with five refusal cases:

- `package install`: an unreadable artifact (refused inside a nested
`catch`), and a runtime with no install-local endpoint (refused in the
`try` itself, behind a stubbed `fetch` answering 404);
- `package publish`: an unreadable artifact, and an `--icon-file` whose
type it cannot infer (behind a stubbed `fetch` answering the
registration);
- `plugin sign`: a failed self-verification (`verifyPayload` replaced by
a seam; `signPayload` stays real).

Each case asserts that the refusal is ONE `✗` line, about the path or
URL the case chose; that `EEXIT` appears nowhere in the output; and that
the exit status is 1. Message wording is not pinned. The cases stay in
the unit tier: nothing is spawned, no kernel boots, and every file they
read is written at module scope.

**Reverse verification.** All three command files were restored to
`f9a8eb889e` by `git restore --source`, under a trap that restores them
on exit. On HEAD `2b562ed58c`, before the run, `isExitSignal` counted 2,
3 and 2 in the three files; after the restore it counted 0, 0 and 0, and
each blob was compared against the `f9a8eb889e` blob to prove the
restore landed. The pin went red as expected: **8 failed, 93 passed of
101**. Three were structural members and five were the driven cases,
which printed 2, 2, 2, 3 and 2 error lines. The files were then restored
to HEAD and proven by blob hash and an empty `git diff HEAD`. The pin
imports the commands by relative `src/` path, so no `dist/` build was
involved.

## Verification

- `pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2
test/exit-signal.pin.test.ts`: **101 passed**. That is 15 fixtures, 3
population checks, 65 members, 13 JSON-face driven cases and 5 text-face
driven cases.
- Every unit test that drives an edited command, plus
`plugin-publish-visibility`: `pnpm --filter @objectstack/cli exec vitest
run --project unit --maxWorkers=2` over the pin,
`package-install-storage-dir`, `package-publish-error-envelope`,
`package-publish-manifest-id`, `package-publish-namespace`,
`package-publish-visibility`, `plugin-sign`,
`publish-active-environment-store` and `plugin-publish-visibility`: **9
files, 172 passed**.
- The cli integration tier for the edited `package install`: `--project
integration` over `package-install-local-boot-steps.integration.test.ts`
and `package-install-local-handlers.integration.test.ts`, which spawn
`os package install` against a live runtime: **2 files, 24 passed**.
- `pnpm --filter @objectstack/cli run typecheck` (`tsc --noEmit` and
`check:test-typecheck`, whose program includes `test/**`): exit 0. The
test layer's ledger is unchanged (3 files, 28 errors, 6 pinned
signatures).
- Gates, run on HEAD `adcc2d77f2`: `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack` derived **66** commands,
and all 66 exited 0. Four of them first answered `PREREQUISITE NOT MET`
(exit 3) because the tree had no `dist/`: `check:dual-build-cjs-loads`,
`check:i18n`, `check:i18n-coverage` and `check:i18n-walk-parity`. After
`turbo run build --filter='!@objectstack/docs'` they ran again and
exited 0. `dispatch-gates --ran`: `66 derived, 66 run, 0 NOT-MEASURED, 0
UNRUN`. I also ran four roster gates whose roster sits in a directory
this diff touches; all four exited 0 (`check-changeset-fixed`,
`check:authz-resolver`, `check:error-code-casing`,
`check:filter-alias-parity`). The tool's own outside-the-list blocks are
NOT MEASURED here and are left to CI: the 5 path-scheduled CI jobs, the
4 type-check lanes, the 6 workflow-valued families, the 11
wide-population families, and the other 50 artifact-roster families.
- Lint, delivered as a proven narrowing rather than a whole-repo `pnpm
lint`, on HEAD `adcc2d77f2`. I ran `eslint --no-inline-config --format
json` (the `pnpm lint` binary and flags) over the five paths this diff
adds or modifies. The checked population comes from eslint's own answer:
the JSON has 5 entries. The 4 TypeScript files are linted with 0 errors
and 0 warnings, and the changeset is reported "File ignored because no
matching configuration was supplied". The only deleted path is the
renamed pin. Narrowing to those files cannot change any other file's
verdict. `eslint.config.mjs` never enables type-aware linting (no
`parserOptions.project`, no `projectService`, no typed rules, as its own
header states and a grep confirms). Its plugins are inline AST rules,
and the only files it reads at load are two baselines this diff does not
touch. Each verdict therefore depends on the file's own text and the
config alone.

## Acceptance notes

- **`this.error(…)` inside a `try` is outside the analyzer, which is
seeded with `exit` only.** Over the whole population on `f9a8eb889e`,
three such calls sit inside a `try`. `compile.ts:1046` is in the same
`catch` block as a `this.exit(1)` the pin already judges green.
`init.ts:1359` and `init.ts:1388` sit under an outer `catch` that
re-reports them. Measured at the public door: `os init demo -p npm` with
an unreachable registry printed `✗ Project scaffolded, but dependency
installation failed.`, then `✗ Dependency installation failed` from that
`catch`, then `Error: Dependency installation failed` on stderr, and
exited 2. That is the same family through a different signal. Widening
the analyzer's seed would reshape it, so it is reported here and in the
report, not fixed. The header's "does NOT cover" section states it.
- **`os plugin sign` accepts a non-Ed25519 key and labels the result
`ed25519:`.** With an RSA key it exits 0 and writes `ed25519:default:`
followed by a 342-character signature; an Ed25519 key gives 86
characters. The contract in
`packages/core/src/security/plugin-artifact-signature.ts` reads "This is
the CANONICAL Ed25519 detached-signature contract". `signPayload` and
`verifyPayload` both pass `null` as the algorithm and never check the
key type. This is outside this card; it is reported, not fixed.
- The public-door readings ran the CLI from source (`bin/run-dev.js`),
not a built `dist/`.

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

---------

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

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants