Repository navigation
fix(cli): package install, package publish and plugin sign print one error line per refusal; the exit-signal pin covers every command - #21522
Conversation
… 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
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
📓 Docs Drift CheckThis PR changes 1 package(s): 11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
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): Which tree this was computed onThis run read A worktree cut from an older # 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
|
…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>
…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>
…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>
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 atry, thattry's owncatchsees the signal first. Inos package install,os package publishandos plugin sign, thecatchreported 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.f9a8eb889e)2b562ed58c)os package install ./does-not-exist.json✗ Cannot read artifact: ENOENT …, then✗ EEXIT: 1; exit 1✗ Cannot read artifact: ENOENT …only; exit 1os package publish ./does-not-exist.json --token t --server URL✗ Cannot read artifact: ENOENT …, then✗ EEXIT: 1; exit 1os package publish ./artifact.json --token t --server STUB --icon-file ./icon.bmp(a local stub answering the package registration with 200)✗ Cannot infer image type from '…icon.bmp'…, then✗ Cannot read --icon-file '…icon.bmp': EEXIT: 1, then✗ EEXIT: 1; exit 1All six runs wrote nothing to stderr.
os plugin signhas one exit inside atry: 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 throughnode:cryptodirectly; every signature verified against its own key. So that refusal is measured in-process, withverifyPayloadreplaced 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 affectedcatchopens withif (isExitSignal(error)) throw error;, the predicate insrc/utils/format.ts. No second helper, andformat.tsis not edited.packages/cli/src/commands/package/install.ts: the outercatch(was:248).packages/cli/src/commands/package/publish.ts: the icon step'scatch(was:667) and the outercatch(was:796).packages/cli/src/commands/plugin/sign.ts: the self-verificationcatch(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
jsonflag, or a flag whoseoptionsinclude'json'". Now there is no member predicate: every module undersrc/commands, thesrc/twin of oclif'spatterncommand 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.
this.exit-in-trysitesos package install:115,:123,:155,:161,:194,:210):248os package publish:796, plus:649and:662in the icon catch at:667as wellos plugin sign:98):101The 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-trysites: 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-signalreturned 0 hits). The header now describes the widened population. Its account of how a new command enters is rewritten: the module exists undersrc/commands, whatever faces it has. The face is still read offstatic flags, but only to label each case (--json,--FLAG jsonortext).Text-face pins
A fourth
describedrives the three commands in-process through oclif, with five refusal cases:package install: an unreadable artifact (refused inside a nestedcatch), and a runtime with no install-local endpoint (refused in thetryitself, behind a stubbedfetchanswering 404);package publish: an unreadable artifact, and an--icon-filewhose type it cannot infer (behind a stubbedfetchanswering the registration);plugin sign: a failed self-verification (verifyPayloadreplaced by a seam;signPayloadstays real).Each case asserts that the refusal is ONE
✗line, about the path or URL the case chose; thatEEXITappears 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
f9a8eb889ebygit restore --source, under a trap that restores them on exit. On HEAD2b562ed58c, before the run,isExitSignalcounted 2, 3 and 2 in the three files; after the restore it counted 0, 0 and 0, and each blob was compared against thef9a8eb889eblob 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 emptygit diff HEAD. The pin imports the commands by relativesrc/path, so nodist/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.plugin-publish-visibility:pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2over 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-storeandplugin-publish-visibility: 9 files, 172 passed.package install:--project integrationoverpackage-install-local-boot-steps.integration.test.tsandpackage-install-local-handlers.integration.test.ts, which spawnos package installagainst a live runtime: 2 files, 24 passed.pnpm --filter @objectstack/cli run typecheck(tsc --noEmitandcheck:test-typecheck, whose program includestest/**): exit 0. The test layer's ledger is unchanged (3 files, 28 errors, 6 pinned signatures).adcc2d77f2:node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 66 commands, and all 66 exited 0. Four of them first answeredPREREQUISITE NOT MET(exit 3) because the tree had nodist/:check:dual-build-cjs-loads,check:i18n,check:i18n-coverageandcheck:i18n-walk-parity. Afterturbo 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.pnpm lint, on HEADadcc2d77f2. I raneslint --no-inline-config --format json(thepnpm lintbinary 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.mjsnever enables type-aware linting (noparserOptions.project, noprojectService, 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 atryis outside the analyzer, which is seeded withexitonly. Over the whole population onf9a8eb889e, three such calls sit inside atry.compile.ts:1046is in the samecatchblock as athis.exit(1)the pin already judges green.init.ts:1359andinit.ts:1388sit under an outercatchthat re-reports them. Measured at the public door:os init demo -p npmwith an unreachable registry printed✗ Project scaffolded, but dependency installation failed., then✗ Dependency installation failedfrom thatcatch, thenError: Dependency installation failedon 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 signaccepts a non-Ed25519 key and labels the resulted25519:. With an RSA key it exits 0 and writesed25519:default:followed by a 342-character signature; an Ed25519 key gives 86 characters. The contract inpackages/core/src/security/plugin-artifact-signature.tsreads "This is the CANONICAL Ed25519 detached-signature contract".signPayloadandverifyPayloadboth passnullas the algorithm and never check the key type. This is outside this card; it is reported, not fixed.bin/run-dev.js), not a builtdist/.Generated by Claude Code