Skip to content

fix(cli): package install, package publish and plugin sign print one error line per refusal; the exit-signal pin covers every command - #21522

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21496-text-face-exit-signal
Oct 3, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21496-text-face-exit-signal

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21496

Clause-②: no

This is the TEXT-face half of the exit-signal family. The JSON-face half landed as #21495 (2ee8383f4e); its card, #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 (#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

claude added 3 commits October 3, 2026 02:07
… through their catch

A `this.exit(1)` inside a `try` throws oclif's exit signal, and these
commands' catches reported it as a second error line (`✗ EEXIT: 1`).
Each affected catch now opens with the `isExitSignal` rethrow, and the
exit-signal pin's population is widened from JSON-capable commands to
every command.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…xt face

The population no longer filters on a JSON face: every module under
src/commands is a member, so a command of any face enters by existing.
Floors move to the widened population (65 commands, 127 sites), and a
fourth describe drives package install, package publish and plugin sign
through a refusal each: one error line, no EEXIT, exit status 1.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels 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 6 documentable anchor(s).

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

  • content/docs/api/declarative-endpoints.mdx (via os package publish (command, read off packages/cli/src/commands/package/publish.ts))
  • content/docs/concepts/metadata-lifecycle.mdx (via os package publish (command, read off packages/cli/src/commands/package/publish.ts))
  • content/docs/deployment/cli.mdx (via os package install (command, read off packages/cli/src/commands/package/install.ts), os package publish (command, read off packages/cli/src/commands/package/publish.ts))
  • content/docs/deployment/index.mdx (via os package publish (command, read off packages/cli/src/commands/package/publish.ts))
  • content/docs/deployment/publish-and-preview.mdx (via os package install (command, read off packages/cli/src/commands/package/install.ts), os package publish (command, read off packages/cli/src/commands/package/publish.ts))
  • content/docs/protocol/kernel/index.mdx (via os package install (command, read off packages/cli/src/commands/package/install.ts), os package publish (command, read off packages/cli/src/commands/package/publish.ts))
  • content/docs/protocol/kernel/lifecycle.mdx (via os package install (command, read off packages/cli/src/commands/package/install.ts), os package publish (command, read off packages/cli/src/commands/package/publish.ts), os plugin sign (command, read off packages/cli/src/commands/plugin/sign.ts))
  • content/docs/protocol/kernel/metadata-service.mdx (via os package install (command, read off packages/cli/src/commands/package/install.ts), os package publish (command, read off packages/cli/src/commands/package/publish.ts))
  • content/docs/protocol/kernel/plugin-spec.mdx (via os package install (command, read off packages/cli/src/commands/package/install.ts), os plugin sign (command, read off packages/cli/src/commands/plugin/sign.ts))
  • content/docs/protocol/objectql/index.mdx (via os package publish (command, read off packages/cli/src/commands/package/publish.ts))
  • content/docs/protocol/objectql/schema.mdx (via os package publish (command, read off packages/cli/src/commands/package/publish.ts))

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

  • content/docs/releases/v17/17-0.mdx (via os package install (command, read off packages/cli/src/commands/package/install.ts))
  • content/docs/releases/v17/17-5.mdx (via os package publish (command, read off packages/cli/src/commands/package/publish.ts))
  • content/docs/releases/v17/17-6.mdx (via os package install (command, read off packages/cli/src/commands/package/install.ts), os package publish (command, read off packages/cli/src/commands/package/publish.ts))
  • content/docs/releases/v9.mdx (via os package publish (command, read off packages/cli/src/commands/package/publish.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 88fb5e85a02009344e9e2cf1abc929ae694c051f → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 88fb5e85a02009344e9e2cf1abc929ae694c051f

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

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 3, 2026 03:17
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 3, 2026 03:17
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 3, 2026
Merged via the queue into main with commit 5895119 Oct 3, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21496-text-face-exit-signal branch October 3, 2026 03:44
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…its and ADR that decided them (stage 9 of objectstack-ai#20595) (objectstack-ai#21525)

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

## What changed

Stage 9 of the `domain:engine` lane of the dead-citation sweep:
`packages/metadata-core/**`, comment and docblock prose only, per the
claim (`5964472656`). Stages 1 to 8 landed as `a7d9768ec`, `d150c3039`,
`4bf4e7e70`, `13a24ece2`, `db0cf2231`, `85986144c`, `48fa7a381` and
`c205b6c35`. objectstack-ai#20595 stays open: the other half of this lane is the
packages this stage does not touch (`drivers/driver-turso` 14,
`drivers/driver-mongodb` 9, `formula` 4, `metadata-fs` 2 on the census
after this stage, 29 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 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. That is **30 sites on 29 lines in 14
files, covering 12 numbers**:

- **19 census sites** (19 lines, 9 files under `src/`): the whole
`allocated-but-absent` population of the gate's own census in this
package at the base;
- **1 site outside the census glob, inside the claimed surface**:
`tsup.config.ts:43` (objectstack-ai#11235, a `//` line), stage 1's and stage 6's
`tsup.config.ts` precedent;
- **10 test-comment sites** (9 lines, 4 test files), which the census
defers. They carry 5 numbers, each also census-dead in this package's
`src` (objectstack-ai#6111, objectstack-ai#10340, objectstack-ai#12914, objectstack-ai#16864);
`form-predicate-root-policy.test.ts:149` carries two numbers on one
line.

No comment-id citation is dead here: the package's one comment id,
ruling `5865890672` on objectstack-ai#20390
(`artifact-forward-conversion.test.ts:486`), answers 200 (see Census).

**Anchors: 10 numbers by commit, 1 by ADR, 1 by repository qualifier; 10
distinct shas.** 9 numbers reuse the anchor another lane or stage
already used for them, and objectstack-ai#6111 takes stage 6's respelling. Measured
here are `b5a239815` (objectstack-ai#12930) and, for objectstack-ai#16864, the ADR-0087 passage this
sentence needs (the spec lane anchored the same number to ADR-0087 for a
different sentence; see the table).

Only comments changed. Every file keeps its line count (29 lines out, 29
in, plus the changeset), so no line citation into any of them moves. 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#10101 twice and objectstack-ai#7894, each
already on its line and each answering 200, and the `objectui#…`
references, which are cross-repository).

**A `patch` changeset**: 13 of the 20 rewritten non-test lines are in
the published `dist` (the `.d.ts` keeps JSDoc on exported members), 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 `c205b6c35` (the before run below), `allocated-but-absent` per
remaining `domain:engine` package:

| package | before | after this stage |
|---|---|---|
| `metadata-core` | **19** | **0** |
| `drivers/driver-turso` | 14 | 14 |
| `drivers/driver-mongodb` | 9 | 9 |
| `formula` | 4 | 4 |
| `metadata-fs` | 2 | 2 |
| `core`, `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 48 to 29. `metadata-core` is the largest remaining
package and reads 19, as at stage 8's head census (`a4c483901`), so the
stage went ahead.

## Census: `metadata-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/metadata-core/`.

| reading | tree | board | whole-repo `allocated-but-absent` | sites |
lines | files | numbers |
|---|---|---|---|---|---|---|---|
| before | base `c205b6c35`, run 02:14:44Z to 02:18:05Z | enumerated,
194 pages, frontier objectstack-ai#21521, 19,342 records | 163 | **19** | 19 | 9 | 11
|
| after | `fcee5ffe3`, run 02:32:19Z to 02:35:31Z | enumerated, 194
pages, frontier objectstack-ai#21521, 19,342 records (newest number read before and
after the run: objectstack-ai#21521) | 144 | **0** | 0 | 0 | 0 |

The whole-repo drop is 19, and the two finding sets differ by exactly
the 19 rows of this package, removed; none was added. `resolves`
(35,468) and `resolves-as-pull-request` (2,388) did not move;
`cross-repo-unjudged` went 1,244 to 1,247, the three census-surface
`objectui#6111` respellings.

The head's later commits are the changeset and one merge of `main`. The
census was run a third time at the head `99f2cfdf0` (02:45:55Z to
02:49:05Z, 194 pages, frontier objectstack-ai#21524, 19,345 records, newest objectstack-ai#21522
before and objectstack-ai#21524 after): whole-repo 144, `metadata-core` 0, and its
`allocated-but-absent` finding set is identical to the after run's (0
removed, 0 added). Its `resolves` reads 35,483, 15 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 (57)
and classifies each citation with the gate's `classifyCitation` against
one board enumerated by the gate's `enumerateBoard` (194 pages, frontier
objectstack-ai#21521, 19,342 records, read 02:18:40Z to 02:21:51Z), the same board for
both readings. Every one of the 12 numbers was then read on its own over
the issues endpoint (02:29:32Z): **all 12 answer 404**; the lit controls
`objectstack-ai#5286` and `objectstack-ai#12624` answer 200, and so do the two numbers that stay on
changed lines (objectstack-ai#10101, objectstack-ai#7894).

| reading | citations | dead | src comment | test comment |
`tsup.config.ts` comment | other files | test string | changelog |
|---|---|---|---|---|---|---|---|---|
| before, `c205b6c35` | 693 | **40** | 19 | 10 | 1 | 0 | 1 | 9 |
| after, `fcee5ffe3` | 668 | **10** | 0 | 0 | 0 | 0 | 1 | 9 |

The citation count drops by 25: the 30 rewritten sites less the 5
`objectui#6111` respellings, which stay citations as cross-repository
ones (src comment cross-repo 16 to 19, test comment 6 to 8). The live
counts did not move (src comment: 325 resolve, 6 as pull requests; test
comment: 49 and 4). A third, raw reading (every `#` followed by 2 to 6
digits, whatever surrounds it, `CHANGELOG.md` aside) counts 477 before
and 452 after: also a drop of 25.

**Comment ids.** Every ten-digit run under `packages/metadata-core` (its
`CHANGELOG.md` aside) was read: two lines.
`artifact-forward-conversion.test.ts:486` cites ruling `5865890672`,
which answers 200 (the objectstack-ai#20390 ruling comment; the control `5964472656`,
the claim, answers 200 too). `contract-suite.ts:331` is a zero-filled
`sha256:` fixture, not a citation.

**The objectui number.** `objectui#6111` was read in this session: it
answers 200 (a closed issue, 「Authored `FormSection.visibleWhen` is
dropped by all four plugin-form layouts」), beside `objectui#6110` and
`objectui#6010`, both 200. Stage 6 could not read objectui from its
container and reused the spec lane's reading; this one is direct.

## Per-number table

`census` counts census sites, `outside` the one site 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 `c205b6c35` (`git merge-base --is-ancestor`, exit 0 for all 10;
the clone is not shallow). The `+` lines carry exactly these 10 nine-hex
spans as new ones. Each commit names the number it replaces, in its
message, its diff or both (`b5a239815` in its subject's squash suffix
only; `f887e5249` in its message only). `git blame` at the base puts 10
of the 23 commit-anchored lines on their anchor; the other 13 were
written by a commit that cites the number as an earlier decision
(`200d255e7` citing objectstack-ai#12914 and objectstack-ai#12930, `1272f0a6b` citing objectstack-ai#8707 and
objectstack-ai#8778, `15eb2c97f` citing objectstack-ai#10340 and objectstack-ai#8919, `46644e25a` citing objectstack-ai#11021,
`15d55fb24` citing objectstack-ai#11235), 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#6111` | 3 | 0 | 2 | `objectui#6111` | repository qualifier | reused
(stage 6; the spec lane's `2123fcca3`) | every site reads 「objectui#6110
+ objectstack-ai#6111」: the second number is objectui's too, so it now carries its
qualifier like the first |
| `objectstack-ai#8707` | 3 | 0 | 0 | `1408fe385` | commit | reused (the plugin-audit,
plugin-approvals and service-automation lanes) | stamp audit rows from
the record's own organization, the resolver this package now hosts.
`[objectstack-ai#8707 / objectstack-ai#10101]` reads `[commit 1408fe3 / objectstack-ai#10101]`, the plugin-audit
lane's spelling of the same pair |
| `objectstack-ai#8778` | 1 | 0 | 0 | `7901b2dd2` | commit | reused (stage 4; the
spec, plugin-security, plugin-audit, plugin-approvals, service-storage
and service-automation lanes) | 「Option A per the maintainer ruling on
objectstack-ai#8778」: the stamp-only organization declaration. The site is inside a
quoted ruling (see Wordings) |
| `objectstack-ai#8919` | 1 | 0 | 0 | `b5378550e` | commit | reused (the runtime lane,
for the same 「single-resolution shape the REST doors carry」 phrase; the
rest, cloud-connection, plugin-security and dogfood lanes) | gate
`/meta` publish and rollback on `manage_metadata` |
| `objectstack-ai#10062` | 2 | 0 | 0 | `fa5d137ab` | commit | reused (stage 3; the
service-automation lane) | gate undeclared workspace imports; it sank
the provenance pair here and created `code-artifact-provenance.ts` |
| `objectstack-ai#10340` | 3 | 0 | 4 | `26f3588fb` | commit | reused (stage 1; the
rest and runtime lanes) | decide `/meta` org scope on the folded type;
it corrected the measured-false parity claim in
`meta-write-org-scope.ts` and wrote that file's test header |
| `objectstack-ai#10842` | 1 | 0 | 0 | `f334d662e` | commit | reused (stage 1) |
`watch(_, since)` replays from history; it settled that card and deleted
the `resumableWatch` declaration the example quoted |
| `objectstack-ai#11021` | 2 | 0 | 0 | `7d81c889f` | commit | reused (stage 1) |
`close()` terminates watch iterators instead of emitting a drain event;
it wrote the invariant's MUST NOT |
| `objectstack-ai#11235` | 0 | 1 | 0 | `376c70f98` | commit | reused (stage 1; the
rest lane) | derive the discovery `version`; it added `shims: true` to
`packages/metadata-protocol/tsup.config.ts`, the line this one mirrors |
| `objectstack-ai#12914` | 1 | 0 | 3 | `f887e5249` | commit | reused (stage 6) | a
form SECTION `visibleWhen` binds `current_user` too: it re-measured the
section contract sentence |
| `objectstack-ai#12930` | 1 | 0 | 0 | `b5a239815` | commit | measured | a form FIELD
`visibleWhen` binds `current_user`: it re-measured the field prose
(2026-08-28, the same day as the vocabulary correction `2852accef`) |
| `objectstack-ai#16864` | 1 | 0 | 1 | ADR-0087 | ADR | measured passage; the spec
lane's `a8acee28d` anchored objectstack-ai#16864 to ADR-0087's 2026-09-13 addendum for
the three-seam sentence | the 「Superseded for metadata at rest」 note
under 「The load-window's second half is now mechanical」 records the
determination these sites quote: the paragraph states the rule 「for the
authoring load path and for nothing else」, and 「Retirement is an
authoring-surface event」. It was written by `24a86923d` with the
2026-09-13 addendum (「the code half is objectstack-ai#16864's」). `29dd1a6dd`, the
commit that settled that card and wrote the flag's own docblock, says
the same; the ADR comes first |

No ADR or ruling record names any of the 12 numbers: `git grep` over
`docs/adr` and `scripts/adr-anchors` finds none of them. ADR-0087
records objectstack-ai#16864's determination without naming the number.

## Wordings to check

Most rewrites swap a tag in place (`[#N]` to `[commit SHA]`, `(#N)` to
`(commit SHA)`, `#N re-measured` to `commit SHA re-measured`, a `#N —`
header to `Commit SHA —`, stage 1's form). These say more than the tag:

- **A number inside a quoted maintainer ruling**
(`record-organization.ts:19`): 「> Ruled: Option A — extend the objectstack-ai#8778
ruling: …」 became 「> Ruled: Option A — extend the [commit 7901b2d]
ruling: …」. The square brackets are an editorial substitution, stage 5's
form for a dead number inside a quotation
(`memory-driver-document-not.test.ts`, 「[commit 9dac1ae]'s」), so the
quotation stays recognisable as one and says where it was edited. The
rest of the quotation is unchanged, and the ruling itself stands on
cloud#1395. Stage 5's case was a note quoting itself; this is a
maintainer ruling, so it is listed here for the seat's check. The
alternative is to leave the quotation untouched and carry its one site
(the census would then read `metadata-core` 1).
- **An example value** (`contract-suite.ts:90`): 「Value is the tracking
issue, e.g. `'objectstack-ai#10842'`.」 became 「Value is the tracking issue, e.g. the
one commit f334d66 closed.」. The example was `SysMetadataRepository`'s
own declaration, which `f334d662e` deleted when it closed the number
(its message says that declaration 「is deleted; the pin it swapped in
went red when replay landed」), as the docblock's next sentence says.
Stage 8's 「the one commit 83a3b1f closed」 form; stage 1 wrote the same
value as `resumableWatch: …`.
- **The ADR anchor** (`artifact-forward-conversion.ts:101` and its test
`:360`): 「(objectstack-ai#16864's determination, and the flag's own docblock now says
so)」 became 「(ADR-0087's recorded determination, and the flag's own
docblock now says so)」; 「(objectstack-ai#16864's / determination, landed)」 became
「(ADR-0087's recorded / determination, landed)」, with `:361` unchanged.
- **A measurement, not a change**: 「the objectstack-ai#10340 measurement in
`meta-write-org-scope.ts`」 became 「the measurement commit 26f3588
wrote in `meta-write-org-scope.ts`」 (`meta-write-capability.ts:116`),
and 「the objectstack-ai#10340 measurement」 became 「the measurement commit 26f3588
wrote」 (`meta-write-capability.test.ts:124`).
- **Sentence starts**: 「objectstack-ai#12914 replaced that sentence」 became 「Commit
f887e52 replaced that sentence」
(`form-predicate-root-policy.test.ts:108`), and 「silent. objectstack-ai#12914 replaced
that contract」 became 「silent. Commit f887e52 replaced that contract」
(`:279`).
- **Pairs that keep a live half**: 「objectstack-ai#10340 / objectstack-ai#7894」 became 「commit
26f3588 / objectstack-ai#7894」 (`meta-write-org-scope.test.ts:22`); `[objectstack-ai#8707 /
objectstack-ai#10101]` keeps `objectstack-ai#10101` (`index.ts:120`, `record-organization.ts:4`).
- 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: 1 test-string site**:
`meta-write-org-scope.test.ts:39`, the `describe` title 「objectstack-ai#10340 org
scope composed with the boundary fold」. objectstack-ai#10340 is in this stage's table
(`26f3588fb`). Strings are outside this stage's surface; non-test
strings cite none.
- **Outside `src`:** the release-owned `CHANGELOG.md` names dead numbers
on 9 sites; left.

## Mechanical guard: no code token moves

The guard (stages 2 to 8's) compares base `c205b6c35` against the tree
over all 14 touched files, with TypeScript 6.0.3:

- **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, at `fcee5ffe3`:

- Real run: 17,663 base tokens, **0 files with a token change** (exit
0).
- Comment control (「Faithful again」 to 「FAITHFUL again」,
`form-predicate-root-policy.ts`): 0 files changed (exit 0).
- Positive control, an identifier (`BOUND_FORM_FIELD_PREDICATE_ROOTS` to
`XBOUND_…`, `form-predicate-root-policy.ts`): DIFFER on both readings
(exit 1).
- Positive control, a template-literal string (「must name the 」 to 「must
name thE 」, `contract-suite.ts`): DIFFER on both readings (exit 1).
- Positive control, a numeric literal (`setTimeout(resolve, 100)` to
`101`, `contract-suite.ts`): DIFFER on both readings (exit 1).
- Positive control, a config value (`sourcemap: true` to `false`,
`tsup.config.ts`): 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 (`ce90e5aab5fa`, `cc43d4b043b5`, `23fa6b1bd3aa`), with `git diff
HEAD` empty and a clean tree afterwards. The identifier control's first
attempt was refused by `ablation-replace` before the guard ran (its
replacement contained the anchor, so the anchor count moved 1 to 1); the
anchor was changed and the whole control set re-run, and the numbers
above are that run's.

## 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 89s), at `fcee5ffe3`: the dependency closure was
built first (`pnpm --filter '@objectstack/metadata-core^...' build`),
then the package's own `build` (tsup and `check-dts-emitted`) ran three
times:

- **Leg 1**, the head text: 12 `dist` files hashed. Of the 20 rewritten
non-test lines, 13 appear verbatim in `dist`, all in declaration files
(`index.d.ts` / `index.d.cts`, the shared chunk
`repository-DHMpxysr.d.ts`, and `testing.d.ts` for `contract-suite.ts`).
The 7 that do not are module docblocks, `//` lines and the docblock of a
non-exported constant (`ORG_OVERRIDABLE_TYPES`):
`artifact-forward-conversion.ts:101`, `index.ts:57` and `:120`,
`record-organization.ts:4` and `:19`, `meta-write-org-scope.ts:77`,
`tsup.config.ts:43`.
- **Leg 2**, the base text put back in the 10 non-test touched files (10
of 10 proven equal to their base blob): 4 of the 12 files differ from
leg 1 (`index.d.ts`, `index.d.cts`, `repository-DHMpxysr.d.ts`,
`testing.d.ts`); the JavaScript files and their sourcemaps do not.
`scripts/ablation-dist-preflight.mjs` finds the base marker 「the objectstack-ai#10340
measurement in」 in 2 built files (`index.d.ts`, `index.d.cts`; exit 0).
- **Leg 3**, after the proven restore (10 of 10 equal to their `HEAD`
blob, `git diff HEAD` empty, porcelain empty): all 12 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-metadata-core-provenance-anchors.md` declares a
`patch` for `@objectstack/metadata-core`, comment text only, with the
claim's `Clause-②: no` line. It names every anchor that is not a commit:
ADR-0087 for the two `retiredFromLoadPath` sites, the five
`objectui#6111` respellings, and the bracketed substitution inside the
quoted ruling. The changeset commit touches no file under
`packages/metadata-core`.

## Gates (head `99f2cfdf0`)

- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` at `99f2cfdf0` (15 paths against
merge base `88fb5e85a`, 78 changed lines) derived 62 commands. All 62
ran, each exit code captured before any pipe: 62 exit 0. `--ran` reports
「62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN」 (a derived zero) and exits
0. The PM's lead derivation (54 commands, tree `c205b6c35`) is a subset:
the extra 8 are the families the `.changeset/` path adds (the ADR-0087
registration and empty-changeset pairs, `check:objectui-changeset`,
`check:pm-changeset-deadline-census` and two release self-tests).
- **Roster families under touched directories**, run as well: `node
scripts/check-changeset-fixed.mjs`, `pnpm check:authz-resolver`, `pnpm
check:error-code-casing` and `pnpm check:filter-alias-parity`: 4 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)」: 8 judged across 9 files: 6 cross-repo and 2 live, the
`objectui#…` pairs and objectstack-ai#10101 kept on changed lines); `pnpm
check:issue-citations` exits 0 (self-test, 173 cases, 9 batteries);
`pnpm check:doc-authoring` exits 0 (the sibling-package prose-id
baseline holds, no growth); `pnpm check:nul-bytes` exits 0 (9,870 files,
no raw control bytes), and a control-byte grep over the 15 changed files
finds none (exit 1). The four changeset gates
(`check-changeset-no-major`, `check-adr-0087-registration`,
`check-empty-changeset` with `--base origin/main`, and
`check:changeset-gate-self-tests`) exit 0.
- **Build, tests and typecheck, under the verify lock:** the workspace
build after the merge (`turbo run build --filter='./packages/*'
--filter='./packages/*/*' --concurrency=2`, 71 of 71 tasks, 8 cached;
VERDICT command-exit 0, held 246s); then at `99f2cfdf0` `pnpm --filter
@objectstack/metadata-core test`: 16 test files pass (16), 298 tests
pass (298); `pnpm --filter @objectstack/metadata-core typecheck` (`tsc
--noEmit && tsc --noEmit -p tsconfig.test.json`) exits 0 (VERDICT
command-exit 0, held 18s). `tsc --listFilesOnly` puts all 16 tracked
test files in `tsconfig.test.json`'s program and the 9 changed non-test
`src` files in `tsconfig.json`'s; `tsup.config.ts` is in neither, and
the token guard covers it. No importing package owes a run: the
declaration files change only in comment text.
- **Lint, as a proven narrowing, at `99f2cfdf0`:** eslint with inline
config disabled, over the 14 touched `.ts` files plus `dist/index.js` as
the control: 15 results, 0 errors and 1 warning, the control's ignore
notice; none of the 14 is reported ignored. `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 `c205b6c35` and merges
`main` once, pinned to `88fb5e85a` (merge `99f2cfdf0`, no conflict). The
three commits it brought (`2df621af3`, `verify`; `e9dec3dab`,
`metadata-protocol`; `88fb5e85a`, `plugin-approvals`) touch neither
`packages/metadata-core`, `check-issue-citations.mjs` nor
`dispatch-gates.mjs`; the workspace was rebuilt after the merge, before
the tests and gates. The net diff against `main` is the 14 rewritten
files (+29/−29) and the changeset (+20).
- **The same dead numbers outside this package**, each left to its own
carrier: `metadata-fs` (this lane's later stage) names objectstack-ai#11021 in
`src/repository.ts` and `src/sync.ts`, where `7d81c889f` is the anchor;
other lanes' test files carry objectstack-ai#10340, objectstack-ai#10842, objectstack-ai#8707, objectstack-ai#8778, objectstack-ai#8919,
objectstack-ai#11021, objectstack-ai#11235 and objectstack-ai#16864; `scripts/check-undeclared-dep-imports.mjs`
and `scripts/check-dual-build-cjs-loads.mjs` name objectstack-ai#10062 and objectstack-ai#11235
(gate scripts, outside the census surface); the release pages name
objectstack-ai#8707, objectstack-ai#8778 and objectstack-ai#8919 (release-owned).
- **The `resumableWatch` contract asks for a tracking issue.**
`DeclaredDivergences.resumableWatch` is documented as 「the tracking
issue」, `runRepositoryContractTests` refuses a blank one, and its value
is printed into a test title (「DECLARED DIVERGENCE …」) of the published
`./testing` suite. No implementation declares one today, so nothing
ships a number; a future declaration would put a tracker number into
that title. Not this stage's surface (a contract, not a comment); noted,
not filed.
- **Wording only:** 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
…at is not Ed25519 (objectstack-ai#21534)

Fixes objectstack-ai#21524

Clause-②: no (narrowing)

The plugin artifact signature contract in `@objectstack/core` declares
Ed25519 and now enforces it. Before this change, `signPayload` and
`verifyPayload` handed any key to node's `sign(null, …)` / `verify(null,
…)`, and those follow the key. So an RSA, EC or Ed448 key signed under
the `ed25519:KEYID:SIG` label and verified against its own public half.
Measured at `ad7c351898`, through the module and five key input shapes:
an RSA key produced a 342-character signature, EC 94 to 96, Ed448 152,
and Ed25519 86. Every one was labelled `ed25519` and every one verified
`true`.

## What changed

`packages/core/src/security/plugin-artifact-signature.ts`:

- **One check, `requireSignatureKeyType(key, alg, use)`.** A key passes
only when `key.asymmetricKeyType` IS the algorithm. Otherwise it throws
a plain `Error`, the module's existing style, naming the key type found
(`rsa`, `ec`, `ed448`, or `secret` for a symmetric key).
- **`signPayload`** calls the check with `SIGNATURE_ALG` before signing.
- **`verifyPayload`** calls it with `parsed.alg`, the algorithm the
signature's own label names. So the label is checked against the
verifying key's type instead of being trusted. `parseSignature` admits
only the `ed25519` label, so the same comparison is also the refusal of
every non-Ed25519 key. It is one rule, not two.
- **Key normalisation** is now "a `KeyObject` is used as given, anything
else goes through `createPrivateKey` / `createPublicKey`". It used to be
"a string is parsed, anything else is passed through". This keeps every
input node accepted working for an Ed25519 key, measured: PEM string,
`KeyObject`, PEM buffer, DER object and JWK object, at sign and at
verify. Without it, a PEM buffer would have reached the check with no
key type.
- **`verifyPublisherSignature`, `verifyPlatformSignature` and
`verifyPluginArtifact`** verify through `verifyPayload` and get the
check from there. Only their docblocks changed.

**Why `verifyPayload` throws where it used to answer `false`.** The card
asks for this to be justified. The ruling requires the refusal to be
loud and to name the key type, and a boolean can carry neither. The
trust paths below show that the verifying key always comes from the
caller's own configuration, never from the artifact. A non-Ed25519 key
is therefore a misconfigured trust anchor, not a verdict on the signed
bytes. Folded into `false`, it would read exactly like a tampered
artifact. Every case that answered `false` before still does: a
malformed signature string, a key that cannot be parsed, and a signature
that does not verify.

**`os plugin sign`: no edit to
`packages/cli/src/commands/plugin/sign.ts`.** Measured: its existing
`catch` around `signPayload` (`sign.ts:81-87` at `ad7c351898`) already
prints `✗ Signing failed: …` and calls `this.exit(1)` from the `catch`
body, outside the `try`. So the refusal surfaces as one error line and
exit 1 with no source change. PR objectstack-ai#21522, which edits that file, has
since landed (`5895119c35`). Its change is in the self-verification
`catch`, not on this path. The CLI pin was also run against main's
landed `sign.ts`: 3/3 passed (see Tests).

**Changeset:** `@objectstack/core` minor, BREAKING, `Clause-②: no
(narrowing)`, ADR-0087 `not-required (no-migration-prescription)`.
`check:adr-0087-registration` reads it and passes.

## Trust paths of `verifyPluginArtifact`

Read at `ad7c351898`, in
`packages/core/src/security/plugin-artifact-signature.ts` unless named
otherwise.

1. **Platform key**: `keys.platformPublicKey` (`:212`), used at
`:228-229`, then `verifyPlatformSignature` (`:171`), then
`verifyPayload`. This is caller configuration.
2. **Publisher key**: `keys.getPublisherPublicKey(keyId)` (`:213`),
passed at `:219-222` to `verifyPublisherSignature` (`:148`), and
resolved at `:162`. The resolver is the caller's key registry. The
artifact contributes only the `keyId`, read out of its own signature
string by `parseSignature` (`:81`). That id selects an entry in the
caller's registry. An id the registry does not know is refused (`:163`),
and no resolver means `verified: false` (`:158-160`). The artifact
cannot introduce a key.
3. **Fields read from the artifact or its version record**: `signature`,
`platform_signature`, `package_id`, `version` and `blob_key`. These are
signature strings and identity only. No field carrying a key is read.
4. **`packages/core/src/plugin-loader.ts:512-536`**
(`verifyPluginSignature`) reads `plugin.signature` through
`parseSignature` for well-formedness and logs `alg` / `keyId`. There is
no key and no cryptographic check.
5. **Production callers of `verifyPluginArtifact` in this repository:
none.** `git grep` finds the module, the barrel re-export
(`security/index.ts:34`) and the module's test. This matches ADR-0025's
status line, which says there is no `.osplugin` loader and no runtime
path on which a distributed plugin executes.
6. **`os plugin sign`'s self-check** (`sign.ts:95-96`) verifies with the
public half derived from the private key it just signed with. That is a
self-consistency check, not a trust path.

**Against triage's raise rule:** no path admits a key supplied with the
artifact. The one input the artifact chooses is the `keyId`, a selector
over the caller's registry that cannot reach a key outside it. This is a
reading, stated for the seat to grade.

## Cloud counterpart

NOT MEASURED: no read access to objectstack-ai/cloud from this session.
The module header claims byte-for-byte compatibility with the cloud
control plane's signing module, and this PR did not read that module.
Whether it has the same gap, and the bare finding to file there if it
does, is left to a seat with that access. For an Ed25519 key this side's
output is unchanged: the signature bytes are deterministic and identical
before and after.

## Tests (at `f1f4368020`)

- `pnpm --filter @objectstack/core exec vitest run --project local
src/security/plugin-artifact-signature.test.ts`: **24 passed**, 14
existing and 10 new.
- An RSA key and an EC key are each refused at `signPayload`, as PEM and
as `KeyObject`.
- Each is refused at `verifyPayload` with a signature that verifies
cryptographically, so the check is the only thing refusing it.
- Each is refused on both `verifyPluginArtifact` trust paths: the
publisher registry and the platform key.
- An Ed25519 key signs and verifies as before. A PEM signature and a
`KeyObject` signature are byte-identical.
- A label that disagrees with the key type is refused both ways: an
`ed25519` label over an RSA key throws, and an `rsa` label answers
`false`.
- A symmetric key is refused as `secret`. An unreadable key and a
malformed signature still answer `false`.
- `pnpm --filter @objectstack/core test`: 76 files, **2166 passed**.
`pnpm --filter @objectstack/core typecheck`: exit 0. The test file is in
the `tsconfig.test.json` program (`--listFiles`: 1 hit).
- `pnpm --filter @objectstack/cli exec vitest run --project unit` over
`test/plugin-sign.test.ts`, `test/plugin-commands.test.ts`,
`test/plugin-publish.test.ts`, `test/plugin-publish-visibility.test.ts`
and `test/json-exit-signal.pin.test.ts`: 5 files, **100 passed**. The
new pin, for an RSA key and an EC key, asserts four things: exit 1,
exactly one error line, that line being `signPayload`'s refusal naming
the key type, and no `Plugin signed` and no sidecar.
- `pnpm --filter @objectstack/cli typecheck`: exit 0. The CLI
`integration` tier is left to CI: the diff touches no integration-tier
file and no spawn entry.
- **Joint state:** `sign.ts` was swapped to main's landed copy
(`550f4cc2fd`, which carries PR objectstack-ai#21522) and then restored. The blob
matched HEAD and `git diff HEAD` was empty. The CLI pin passed 3/3.
- The CLI suite resolves `@objectstack/core` through `dist/`, so `core`
was rebuilt before every CLI reading.

## Ablation and reverse verification

Each leg ran on committed code, through `scripts/ablation-replace.mjs`.
Each restore was proven by blob hash equal to HEAD and an empty `git
diff HEAD`. Every leg below went red as predicted, except C's first run,
which is described under C.

- **A**: delete the `signPayload` check. The core suite resolves source.
**3 red**: RSA sign, EC sign and `secret`. 21 green.
- **B**: delete the `verifyPayload` check. **5 red**: RSA and EC verify,
RSA and EC trust paths, and the label case. 19 green.
- **C**: delete the `signPayload` check, measured at the CLI. Core was
rebuilt, and `ablation-dist-preflight` confirmed the marker absent from
`dist/`. **Observed direction, first run: the CLI pin stayed green.**
The verify-side check refused the same key at the self-verification
step, so the command still exited 1 with one error naming `rsa`. The two
checks overlap at this door. The pin was then tightened to assert that
the refusal is `signPayload`'s, given at sign time. Re-run: **2 red**
(`expected '✗ Self-verification error: verifyPayload…' to match
/signPayload: the private key is of type …/`).
- **D**: make the one check a no-op, measured at the CLI. The
`ablation-d-noop` marker was present in `dist/`. **2 red**: `expected
undefined to be 1`. The command completed with exit 0, which reproduces
the card's measurement at the public door.
- **Restore leg:** core rebuilt. Preflight found the D marker absent and
the original check present, and the tree clean against HEAD. The CLI pin
passed 3/3.

## Gates

- `node scripts/pm/dispatch-gates.mjs --commands`, run without paths on
the final diff: 64 commands, tree `f1f4368020`, merge base `ad7c35189`.
All 64 exited 0.
- `check:dual-build-cjs-loads` and `check:lean-entry-closure` first
answered exit 3 PREREQUISITE NOT MET. Both were re-run green after a
full turbo build (72 tasks, 71 cached).
- `--ran` reconciliation: 64 derived, 64 run, 0 NOT-MEASURED. That zero
is derived, because every line carries its exit code.
- The PM's lead list also named `check:i18n`, `check:i18n-coverage` and
`check:i18n-walk-parity`. The derivation on this diff does not, because
no `packages/cli/src` path changed. They were not run.

**Lint, a declared narrowing:** `pnpm exec eslint --no-inline-config
--format json` on the three touched TypeScript files gave 3 files, 0
errors and 0 warnings.

- **Population:** the config's `files:
['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']`. The changeset is outside it.
- **Invariance:** `eslint.config.mjs` enables no type-aware linting. It
sets no `parserOptions.project` and no typed rules, and every
`parserOptions` block is `ecmaVersion` and `sourceType` only. Its only
disk reads are two baseline JSON files this diff does not touch. So the
diff cannot move the verdict on an untouched file.

## Acceptance notes

- **`PluginSignatureVerifier`**
(`packages/core/src/security/plugin-signature-verifier.ts`) is a second
plugin-signature verifier, exported from the same barrel. It declares
`algorithm: 'RS256' | 'ES256'` and verifies with
`createVerify('RSA-SHA256')` / `createVerify('sha256')`, where the key,
not the declared algorithm, again decides the scheme. Measured with node
directly, not through the class: `createVerify('RSA-SHA256')` verifies
an ECDSA signature against an EC key as `true`. It also expects a bare
base64 signature, while `plugin-loader.ts` requires
`PluginMetadata.signature` to parse as `ed25519:KEYID:SIG`. It has zero
production callers: the barrel and one pin test that asserts it is
exported. The class was read, not run, and has no live reach, so it is
noted and not filed. No carrier.
- `verifyPayload` still answers `false` for a key it cannot parse at
all, which is the trust configuration being wrong in a different way. It
is left as it was, under the card's rule of no throw where `false` was
answered before, and noted only. No carrier.

---
_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
…n is seeded with this.error (objectstack-ai#21541)

Fixes objectstack-ai#21523

Clause-②: no

This is the `this.error` face of the exit-signal family. Its `this.exit`
text face (objectstack-ai#21496) landed as PR objectstack-ai#21522 (`5895119c35`), which renamed and
widened the pin this PR extends.

## What was wrong

`this.error(msg)` does not end the process. It throws oclif's
`CLIError`, which carries `oclif.exit` (2 by default), so `isExitSignal`
(`packages/cli/src/utils/format.ts`) already recognises it. In `os
init`, two `this.error` calls sit inside `run()`'s outer `try`: the
scaffold self-test refusal and the dependency-install refusal. That
`try`'s `catch` printed the message again with `printError`, then raised
a second `this.error` with the same message.

Measured at the public door: the published entry
`packages/cli/bin/run.js` over a freshly built `dist/`, run from a
scratch directory, with npm pointed at a closed local port
(`npm_config_registry=http://127.0.0.1:9/`,
`npm_config_fetch_retries=0`).

| `os init demo -p npm` | before (`bee8d1c62c`) | after (`549f3704e4`) |
|---|---|---|
| stdout | `✗ Project scaffolded, but dependency installation failed.`,
then `✗ Dependency installation failed` | `✗ Project scaffolded, but
dependency installation failed.` only |
| stderr (besides npm's own output) | `› Error: Dependency installation
failed` | `› Error: Dependency installation failed` |
| exit status | 2 | 2 |

The scaffold self-test refusal had the same shape, measured in-process
(below): `✗ Scaffold validation failed: …`, then a second `✗ Scaffold
validation failed`.

## The fix

The outer `catch` of `packages/cli/src/commands/init.ts` now opens with
`if (isExitSignal(error)) throw error;`, imported from
`utils/format.js`. This is the ruled idiom: no second helper, and
`format.ts` is not edited. The catch-all still prints and refuses for
every other error it catches.

## Closing the class: the analyzer is seeded with `this.error`

The analyzer in `packages/cli/test/exit-signal.pin.test.ts` was seeded
with `exit` only. It is now seeded with both members (`SIGNAL_SEEDS =
['exit', 'error']`), over the same every-command population discovered
from oclif's command table. A later command that throws either signal
inside a `try` enters by existing.

**Red list of the reseeded analyzer, measured BEFORE the fix on
`bee8d1c62c`: 1 member, 2 sites.**

- `os init`: `src/commands/init.ts:1359 this.error('Scaffold validation
failed')` and `src/commands/init.ts:1388 this.error('Dependency
installation failed')`, both swallowed by the catch at line 1391 ("its
first statement is not the isExitSignal rethrow").

Both are in scope and both are repaired by the one catch above. The
reseed finds 4 `this.error` sites inside a `try` over the population, 3
distinct in source:

- `init.ts`'s two, above;
- `compile.ts:1046` (`this.error(err.message)`), judged once for `os
compile` and once for `os build`, which inherits it. It shares its
enclosing catch with a `this.exit(1)` the pin already judged. That catch
opens with the rethrow, so it stays green.

No flagged catch handles the signal on purpose. The only red catch,
`init.ts`'s, re-reports what it caught. No catch converts a `this.error`
into something else, so the two seeds have the same shape.

One more census, taken on `bee8d1c62c` over every command source: the
only other oclif `Command` member that throws the signal, `this.parse`,
is called inside a `try` by no command. The header records this, along
with the rule that a command which does so adds a seed.

**Header and floors.** The header now names what seeds the analyzer, and
says a new command enters by either call. The population floor (65) and
the JSON-face floor (46) are unchanged; both were re-measured on
`bee8d1c62c`. The site floor rises from 127 to 131: the 127 `this.exit`
sites plus the 4 `this.error` sites.

**Fixtures.** Five new fixtures pin the new seed:

- the `os init` defect shape (red);
- the same shape under the idiom (green);
- a `this.error` outside every `try` (not a site);
- a `this.error` reached through a same-class helper (red);
- `os compile`'s shape: a `this.error` sharing a guarded catch with a
`this.exit` (green).

`this.error(msg, { exit: false })` throws nothing, and no command writes
it. The header says the analyzer judges it like any other `this.error`.
That can give a false red (the guard is harmless there), never a false
green.

## Text-face pins for `os init`

Two cases join the driven text-face `describe`. `os init` runs
in-process through oclif. Three things are replaced:

- its package-manager install (`execSync`, through
`vi.mock('child_process')`) by a seam;
- its scaffold self-test (`validateScaffold`) by a seam;
- its working directory, by a scratch directory (a `process.cwd` spy,
restored after each case).

The cases:

- **A failed dependency install.** ONE `✗` line and exit 2. The install
ran once, in the target directory. The self-test never ran.
- **A scaffold its own self-test rejects.** ONE `✗` line, naming the
rejection the case chose, and exit 2.

`expectOneRefusal` now takes the expected status: 1 for the
`this.exit(1)` refusals, as before, and 2 for `os init`'s `this.error`
ones. The tier stays `unit`: nothing is spawned and nothing is bundled.

## Verification

Everything below ran on the final commit `549f3704e4` unless it names
another commit.

- **The pin.** `pnpm --filter @objectstack/cli exec vitest run
--maxWorkers=2 test/exit-signal.pin.test.ts`: **109 passed**. Baseline
on `bee8d1c62c`, before any edit: 101 passed.
- **Before the fix**, with the pin edited and `init.ts` untouched: 3
failed, 106 passed. The failures are `os init (text)`, with the two
leaks above, and both driven `os init` cases, each printing 2 `✗` lines.
- **Reverse verification, after the fix was committed.**
- Command: `node scripts/ablation-replace.mjs --file
packages/cli/src/commands/init.ts --anchor 'if (isExitSignal(error))
throw error;' --delete -- pnpm --filter @objectstack/cli exec vitest run
--maxWorkers=2 test/exit-signal.pin.test.ts`.
- The mutation landed: anchor count 1 → 0, blob `4930c989f594` →
`d811c1c322b6`.
- Result: **3 failed, 106 passed**, exactly the three predicted. `os
init (text)` names `init.ts:1360` and `:1389` swallowed by the catch at
`:1392`. Each driven case reports "expected [ …(2) ] to have a length of
1 but got 2".
- The restore was proven: blob == HEAD (`4930c989f594`) and `git diff
HEAD` is empty.
- The pin imports `init.ts` from `src/` by a relative import, so this
leg needed no `dist/` rebuild.
- **`packages/cli` unit tier.** `pnpm --filter @objectstack/cli exec
vitest run --project unit --maxWorkers=2`: 251 files, 3672 tests passed.
The integration tier is declared to CI.
- **The six nightly-tier e2e files that drive `os init`.** These are
`init-created-files-summary`, `starter-field-consumers`,
`scaffold-emission-policy`, `generate-object-namespace-prefix`,
`generate-scaffolds-reach-stack` and
`create-refuses-invalid-project-name`. Command: `OS_TEST_TIERS=nightly
pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2` over
those files. Result: 6 files, 53 tests passed.
`init-created-files-summary` drives the failed-install path through a
fake package manager on `PATH`.
- **Typecheck.** `pnpm --filter @objectstack/cli typecheck` exits 0.
`check:test-typecheck` reports OK: the debt ledger holds 28 errors, and
none is in the pin file. `--listFiles` confirms the pin is in
`tsconfig.test.json`'s program.
- **Gates.**
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 66 commands for this diff, and all
66 exit 0.
- Two of them, `check:dual-build-cjs-loads` and `check:i18n-coverage`,
first answered exit 3 (PREREQUISITE NOT MET: no `dist/` for packages
outside the cli closure). They were re-run after a full `turbo run
build` and then exited 0.
- `--ran` reconciliation: "66 derived famil(ies) accounted for — 66 run,
0 NOT-MEASURED".
- **Lint: a proven narrowing, not a `pnpm lint` run.**
- ① The population, read from eslint's own config: both touched
TypeScript files are linted. `eslint --print-config` shows 5 rules in
effect on each. The changeset is outside every `files` glob.
- ② The file count, from `--format json`: `node --stack-size=4000
node_modules/eslint/bin/eslint.js --no-inline-config --format json
packages/cli/src/commands/init.ts
packages/cli/test/exit-signal.pin.test.ts` reports 2 files, 0 errors, 0
warnings, exit 0.
- ③ Invariance: on both files `--print-config` shows
`parserOptions.project` and `projectService` null. The config states it
never enables type-aware linting. Every rule in effect is single-file
(`no-restricted-syntax`, `no-restricted-imports`,
`slot-lookup/no-any-assignment`, `query-options/no-any-erasure`,
`verify-stand-in/no-asserted-driver-argument`,
`comment-swallow/no-code-inside-block-comment`). This diff touches
neither `eslint.config.mjs` nor the baselines it reads, so no untouched
file's verdict can move.

## Acceptance notes

- **oclif's own error block remains.** After the fix, `os init`'s
refusal is still followed by oclif's `› Error: …` block on stderr. The
entry point renders that block from the thrown `CLIError` after `run()`
exits, and it printed exactly once before the fix too. What the fix
removed is the catch's second `✗` line. Every other `os init` refusal
has the same `✗` + `Error:` pair: `os init demo -t bogus` prints `✗
Unknown template: bogus` and then `› Error: Unknown template: bogus`,
exit 2, measured on `549f3704e4`. That pairing comes from `printError`
followed by `this.error`, not from this mechanism. The pin counts the
command's `✗` lines, as the family's other text-face pins do. Whether
the pairing is itself a second report of one refusal is outside this
card, and is reported to the seat rather than changed here.
- **No changes outside the claimed surface.** Nothing under `dev.ts`,
`start.ts`, `serve.ts`, `packages/runtime/` or
`packages/metadata-protocol/` was touched. `serve.ts` changed on `main`
since this branch's base (`550f4cc2fd`), adding a `try` with no signal
call inside it, and `git merge-tree` of this head with `main` is clean.

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

Projects

None yet

2 participants