Skip to content

fix(cli): os init and os compile render each refusal once, not once on stdout and again as oclif's Error block (#21542) - #21560

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21542-one-rendering-per-refusal
Oct 3, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21542-one-rendering-per-refusal

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21542

Clause-②: no

os init and os compile printed ten refusals twice: once as the command's own ✗ line on stdout, and again as oclif's Error: block on stderr, because the command handed the same sentence to this.error. Each now prints its ✗ line and the hint under it once, and ends in this.exit(2), the status this.error raised. Stdout is unchanged; stderr no longer repeats it; every exit status is still 2.

The shape, and why it is this one

Triage's ruling asks for one shape at every site, through the split isReportedError guards, with no second mechanism and no per-site variant. The shape the family's text faces already use is: the command renders its own refusal with printError, and ends in this.exit(n). validate, info, diff, lint, verify, i18n check, i18n extract, generate and migrate meta all end that way, and isReportedError is the guard a catch-all uses for a refusal a helper already wrote to stderr. This PR applies that shape at all ten sites. No helper is added, and utils/format.ts is read, not edited.

  • Which stream the one copy is on. The dispatch read the split as "the human text goes on stderr once". That is the helper half of it: printErrorToStderr's docblock says a command that has already decided it renders the text face keeps printError on stdout, and stderr is for shared paths that cannot see --json (resolveConfigPath). os init declares no --json at all, and os compile's --json branch returns above every site here. So the surviving copy stays where the ✗ line and its hint always were. Stdout already held everything stderr carried, so nothing is lost.
  • Why init.ts's catch-all has no isReportedError guard. compile.ts's catch-all already carries it, because resolveConfigPath() can reach it. Only ConfigRefusalError (config.ts) and the SDUI manifest error (sdui-manifest.ts) carry the marker, and nothing in init.ts's try reaches either: it imports neither, and validateScaffold has its own loader. A guard there would be a branch no run can take.
  • Why exit 2. this.error(msg) exits 2 and this.exit(2) is the same status. The dispatch's A4 and the ruling's second pin both say the statuses stay, and the platform checklist already names oclif's 2 on os compile's human path as legitimate.

Population: a symbol walk, not a grep

ts-morph over packages/cli/src/commands at fd5a1cd597 with the type checker: calls resolving to oclif's Command.error, and calls resolving to printError or printErrorToStderr in utils/format.ts, grouped by outermost function.

  • 16 this.error call sites in 5 files; 57 command files call a refusal printer. Functions holding both: 2, init.ts run() and compile.ts run().
  • Those two hold 10 this.error sites: the card's 8, plus init's scaffold self-test and dependency install refusals inside its try. The ruling's third bullet makes any pair beyond the 8 ride this landing, and they do.
  • The three datasource commands (introspect, list-tables, validate) hold the other 6 this.error sites and call no refusal printer. Their detail lines go through this.log and the one sentence is oclif's, so they are not pairs.
  • After this PR the same walk finds 0 functions holding both, and 6 this.error sites in 3 files.

Readings at the public door

Spawned bin/run-dev.js (the source entry; it shares the published entry's oclif handle() and flush()) from a scratch directory, before and after. Before, the source entry prints oclif's stack beneath the same sentence; the published entry prints › Error: …, as the card measured it.

os init demo -t bogus      before: stdout "  ✗ Unknown template: bogus" + "  Available: app, plugin, empty"
                                   stderr "Error: Unknown template: bogus" + stack      exit 2
                           after:  stdout the same two lines                            exit 2, stderr empty
os compile  (config throws at load)
                           before: stdout "  ✗ probe: the config module threw at load"
                                   stderr "Error: probe: the config module threw at load" + stack   exit 2
                           after:  stdout the same line                                 exit 2, stderr empty

All ten sites, each reached through a real run (fixtures are in the e2e pin's header). Copies of the sentence across both streams, and the exit status:

Site Reached by Copies Exit
init: unknown template init demo -t bogus 2 to 1 2 to 2
init: invalid project name init Bad_Name 2 to 1 2 to 2
init: target not empty init demo over a non-empty demo/ 2 to 1 2 to 2
init: current directory name invalid init in a directory named Bad Cwd 2 to 1 2 to 2
init: config already exists init beside an objectstack.config.ts 2 to 1 2 to 2
init: scaffold self-test rejects (in the try) init demo -p npm, stub npm that installs nothing 2 to 1 2 to 2
init: dependency install fails (in the try) init demo -p npm, stub npm exiting 1 2 to 1 2 to 2
init: catch-all init --no-install with a file named src 2 to 1 2 to 2
compile: runtime bundle refused compile --runtime-bundle, config importing ./helper that only helper.jsx satisfies 2 to 1 2 to 2
compile: catch-all compile, config throwing at load 2 to 1 2 to 2

os build extends Compile and has no refusal of its own, so it inherits both compile rows. The published bin/run.js, built from this tree, was run for the init unknown-template and compile catch-all rows too: exit 2, stdout once, stderr empty.

Pins

  • packages/cli/test/refusal-renders-once.e2e.test.ts (nightly): spawns the CLI through each of the ten sites and asserts the exit status is 2, the refusal's subject occurs once across both streams, exactly one ✗ line is printed, and the hint under it survives. Every spawn is paid in beforeAll; no case is clocked.
  • packages/cli/test/refusal-renders-once.test.ts (queue, unit): the structural half over every command module. No function that calls a refusal printer (an identifier bound by an import from utils/format.js, aliases followed) also calls this.error. Nine fixtures pin the scan, and three floors keep it from going vacuous (60 command modules, 50 files calling a printer, 3 files raising this.error), set under what the symbol walk counted on this tree: 65, 57 and 3. The population is discovered, so the next command that repeats the shape is in it.
  • packages/cli/test/exit-signal.pin.test.ts: its this.error real-site anchor followed the sites to this.exit(2) (init's two in-try refusals, compile's and build's bundling refusal). SITE_FLOOR stays 131 (both spellings are seeds), and the pin stays green; the this.error seed itself is still covered by its fixtures.

Reverse verification

Run on the committed fix, through scripts/ablation-replace.mjs in wrap mode under the lock, restoring one site's pairing at a time. Both halves read the source (bin/run-dev.js runs from src/, the structural pin reads text), so no dist/ is on the path and no rebuild leg applies.

  • Leg A, init.ts unknown-template site back to this.error(...): anchor 1 to 0, blob 929642fba1ff to edab7c113e65. Structural pin red (1 failed, naming init.ts run()); e2e red on exactly that case (1 failed, 9 passed: "expected 2 to be 1"); exit-signal pin green. Restore proven: blob equals HEAD (929642fba1ff) and git diff HEAD is empty.
  • Leg B, compile.ts runtime-bundle site back to this.error(err.message): anchor 1 to 0, blob 25d3ea870831 to 42e44c372e8e. Structural pin red (2 failed: the pair, and the exit-signal anchor "each ends in this.exit(2)"); e2e red on exactly the bundling case (1 failed, 9 passed). Restore proven: blob equals HEAD (25d3ea870831), git diff HEAD empty, git status clean.
  • Direction observed: red in both legs, the normal one.

Verification

At d83eb007b0 unless noted; every heavy run went through os-verify-lock.sh.

  • pnpm --filter @objectstack/cli typecheck: exit 0 (tsc --noEmit, and check:test-typecheck OK with its ledger unchanged: 3 files, 28 errors).
  • Unit: refusal-renders-once.test.ts, exit-signal.pin.test.ts and vitest-tiers-partition.test.ts: 3 files, 142 tests passed. Driven pin with OS_TEST_TIERS=nightly: 10 of 10 passed.
  • Every test file that drives os init, os compile or os build, selected by import of the command module or by spawn argv (run at 228259e373; the later commit changes one comment line in a test file): 31 queue-tier files, 485 passed and 6 failed, all 6 in published-subpath-console.pin.test.ts. Those six failed because this worktree's packages/cli/dist had been built with OS_SKIP_DTS=1 and carried no declarations; after a declaration build the file passed 14 of 14. 18 nightly e2e files with OS_TEST_TIERS=nightly: 210 passed.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, no paths, at d83eb007b0: 66 derived, all 66 run. Three first answered exit 3 or 124 for environment reasons (check:dual-build-cjs-loads and check:i18n-coverage said PREREQUISITE NOT MET because packages outside the cli closure had no dist/; check:type-check-debt hit my own 420 s cap). After building the missing packages with declarations and a longer cap, all three exited 0. --ran: 66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN.
  • Lint, as a proven narrowing: ESLint with inline config disabled over the 5 touched TypeScript files, population read from ESLint's own config (isPathIgnored and calculateConfigForFile): 5 files in the JSON results, 0 errors, 0 warnings. The .changeset file is outside ESLint's population. Type-aware linting is off for each file (no parserOptions.project; the config header says it is never enabled), and the diff touches no ESLint config, so it cannot move a verdict on an untouched file. Whole-repo pnpm lint is left to CI.
  • Not re-derived on a fresh tree: the branch is based on fd5a1cd597 and origin/main is 7 commits ahead. The derivation tool reports one input changed upstream, scripts/engine-double-contract.pinned.json (the check:engine-double-contract family, which ran green here; this diff adds no fake engine). The upstream commits touch packages/cli only under migrate/ and two utils, and add no printError or this.error line under src/commands.

Acceptance notes

  • The declaration in utils/format.ts above CliExitCode reads "The only two exit codes this CLI has: 0 success, 1 failure". Ten refusals exit 2, as they always did, and the platform checklist (docs/qa/platform-checklist/areas/cli.json) blesses oclif's 2 on os compile's human path. This PR keeps 2, as the ruling's pin says. Noted, not filed: the docblock's scope is the emitJson slot's type.
  • The same checklist file describes that exit 2 as coming from compile.ts's this.error() in seven strings. The status it blesses is unchanged; only the named mechanism now reads stale. Not edited here (outside this card's file surface, and the file carries a revision history of its own).
  • os compile's catch-all still answers exit 1 for a refusal a helper already wrote (the isReportedError branch) and exit 2 for any other. That asymmetry predates this PR and is kept.

Generated by Claude Code

claude added 3 commits October 3, 2026 06:23
…n stdout and again as oclif's Error block

Ten sites printed their sentence with printError and then handed it to
this.error, which has oclif's entry point render it a second time on stderr.
Each now ends in this.exit(2): the status this.error raised, with no second
rendering.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…and keeps exit 2

A structural half over every command module (no refusal printer paired with
this.error) and a driven half that spawns the CLI through all ten sites. The
exit-signal pin's this.error anchor follows the sites to this.exit(2).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…exit-signal pin's comment

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
@github-actions github-actions Bot added the size/l label Oct 3, 2026
@github-actions github-actions Bot added 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 2 documentable anchor(s).

20 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 10454b3afa94d49e6e424cc16fbbff3a898f3ad8.

⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

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 10454b3afa94d49e6e424cc16fbbff3a898f3ad8 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 10454b3afa94d49e6e424cc16fbbff3a898f3ad8

⚠️ 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 10454b3afa94d49e6e424cc16fbbff3a898f3ad8 → 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 07:59
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 3, 2026 07:59
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 3, 2026
Merged via the queue into main with commit bf36edd Oct 3, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21542-one-rendering-per-refusal branch October 3, 2026 08:30
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…no database yet with empty work (objectstack-ai#21552) (objectstack-ai#21570)

Fixes objectstack-ai#21552
Clause-②: no

On a project whose database does not exist yet, `os migrate
account-issuer`, `os migrate audit-metadata-bodies`, `os migrate meta
--stored`, `os secret orphans`, `os secret rewrap` and `os storage
orphans` now answer with empty work and exit 0. Before, each exited 1
from its own first read of a table its read-only boot had deferred. This
completes the family that PR objectstack-ai#21550 started for `resume`, `recorded-by`
and `value-shapes`, under the same ruling (`5965283666`, applied to
these six doors by triage's `5966537984`): prefer "not asked"; where a
read cannot be avoided, recognise its refusal only with
`isMissingTableError` and only for the command's own table.

## Measured at the public door

Fixture: one artifact with two app objects, `--database-url file:` a
path that does not exist, run as `node packages/cli/bin/run-dev.js ...
--json`. Base `f83d0669d7` (origin/main when the branch was cut), head
`491087ee0f` (the `src` of the six doors is the same at the current head
`69a1689f00`; the patch round touched two test files only).
`files-to-references`, `summary-nulls`, `multi-value-columns` and
`duplicates` are the controls; `duplicates` has no `--json` flag and
always prints its one document.

| command (absent database) | base | head |
|:--|:--|:--|
| `migrate account-issuer` | exit 1, `RESOURCE_CONFLICT`, "Cannot
enumerate sys_account" | exit 0, `scanned: 0, ok: true` |
| `migrate audit-metadata-bodies` | exit 1, `failures: 3`
(`sys_audit_log`, `sys_activity`, `sys_metadata_audit`) | exit 0,
`failures: 0` |
| `migrate meta --stored` | exit 1, `DATABASE_ERROR` for `sys_metadata`
| exit 0, `scanned: 0, clean: true` |
| `secret orphans` | exit 1, `scan_failed` for `sys_secret` | exit 0,
`counts.total: 0`, all three families `enumerated` |
| `secret rewrap` | exit 1, `scan_failed` for `sys_secret` | exit 0,
`counts.total: 0`, all three families `enumerated` |
| `storage orphans` | exit 1, `DATABASE_ERROR` for `sys_file` | exit 0,
`filesScanned: 0, stranded: 0` |
| the four controls | exit 0 | exit 0 |
| database file left behind | none | none |

Each of the six names, on stderr under `--json` and on stdout in human
mode, the tables it read as no rows.

## The boot path of each door (what the seam can and cannot say)

All six boot through `bootSchemaStack` with `deferSchemaDdl` and
`readOnlyProbe`, so `SchemaStack.tableAbsent` is there to ask. Measured
per door:

- **Five ask it** (`audit-metadata-bodies`, `meta --stored`, `secret
orphans`, `secret rewrap`, `storage orphans`) before the first read. A
shared helper, `packages/cli/src/utils/absent-table-reads.ts`, is the
one place a door asks. It adds no second mechanism: it wraps
`tableAbsent`, and a refused read of an ordinary table is still issued
and still refused.
- **`account-issuer` cannot ask.** Its boot composes no auth plugin, so
`sys_account` is not a registered object and the held-back sync never
lists it: `tableAbsent('sys_account')` is false on every database. It is
the `isMissingTableError` case the ruling names: the probe's read is not
avoidable, and the door reads that one refusal, for `sys_account` only,
as no rows. Registering the object would not be a fix: the probe selects
the retired `issuer` column, which a registered `sys_account` no longer
declares.
- **`meta --stored`** hands its read to the metadata protocol, which
holds a private engine this command cannot wrap. The preview answers an
absent `sys_metadata` itself with the report the protocol returns for
zero rows, typed as `StoredMigrationReport` so a new field is a compile
error here.
- **`secret orphans` and `secret rewrap`** read at driver level and
through the reference union. The union is given a read-only view of the
engine (`secretUnionReadView`): only `find` is carried onto the driver,
so `--delete` and `--apply` take their write verbs from the unwrapped
driver, and `listDatasourceDefs` stays absent when the engine has none
(its absence is a declared gap).

## The table the boot cannot measure: `sys_activity`

The control for `audit-metadata-bodies` showed
`tableAbsent('sys_activity')` true on a booted database. `sys_activity`
is rotation-managed: its rows live in `sys_activity__rYYYYMMDD` shard
tables and its base name is a view. The deferred sync asks the driver
`hasTable` for the base name, a view is not a table, so on SQLite it
lists the base as `create_table` over a database that serves it. A door
that believed it would have answered "Nothing to rewrite" over the
cleartext credential copies the command exists to reach.

So `absentTableReads` takes a schema lookup, and for a rotation-managed
object it does not consult the measurement: the read is issued, and only
its missing-table refusal (`isMissingTableError`, for that object) reads
as no rows. The control pins it: a cleartext copy seeded into the
rotation shard is found and counted (`sys_activity.rewritten: 1`). The
seam itself is untouched here (see Out of scope).

## Documented exit (A3)

- `migrate account-issuer`: its own description, "exits non-zero when
the drop must not proceed". A database with no account table has no
collision, and a booted database holding two clean accounts answers `ok:
true` with exit 0.
- `migrate audit-metadata-bodies`, `migrate meta --stored`: the platform
checklist's migrate item (`docs/qa/platform-checklist/areas/cli.json`:
"EVERY migrate subcommand with --json exits 0 on success"); `meta
--stored` also documents "A second pass reporting every row canonical
exits 0".
- `secret orphans`, `secret rewrap`, `storage orphans`: report-only by
their own documentation ("Report-only by default: without `--delete` it
writes nothing", "A dry run by default", "Writes nothing"); their report
mode exits 0 whenever the read succeeded.
- Each is also the exit the same door gives the booted control (below).

`content/docs/deployment/cli.mdx` said the opposite in two places: the
Data migrations edge paragraph ("Another dry run that reads a missing
table can still fail and exit 1") and `os secret orphans` ("it refuses
and exits 1 ... `scan_failed`"). Both now describe the empty-work
answer, and keep the refusal for any other read that fails.

## Tests

- **Integration pin, extended:**
`packages/cli/src/commands/migrate/data-commands.absent-database.integration.test.ts`
gains a second block. For each of the six, on the absent database:
`--json` exit 0 with the empty-work document, no refused read of its own
tables, the table named on stderr; human mode exit 0 on the empty-work
sentence; no database file created. The control is a booted database
holding one row of work per door (two legacy `sys_account` rows, a
cleartext audit copy in `sys_audit_log` and in the `sys_activity` shard,
a stored metadata row, an unreferenced `sys_secret` row, a stranded
`sys_file`): each door READS it, reports the row, and says nothing about
absent tables. The four doors that already exited 0 are pinned to still
do. Every spawn runs in `beforeAll`.
- **Two refusal pins flipped:** `preview-read-only.integration.test.ts`
pinned exit 1 for `meta --stored` and `audit-metadata-bodies` on a
missing database (the refusal objectstack-ai#21391 declared). That is the behaviour
the ruling reverses for these doors; both cases now assert exit 0 and
the empty-work document, with the file header updated.
- **Unit:** `packages/cli/src/utils/absent-table-reads.test.ts`, 17
cases: the helper's answers, the stderr/stdout split, and the rotation
rule.
- **Patch round 1: two stale test doubles.** CI Test Core (1/6) was red
at `491087ee0f` in `rewrap.guards.test.ts`. That suite, and
`orphans.guards.test.ts`, replace `bootSchemaStack` and hand the command
a stack of only `kernel` and `shutdown`. The doors' first ask is
`stack.tableAbsent`, a member of `SchemaStack` since PR objectstack-ai#21550, so every
case fell into the `scan_failed` catch. Root cause read off the case's
own output (a throwaway copy of the suite that printed the payload):
`{"error":"scan_failed","message":"stack.tableAbsent is not a
function"}`, in all five cases of `rewrap.guards.test.ts`. Run locally
against the unfixed head, the two suites had 7 red cases (5 in
`rewrap.guards`, 2 in `orphans.guards`); CI listed four. The fix is on
the double's side: both doubles now carry `tableAbsent` as the real boot
returns it (`false` for every table on the plain boot of a writing run;
`rewrap.guards` takes the measured-absent set as an option). No
consumer-side tolerance was added. `rewrap.guards.test.ts` also gains
one case: a dry run over tables the boot measured absent issues no read,
reports `counts.total: 0` with every family `enumerated`, and a
present-table control still reads. The stack double in
`test/exit-signal.pin.test.ts` (`stackWith`) has no `tableAbsent`
either, and no path that pin drives reaches it (`recorded-by --apply`
and `resume --run` short-circuit before the ask, and `account-issuer`
does not use the seam), so it is left as it is.
- **Test files that mock `bootSchemaStack` or `schema-migrate.js`**
(`git grep` for `vi.mock` of the module over `packages/cli`):
`rewrap.guards`, `orphans.guards`, `test/exit-signal.pin`,
`files-to-references.column-step-refusal` and `summary-nulls`. The last
two reach other commands, not these doors. The test files that reach a
door through any other seam (a real boot, the command module, or a
source scan) were found by `git grep` for the door names and modules:
`data-commands.absent-database`, `preview-read-only`,
`meta.stored-flow-resolution`, `meta.report-order`, `meta.stored-flags`,
`orphans.driver-contract`, `rewrap.driver-contract`,
`platform-migrations-arming`, `schema-migrate.one-shot-family`,
`resume.recorded-by`, `sys-secret-rewrap`, `one-shot-settings.pin`,
`migrate-meta-engine-guidance`, `migrate-meta-strict-factories`.
- At `69a1689f00` (this branch merged with origin/main at `491087ee0f`,
which brought PR objectstack-ai#21560's refusal pins; no later merge, the gate
derivation does not call the tree stale):
- The 13 files above that run in the package's two projects (the five
mockers and the door-reaching files, `summary-nulls` and
`files-to-references.column-step-refusal` among them), `vitest run
--maxWorkers=2`, both projects: 13 files, 202 tests passed (it was 7
failed, 194 passed before the double fix). 7 of the 13 are
integration-tier.
- The round-1 integration files (`data-commands.absent-database`,
`preview-read-only`, `meta.stored-flow-resolution`,
`platform-migrations-arming`, `schema-migrate.one-shot-family`,
`resume.recorded-by`), `--project integration`: 6 files, 137 passed, 1
skipped (the live PostgreSQL cell, not provisioned here).
- `pnpm --filter @objectstack/cli exec vitest run --project unit
--maxWorkers=2`: 253 files, 3702 passed (after `pnpm --filter
@objectstack/cli build`, which the two `published-subpath-*` pins need).
- `pnpm --filter @objectstack/cli build` and `pnpm --filter
@objectstack/cli typecheck` (test layer included): exit 0.
- **NOT MEASURED: the other 69 files of the cli integration tier** (82
files listed by `vitest list --project integration`; 13 run here: those
7 and the six round-1 files). Reason: the tier runs past the 10-minute
foreground cap here, so the rest is declared to CI. The narrowing is the
`git grep` above: none of the 69 names a door, mocks
`schema-migrate.js`, or imports the new helper. And the two
`*.e2e.test.ts` files that spawn the doors
(`test/migrate-meta.e2e.test.ts`, `test/json-stdout-purity.e2e.test.ts`)
belong to neither of the package's two vitest projects, so no local run
selects them (the runner printed "matches no test file in this
package").

## Reverse verification

Each leg mutated the committed tree through
`scripts/ablation-replace.mjs`: anchor 1 to 0, blob changed, restored to
`HEAD` with an empty `git diff HEAD` and a clean status. The CLI spawns
run `packages/cli/src` through tsx, so there is no dist hop. Two first
attempts were refused by the tool itself because the replacement text
contained the anchor or already occurred in the file; nothing ran, and
each leg was redone with a distinct replacement.

- **Leg A, `tableAbsent` forced false** (`schema-migrate.ts`).
Predicted: every fresh-project pin of the five seam doors red, the
booted controls green, `account-issuer` green (it does not use the
seam). Observed: 19 failed, 25 passed, 1 skipped. The 19 are the 7 pins
PR objectstack-ai#21550 added, the 10 new fresh-project cases (five doors, `--json`
and human) and the two flipped `preview-read-only` cases. Every booted
control and the four exit-0 controls stayed green, and `account-issuer`
stayed green.
- **Leg B, the `isMissingTableError` branch of `account-issuer`
disabled.** Predicted: the two `account-issuer` fresh-project cases red,
nothing else. Observed: 2 failed, 34 passed.
- **Leg C, the rotation rule disabled** (`absent-table-reads.ts`).
Predicted: the audit control red because `sys_activity` is skipped.
Observed: integration 1 failed, 35 passed, with `sys_activity.scanned` 0
where a cleartext copy is stored; the unit file 2 failed, 15 passed.

- **Leg D, patch round: the not-asked answer disabled**
(`absent-table-reads.ts`, `absent()` always false), over
`rewrap.guards.test.ts`. Predicted: only the new absent-tables case red.
Observed: 1 failed, 6 passed. Restored to `HEAD`, empty `git diff HEAD`.

Legs A to C ran at `491087ee0f`; `src` of the doors and the helper is
unchanged since (the round touched two test files).

## Gates

At `69a1689f00`, after the final commit:

- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 94 commands (the same list as at
`491087ee0f`); `--ran`, with an exit code per command, reconciled as "94
run, 0 NOT-MEASURED (a DERIVED zero)". All 94 read exit 0 on this pass,
with every package built first (`turbo run build
--filter='!@objectstack/docs'`). The derivation says no commit it can
see touched what it derives from, so no merge was made.
- **`pnpm lint`, as a proven narrowing.** `eslint --no-inline-config
--format json` on the 12 changed TS files: 12 files, 0 errors, 0
warnings, none reported as ignored. The population comes from
`eslint.config.mjs` (`files: **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`); the
config enables no type-aware linting (no `parserOptions.project`, and
every block's `parserOptions` is `{ ecmaVersion, sourceType }`), so this
diff cannot move the verdict on a file it does not touch. The `.md` and
`.mdx` files are outside it.

## Acceptance notes

- **Out of the card, kept:** every read-only data-command boot of an
absent database still prints `[ObjectQLPlugin] sys_metadata_activation
is registered but could not be read`. Triage ruled it out of this card.
- **A new file outside the claim's list:**
`packages/cli/src/utils/absent-table-reads.ts` (and its unit test). Five
doors need the same view, the same notice line and the same rotation
rule; five inline copies would drift. `schema-migrate.ts` is untouched.
- **A new `this.exit` or `this.error`:** none. The exit-signal pin and
PR objectstack-ai#21560's refusal pin both ran green over the whole command table.
- **Round 1 of the patch:** `rewrap.guards.test.ts` and
`orphans.guards.test.ts` are the two files added to the diff (see
Tests).
- **`value-shapes` (PR objectstack-ai#21550)** reads through the same seam without the
rotation rule. It composes no audit plugin, so none of its scanned
objects is rotation-managed today; noted, not changed.

## Out of scope (reported to the seat, not filed here)

- `SchemaStack.tableAbsent` answers true for a rotation-managed object's
base name on a booted SQLite database, because
`previewDeferredSchemaWork` asks `hasTable` and a view is not a table.
Measured through `os migrate audit-metadata-bodies` against a booted
database whose `sys_activity` is the view over
`sys_activity__rYYYYMMDD`. The same list is what `os migrate plan`
prints for a host that composes the audit plugin (not measured at
`plan`). The fix belongs in the driver's preview, not in a consumer.

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

---------

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

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants