diff --git a/docs/reference/12-state-machine.ja.md b/docs/reference/12-state-machine.ja.md index 10a91da317..2675a5fa3b 100644 --- a/docs/reference/12-state-machine.ja.md +++ b/docs/reference/12-state-machine.ja.md @@ -284,6 +284,23 @@ bun .claude/tools/amadeus-advisory-choice.ts record \ `correct-misattributed` は唯一の取消経路であり、あらゆる側から囲われています。対象は `run-now` receipt に限り、それを根拠づける対応提示が存在しないときに限り、かつその試行に対するモデル検査エビデンスが存在しないときに限ります。receipt は削除されず、理由 `misattributed-unpresented-choice` とともに revoked として印されます。これらの経路はすべて audit ロック下で走ります。 +#### store の schema と移行経路 + +choice store(`/.amadeus-advisory-choice.json`)は **schema 2** です。schema 1 の receipt は provenance が裸の `humanTurn` でしたが、schema 2 は **provenance union** — `{ kind: "human-turn", … }` または `{ kind: "auto-decision", … }` — を持ちます。これにより、人間経路と autonomy ladder の無人経路を1つの受理関数が覆います。pending advisory は移行しておらず、schema 2 の store の中でも `schema: 1` のままです。 + +ディスク上の schema 1 store は **読み替えません**。parse に失敗し、各リーダーはそれを fail-closed な hold に変えます — union のもとで `humanTurn` だけの receipt が何を意味するかを推測しないためです。この拒否は正しいのですが、それ単体では行き止まりでもあります。移行前の store を持つ intent では `report` が `advisory choice evidence is invalid: …` を返し続け、どの回答もそれを解消できません。 + +`recover-schema-1` はその状態からの移行経路です: + +```sh +bun .claude/tools/amadeus-advisory-choice.ts recover-schema-1 \ + [--project-dir ] +``` + +対象は **単一** の store — アクティブ intent のもの、または `--project-dir` が指すもの — に限られます。pending advisory を schema 2 store と同じパーサで salvage し、schema 1 の receipt は翻訳せず **破棄** し、schema 2 の store を書きます。破棄は代償ではなく目的です。receipt を持たない advisory はチェックポイントが再び問うものであり、それは fail-closed hold が意図していた「人間にもう一度聞く」と同じ状態だからです。 + +書き込みの前に、store がアクティブ intent のものであることを検査し、そうでなければ何も変えずに loud に拒否します — 古い intent カーソル越しに辿り着いた store を事故で空にすることはありません。検査は salvage した pending から intent run を読み、pending が1件も無い場合は receipt から読みます。receipt しか無い store は、まさに pending の検査が空振りしつつ中身の全部が破棄されようとしている場合だからです。receipt から intent run を読むのは安全のための読み取りであって翻訳ではありません — その receipt が何を意味したかは一切解釈しません。intent run を読めない receipt もまた回復を拒否します。receipt しか無い経路では所有者を名指す pending が存在しないため、その receipt を読み飛ばすことは「誰のものか確認しないまま削除する」ことに等しく、沈黙は帰属の証拠にならないからです。結果は変化した内容を明示します: `receipts_dropped`、`re_presentation_required`(open な advisory を salvage しなかった場合は false — store は次に備えて正常化されるだけです)、そして `formal_check_attempts_reset`(形式検査ルートの試行番号は、いま破棄された `run-now` receipt から導出されるため)。 + ### Scope and configuration | Event | Emitter | Notes | diff --git a/docs/reference/12-state-machine.md b/docs/reference/12-state-machine.md index dd1717c85c..dfc3eb646b 100644 --- a/docs/reference/12-state-machine.md +++ b/docs/reference/12-state-machine.md @@ -316,6 +316,51 @@ grounds it, and only when no model-check evidence exists for that attempt. It marks the receipt revoked with the reason `misattributed-unpresented-choice` rather than deleting it. All of these paths run under the audit lock. +#### Store schemas and the migration path + +The choice store (`/.amadeus-advisory-choice.json`) is at **schema 2**. +Schema 1 held a receipt whose provenance was a bare `humanTurn`; schema 2 holds +a **provenance union** — either `{ kind: "human-turn", … }` or +`{ kind: "auto-decision", … }` — so one acceptance function covers both the +human route and the autonomy ladder's unattended route. Pending advisories did +not migrate and are still `schema: 1` inside a schema 2 store. + +A schema 1 store on disk is **not** translated. It fails to parse, and each +reader turns that into a fail-closed hold rather than guessing what a +`humanTurn`-only receipt means under the union. That refusal is correct but, on +its own, terminal: an intent whose store predates the migration sees `report` +answer `advisory choice evidence is invalid: …` with no answer able to clear it. + +`recover-schema-1` is the migration path out of that state: + +```sh +bun .claude/tools/amadeus-advisory-choice.ts recover-schema-1 \ + [--project-dir ] +``` + +It operates on **one** store — the active intent's, or the one the +`--project-dir` names. It salvages the pending advisories through the same +parser a schema 2 store uses, **discards** the schema 1 receipts instead of +translating them, and writes a schema 2 store. Discarding is the point rather +than a cost: an advisory with no receipt is one the checkpoint asks again, which +is the same "ask the human again" the fail-closed hold intended. + +Before writing anything it checks that the store belongs to the active intent +and refuses loudly, changing nothing, when it does not — a store reached through +a stale intent cursor is never emptied by accident. The check reads the intent +run off the salvaged pending, and off the receipts when there is no pending row +to carry it: a receipts-only store is exactly the case where the pending check +would be vacuous and the whole content is about to be discarded. Reading an +intent run off a receipt is a safety read, not a translation — nothing about +what the receipt meant is interpreted. A receipt whose intent run cannot be read +refuses the recovery too: on the receipts-only path there is no pending row to +name the owner, so passing that receipt over would delete it without ever +establishing whose it was, and silence is not evidence of belonging. The outcome +names what changed: `receipts_dropped`, `re_presentation_required` (false when +no open advisory was salvaged — the store is simply normalised for whatever +comes next), and `formal_check_attempts_reset`, since the attempt a formal-check +route is numbered by is derived from the `run-now` receipts now gone. + ### Scope and configuration | Event | Emitter | Notes | diff --git a/packages/framework/core/knowledge/amadeus-shared/audit-format.md b/packages/framework/core/knowledge/amadeus-shared/audit-format.md index 6354216446..47046136e9 100644 --- a/packages/framework/core/knowledge/amadeus-shared/audit-format.md +++ b/packages/framework/core/knowledge/amadeus-shared/audit-format.md @@ -88,9 +88,11 @@ is. Formal-model-check checkpoint choices use an authoritative side ledger at `/.amadeus-advisory-choice.json`, written atomically under the audit lock. Each pending row binds plugin/code, checkpoint, target, spec identity, -intent run, and advisory instance. Each receipt adds the canonical choice and -the exact physical `HUMAN_TURN` coordinates: shard, timestamp, and SHA-256 of -the event record. The choice is accepted only when the immediately preceding +intent run, and advisory instance. Each receipt adds the canonical choice and a +**provenance union** naming how the choice earned the right to exist: a +`human-turn` arm carrying the exact physical `HUMAN_TURN` coordinates (shard, +timestamp, and SHA-256 of the event record), or an `auto-decision` arm carrying +the autonomy ladder's decision id and basis. The choice is accepted only when the immediately preceding interaction decision is the tool-validated advisory presentation for those exact instances. A correction may mark a legacy, unpresented run-now receipt as revoked only while the advisory remains open and no model-check evidence exists; @@ -100,6 +102,26 @@ lifecycle; the side ledger supplies the advisory-specific correlation that a general approval event cannot express. Local run-now evidence is retained in the instance-specific `.amadeus-advisory-check/` directory. +The ledger is at schema 2, which is where the provenance union lives; schema 1 +receipts predate it and carried a bare human turn. A schema 1 ledger is never +translated — it fails to parse and every reader falls closed to a hold rather +than guessing what a bare human turn means under the union. The migration path +out is the `recover-schema-1` verb of `tools/amadeus-advisory-choice.ts`: for +one named ledger it salvages the pending rows (still `schema: 1` by design, +inside a schema 2 ledger), discards the schema 1 receipts rather than +translating them, and writes schema 2. Discarding is what makes the advisories +unanswered again, which is the "ask the human again" the fail-closed hold +already stood for. It refuses loudly and writes nothing when the ledger names an +intent run other than the active one — read off the salvaged pending rows, or +off the receipts when there is no pending row to carry it, so a receipts-only +ledger is not left undefended at the moment its whole content is discarded. On +that receipts-only path a receipt whose intent run cannot be read refuses the +recovery as well: with no pending row to name the owner, passing such a receipt +over would delete it without ever establishing whose it was, and silence is not +evidence of belonging. The outcome +reports the receipts dropped, whether re-presentation is required, and the +formal-check attempt counts that reset with the discarded run-now receipts. + ### Initialization Events (3 events — fire IN ADDITION TO `STAGE_COMPLETED`) | Event | When | Required | Optional | Emitter | diff --git a/packages/framework/core/tools/amadeus-advisory-choice.ts b/packages/framework/core/tools/amadeus-advisory-choice.ts index ede36f93e5..8995fba013 100644 --- a/packages/framework/core/tools/amadeus-advisory-choice.ts +++ b/packages/framework/core/tools/amadeus-advisory-choice.ts @@ -1453,6 +1453,126 @@ export function revokeMisattributedAdvisoryChoice( }); } +export type AdvisoryStoreRecovery = { + readonly pendingSalvaged: number; + readonly receiptsDropped: number; + readonly rePresentationRequired: boolean; + readonly formalCheckAttemptsReset: number; +}; + +// The ONE thing a discarded legacy receipt is read for, and it is a count, not a +// meaning: the formal-check attempt a route is numbered by is +// `receipts.filter(choice === "run-now").length` everywhere it is computed, so +// this is how far those counters wind back. A receipt too malformed to show a +// choice simply does not count — it is being thrown away either way, and +// guessing at it is the exact thing ADR-9 refuses. +function droppedRunNowCount(receipts: readonly unknown[]): number { + return receipts.filter((receipt) => isPlainObject(receipt) && receipt.choice === "run-now").length; +} + +// The second thing a discarded legacy receipt is read for, and it is likewise +// not a meaning: WHOSE it is. `undefined` means the receipt does not say — which +// is NOT the same as saying it belongs here. +function receiptIntentRun(receipt: unknown): string | undefined { + const identity = isPlainObject(receipt) ? receipt.identity : undefined; + const owner = isPlainObject(identity) ? identity.intentRun : undefined; + return nonEmptyString(owner) ? owner : undefined; +} + +function foreignReceiptIntentRuns(receipts: readonly unknown[], intentRun: string): string[] { + return receipts.flatMap((receipt) => { + const owner = receiptIntentRun(receipt); + return owner !== undefined && owner !== intentRun ? [owner] : []; + }); +} + +// A receipts-only store has no pending row to name the owner, which is exactly +// where the pending check goes vacuous and the entire content of the store is +// about to be deleted. Skipping a receipt whose identity cannot be read looks +// conservative and is the opposite: it deletes that receipt without ever +// establishing whose it was. Silence is not evidence of belonging, so an +// unreadable owner refuses the recovery rather than being passed over. +function ownershipIsUnverifiable(receipts: readonly unknown[], pendingCount: number): boolean { + return pendingCount === 0 && receipts.some((receipt) => receiptIntentRun(receipt) === undefined); +} + +// #2330. ADR-9's refusal to read a schema 1 store is kept exactly as it is — +// parseStore is untouched and every reader still fails closed on the old shape. +// What was missing is the way OUT of that state, and this is it: the pending +// advisories are salvaged through the same parsePending every schema 2 store +// goes through (pending has been schema 1 since the beginning and did not +// migrate), and the receipts are DISCARDED rather than translated. Discarding +// them is not data loss to be minimised — it is ADR-9's "ask the human again" +// finally being reachable, because an advisory with no receipt is an advisory +// the checkpoint will put to the human once more. +export function recoverSchema1AdvisoryStore(projectDir: string): ParseResult { + return withAuditLock(projectDir, () => { + const path = storePath(projectDir); + if (!existsSync(path)) return { ok: false as const, reason: "advisory choice store does not exist" }; + let raw: unknown; + try { + raw = JSON.parse(readFileSync(path, "utf-8")); + } catch (error) { + return { ok: false as const, reason: `advisory choice store is unreadable: ${String(error)}` }; + } + if (!isPlainObject(raw) || !Array.isArray(raw.pending) || !Array.isArray(raw.receipts)) { + return { ok: false as const, reason: "advisory choice store shape is invalid" }; + } + if (raw.schema !== 1) { + return { ok: false as const, reason: `advisory choice store is not schema 1 (schema: ${JSON.stringify(raw.schema)})` }; + } + const pending: PendingAdvisory[] = []; + for (const item of raw.pending) { + const parsed = parsePending(item); + if (!parsed.ok) return parsed; + pending.push(parsed.value); + } + const intentRun = intentRunIdentity(projectDir); + if (intentRun === null) return { ok: false as const, reason: "active intent is unresolved" }; + const foreign = pending.find((item) => item.identity.intentRun !== intentRun); + const foreignOwner = foreign?.identity.intentRun ?? foreignReceiptIntentRuns(raw.receipts, intentRun)[0]; + if (foreignOwner !== undefined) { + return { + ok: false as const, + reason: `advisory choice store does not belong to the active intent (store: ${foreignOwner}, active: ${intentRun})`, + }; + } + if (ownershipIsUnverifiable(raw.receipts, pending.length)) { + return { ok: false as const, reason: "advisory choice store ownership cannot be verified: a receipt carries no readable intent run" }; + } + writeStore(projectDir, { schema: 2, pending, receipts: [] }); + return { + ok: true as const, + value: { + pendingSalvaged: pending.length, + receiptsDropped: raw.receipts.length, + rePresentationRequired: pending.some((item) => item.closedAt === undefined), + formalCheckAttemptsReset: droppedRunNowCount(raw.receipts), + }, + }; + }); +} + +// The whole command, seam-first: the module-level dispatch arm is one call and +// a process.exit, so everything a spawned run does — the outcome, the JSON, the +// stream each line goes to, the exit code — is driven in-process by t470 rather +// than only through a subprocess the coverage run cannot see. +export function recoverSchema1AdvisoryStoreCli(projectDir: string): number { + const result = recoverSchema1AdvisoryStore(projectDir); + if (!result.ok) { + console.error(result.reason); + return 1; + } + console.log(JSON.stringify({ + recovered: true, + pending_salvaged: result.value.pendingSalvaged, + receipts_dropped: result.value.receiptsDropped, + re_presentation_required: result.value.rePresentationRequired, + formal_check_attempts_reset: result.value.formalCheckAttemptsReset, + })); + return 0; +} + // C16 (#2253 FR-ADV-1). A hold reaches here only after guardAdvisoryChoices has // already released its lock, so the ladder and the acceptance below run in their // own sections rather than nested inside the guard's. @@ -1517,9 +1637,11 @@ const USAGE = [ "Usage:", " amadeus-advisory-choice.ts record --advisory-instance --choice [--project-dir ]", " amadeus-advisory-choice.ts correct-misattributed --advisory-instance --human-turn [--project-dir ]", + " amadeus-advisory-choice.ts recover-schema-1 [--project-dir ]", ].join("\n"); if (import.meta.main) { + if (process.argv[2] === "recover-schema-1") process.exit(recoverSchema1AdvisoryStoreCli(resolve(cliFlag(process.argv.slice(2), "--project-dir") ?? process.cwd()))); const args = process.argv.slice(2); const subcommand = args[0]; if (subcommand !== "correct-misattributed" && subcommand !== "record") { diff --git a/tests/.coverage-patch-allowlist.json b/tests/.coverage-patch-allowlist.json index a5ba9135a1..bc5d130b16 100644 --- a/tests/.coverage-patch-allowlist.json +++ b/tests/.coverage-patch-allowlist.json @@ -6480,5 +6480,16 @@ }, "reason": "Legacy git ls-tree non-zero status arm for a missing events path. Modern git returns status 0 with empty stdout for an absent path (covered by t433); the status!==0 branch is retained fail-closed for older git contracts.", "expiry": "remove when the gate drops compatibility with git versions that non-zero on missing path trees" + }, + { + "file": "packages/framework/core/tools/amadeus-advisory-choice.ts", + "selector": { + "function": "", + "fingerprint": "sha256:7dabf7a349909bbd5729e309e392b8d400828e0522cdab3ea16429753273a1a4", + "anchorLines": 1, + "targetLines": "1" + }, + "reason": "The recover-schema-1 dispatch arm is a single call plus process.exit inside import.meta.main, so it is only ever executed by a spawned CLI run that bun --coverage does not measure. Everything it decides — the outcome, the JSON payload, the stream each line goes to and the exit code — lives in recoverSchema1AdvisoryStoreCli, which t470 drives in-process; t470 also spawns the real CLI so the arm itself is proven wired.", + "expiry": "remove when the patch gate measures import.meta.main dispatch arms or the arm moves out of the main block" } ] diff --git a/tests/integration/t-coverage-mechanism-ratchet.test.ts b/tests/integration/t-coverage-mechanism-ratchet.test.ts index c9bd348039..99ca49aef4 100644 --- a/tests/integration/t-coverage-mechanism-ratchet.test.ts +++ b/tests/integration/t-coverage-mechanism-ratchet.test.ts @@ -218,6 +218,7 @@ describe("repository-wide mechanism honesty ratchets", () => { "integration/t464-session-takeover-seam.integration.test.ts", "integration/t46-parallel-bolt.test.ts", "integration/t47-failure-injection.test.ts", + "integration/t470-advisory-store-recovery.integration.test.ts", "integration/t48-runtime-graph-end-to-end.test.ts", "integration/t49-bolt-sensor-failures.test.ts", "integration/t99-learnings-gate-flow.test.ts", diff --git a/tests/integration/t470-advisory-store-recovery.integration.test.ts b/tests/integration/t470-advisory-store-recovery.integration.test.ts new file mode 100644 index 0000000000..838080c0a8 --- /dev/null +++ b/tests/integration/t470-advisory-store-recovery.integration.test.ts @@ -0,0 +1,427 @@ +// covers: file:packages/framework/core/tools/amadeus-advisory-choice.ts +// size: medium +// +// t470 — recovering a schema 1 advisory choice store (#2330). +// +// #2253 moved the store to schema 2 and deliberately refused to translate the +// old shape: a schema 1 store fails to parse, and every reader turns that into a +// fail-closed hold (ADR-9, pinned by t458). That is the right refusal, but it +// left an intent whose store predates the migration with no way out at all — +// `report` answers `advisory choice evidence is invalid: ...` forever, and no +// human answer can clear it because the store the answer would be written to is +// the unreadable one. +// +// The recovery verb is the exit. It reads the schema 1 store WITHOUT reusing +// parseStore, salvages the pending advisories through the unchanged parsePending +// (pending has been schema 1 all along), DISCARDS the receipts rather than +// guessing what a `humanTurn`-only receipt means under the provenance union, and +// writes a schema 2 store. Discarding the receipts is what makes the advisories +// unanswered again — which is exactly ADR-9's "ask the human again", now +// reachable instead of merely correct. +// +// What is asserted here: +// +// FR-2.1 pending is salvaged, receipts are dropped, the store is schema 2. +// FR-2.2 one store only — the one the caller named. +// FR-2.3 a store whose pending belongs to another intent is refused, loudly +// and without a byte of change (#2352 defence). +// FR-2.4 the outcome names the dropped receipts, whether the advisories must +// be presented again, and the formal-check attempts that reset. +// AC-2c the report-side block is gone afterwards: an open advisory is asked +// again, and a store that had no open advisory returns to normal flow. + +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { + advisoryReportHoldReason, + guardAdvisoryChoices, + recoverSchema1AdvisoryStore, + recoverSchema1AdvisoryStoreCli, + type AdvisoryChoiceStore, + type PendingAdvisory, +} from "../../packages/framework/core/tools/amadeus-advisory-choice.ts"; +import { docsRoot } from "../../packages/framework/core/tools/amadeus-lib.ts"; +import type { Advisory } from "../../packages/framework/core/tools/amadeus-plugin-activation.ts"; +import { + cleanupTestProject, + createTestProject, + FIXTURES_DIR, + seedStateFile, +} from "../harness/fixtures.ts"; +import { resetOtelPerProject } from "../harness/otel-reset.ts"; + +const STAGE = "requirements-analysis"; + +const TOOL = join( + import.meta.dir, + "..", + "..", + "packages", + "framework", + "core", + "tools", + "amadeus-advisory-choice.ts", +); + +const advisory: Advisory = { + plugin: "formal-model-check", + code: "changed", + message: "advisory: formal-model-check spec hash CHANGED", + stage: STAGE, + target: "specs/tla", + specIdentity: "sha256:abc", +}; + +function storePath(projectDir: string): string { + return join(docsRoot(projectDir), ".amadeus-advisory-choice.json"); +} + +function readRawStore(projectDir: string): Record { + return JSON.parse(readFileSync(storePath(projectDir), "utf-8")) as Record; +} + +function writeRawStore(projectDir: string, store: unknown): void { + writeFileSync(storePath(projectDir), `${JSON.stringify(store, null, 2)}\n`); +} + +// A receipt exactly as builds before #2253 wrote it: schema 1, and a bare +// `humanTurn` where the union now lives. Nothing in the recovery path is allowed +// to interpret this shape beyond counting it. +function legacyReceipt(pending: PendingAdvisory, choice: string): Record { + return { + schema: 1, + identity: pending.identity, + choice, + humanTurn: { + timestamp: "2026-08-01T00:00:00.000Z", + shard: "legacy-shard", + eventIdentity: "0".repeat(64), + }, + recordedAt: "2026-08-01T00:00:00.000Z", + }; +} + +// The store as an intent that never migrated has it: the pending the guard +// itself wrote, demoted to schema 1, carrying legacy receipts. +function seedSchema1Project(choices: readonly string[]): { projectDir: string; pending: PendingAdvisory } { + const projectDir = createTestProject(); + seedStateFile(projectDir, join(FIXTURES_DIR, "state-mid-inception.md")); + expect(guardAdvisoryChoices(projectDir, STAGE, [advisory]).kind).toBe("hold"); + const store = readRawStore(projectDir); + const pending = (store.pending as PendingAdvisory[])[0]!; + store.schema = 1; + store.receipts = choices.map((choice) => legacyReceipt(pending, choice)); + writeRawStore(projectDir, store); + return { projectDir, pending }; +} + +const projects: string[] = []; +function track(value: T): T { + projects.push(value.projectDir); + return value; +} +afterEach(() => { + resetOtelPerProject(); + for (const dir of projects.splice(0)) cleanupTestProject(dir); +}); + +describe("advisory store recovery: salvage (FR-2.1)", () => { + test("schema 1のstoreはpendingを保ったままschema 2へ回復しreceiptsは破棄される", () => { + const { projectDir, pending } = track(seedSchema1Project(["run-now", "run-now"])); + + const result = recoverSchema1AdvisoryStore(projectDir); + + expect(result.ok).toBe(true); + if (!result.ok) return; + const store = readRawStore(projectDir) as unknown as AdvisoryChoiceStore; + expect(store.schema).toBe(2); + expect(store.receipts).toHaveLength(0); + expect(store.pending).toHaveLength(1); + expect(store.pending[0]).toEqual(pending); + }); +}); + +describe("advisory store recovery: outcome contract (FR-2.4)", () => { + test("回復結果はdropped件数・再提示の要否・リセットされた形式検査回数を明示する", () => { + const { projectDir } = track(seedSchema1Project(["run-now", "defer-with-risk", "run-now"])); + + const result = recoverSchema1AdvisoryStore(projectDir); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value).toEqual({ + pendingSalvaged: 1, + receiptsDropped: 3, + rePresentationRequired: true, + // The attempt counter every formal-check route is numbered by is derived + // from the run-now receipts an advisory holds, so dropping them takes two + // attempts back to zero. Counting is all the recovery does with a legacy + // receipt — nothing is read from it for meaning. + formalCheckAttemptsReset: 2, + }); + }); +}); + +describe("advisory store recovery: misdirection defence (FR-2.3 / AC-2b)", () => { + test("pendingが別intentのものならloudに拒否しstoreを1バイトも変えない", () => { + const { projectDir } = track(seedSchema1Project(["run-now"])); + const store = readRawStore(projectDir); + const pending = store.pending as PendingAdvisory[]; + pending[0]!.identity.intentRun = "019ffffff-dead-7000-b000-000000000000"; + writeRawStore(projectDir, store); + const before = readFileSync(storePath(projectDir), "utf-8"); + + const result = recoverSchema1AdvisoryStore(projectDir); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toContain("does not belong to the active intent"); + expect(readFileSync(storePath(projectDir), "utf-8")).toBe(before); + }); + + // A receipts-only store has no pending row to carry the owner's identity, so + // the pending check above is vacuous exactly where the whole content of the + // store is about to be thrown away. The receipts carry an identity too, and + // reading the intent run off one is a safety read, not a translation: nothing + // about what the receipt MEANT is interpreted, and the receipt is discarded + // either way. + test("pendingが0件でもreceiptsが別intentのものならloudに拒否される", () => { + const { projectDir } = track(seedSchema1Project(["run-now"])); + const store = readRawStore(projectDir); + const receipts = store.receipts as { identity: { intentRun: string } }[]; + receipts[0]!.identity = { ...receipts[0]!.identity, intentRun: "019ffffff-dead-7000-b000-000000000000" }; + store.pending = []; + writeRawStore(projectDir, store); + const before = readFileSync(storePath(projectDir), "utf-8"); + + const result = recoverSchema1AdvisoryStore(projectDir); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toContain("does not belong to the active intent"); + expect(readFileSync(storePath(projectDir), "utf-8")).toBe(before); + }); + + // Ignoring a receipt whose identity cannot be read looks conservative and is + // the opposite: with no pending row to name the owner, an unreadable receipt + // is a receipt about to be deleted with its owner never established. Silence + // is not evidence of belonging, so the unverifiable case refuses too. + test("pendingが0件でreceiptの所有者を読めない場合は所有者未確認として拒否される", () => { + const { projectDir } = track(seedSchema1Project(["run-now"])); + const store = readRawStore(projectDir); + const receipts = store.receipts as Record[]; + delete (receipts[0]!.identity as Record).intentRun; + store.pending = []; + writeRawStore(projectDir, store); + const before = readFileSync(storePath(projectDir), "utf-8"); + + const result = recoverSchema1AdvisoryStore(projectDir); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toContain("ownership cannot be verified"); + expect(readFileSync(storePath(projectDir), "utf-8")).toBe(before); + }); +}); + +describe("advisory store recovery: receipts-only store (AC-2a)", () => { + test("pendingが0件のstoreはschema 2へ正常化され再提示は不要と示される", () => { + const { projectDir } = track(seedSchema1Project(["defer-with-risk"])); + const seeded = readRawStore(projectDir); + seeded.pending = []; + writeRawStore(projectDir, seeded); + + const result = recoverSchema1AdvisoryStore(projectDir); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value).toEqual({ + pendingSalvaged: 0, + receiptsDropped: 1, + rePresentationRequired: false, + formalCheckAttemptsReset: 0, + }); + const store = readRawStore(projectDir) as unknown as AdvisoryChoiceStore; + expect(store).toEqual({ schema: 2, pending: [], receipts: [] }); + }); + + test("閉じたpendingしか残っていないstoreも再提示不要として扱われる", () => { + const { projectDir } = track(seedSchema1Project(["run-now"])); + const seeded = readRawStore(projectDir); + (seeded.pending as PendingAdvisory[])[0]!.closedAt = "2026-08-02T00:00:00.000Z"; + writeRawStore(projectDir, seeded); + + const result = recoverSchema1AdvisoryStore(projectDir); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.pendingSalvaged).toBe(1); + expect(result.value.rePresentationRequired).toBe(false); + }); +}); + +describe("advisory store recovery: refusals", () => { + test("既にschema 2のstoreは回復対象ではないとしてloudに拒否される", () => { + const projectDir = track({ projectDir: createTestProject() }).projectDir; + seedStateFile(projectDir, join(FIXTURES_DIR, "state-mid-inception.md")); + expect(guardAdvisoryChoices(projectDir, STAGE, [advisory]).kind).toBe("hold"); + const before = readFileSync(storePath(projectDir), "utf-8"); + + const result = recoverSchema1AdvisoryStore(projectDir); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toContain("not schema 1"); + expect(readFileSync(storePath(projectDir), "utf-8")).toBe(before); + }); + + // The two arms below guard the read itself rather than the store's content, + // so they are reached before any advisory is understood. Both are driven + // through the exported seam in-process: the spawned CLI would exercise the + // same code, but bun --coverage does not attribute a child process to the + // parent report, and an arm nothing measures is an arm nothing protects. The + // reason string is asserted exactly, because it is the only thing that + // distinguishes which of the refusals actually ran. + test("JSONとして読めないstoreはunreadableとして拒否され破棄されない", () => { + const { projectDir } = track(seedSchema1Project(["run-now"])); + writeFileSync(storePath(projectDir), "{broken"); + const before = readFileSync(storePath(projectDir), "utf-8"); + + const result = recoverSchema1AdvisoryStore(projectDir); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toStartWith("advisory choice store is unreadable:"); + expect(readFileSync(storePath(projectDir), "utf-8")).toBe(before); + }); + + test("pending/receiptsが配列でないstoreはshape不正として拒否され破棄されない", () => { + const { projectDir } = track(seedSchema1Project(["run-now"])); + writeRawStore(projectDir, { schema: 1, pending: "not-array", receipts: [] }); + const before = readFileSync(storePath(projectDir), "utf-8"); + + const result = recoverSchema1AdvisoryStore(projectDir); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toBe("advisory choice store shape is invalid"); + expect(readFileSync(storePath(projectDir), "utf-8")).toBe(before); + }); + + test("pendingが壊れているstoreはfail-closedで拒否され破棄されない", () => { + const { projectDir } = track(seedSchema1Project(["run-now"])); + const store = readRawStore(projectDir); + (store.pending as Record[])[0]!.message = ""; + writeRawStore(projectDir, store); + const before = readFileSync(storePath(projectDir), "utf-8"); + + const result = recoverSchema1AdvisoryStore(projectDir); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toContain("pending message/createdAt is invalid"); + expect(readFileSync(storePath(projectDir), "utf-8")).toBe(before); + }); +}); + +describe("advisory store recovery: report closure (AC-2c)", () => { + test("回復前はschema不正でreportが塞がれ、回復後は同じadvisoryが再提示される", () => { + const { projectDir, pending } = track(seedSchema1Project(["run-now"])); + + expect(advisoryReportHoldReason(projectDir, STAGE)).toContain("advisory choice evidence is invalid"); + + expect(recoverSchema1AdvisoryStore(projectDir).ok).toBe(true); + + // The block is no longer about the store's schema: it is the advisory + // itself, unanswered, which is the state a human can act on. + expect(advisoryReportHoldReason(projectDir, STAGE)).toBe( + `unresolved advisory choice: ${pending.identity.plugin}/${pending.identity.code}/${pending.identity.advisoryInstance}`, + ); + }); + + test("pendingが0件のstoreは回復後にreportのブロックが消える", () => { + const { projectDir } = track(seedSchema1Project(["run-now"])); + const seeded = readRawStore(projectDir); + seeded.pending = []; + writeRawStore(projectDir, seeded); + + expect(advisoryReportHoldReason(projectDir, STAGE)).toContain("advisory choice evidence is invalid"); + expect(recoverSchema1AdvisoryStore(projectDir).ok).toBe(true); + expect(advisoryReportHoldReason(projectDir, STAGE)).toBeNull(); + }); +}); + +describe("advisory store recovery: CLI surface (FR-2.5)", () => { + function captured(run: () => number): { code: number; out: string[]; err: string[] } { + const out: string[] = []; + const err: string[] = []; + const log = spyOn(console, "log").mockImplementation((line: unknown) => void out.push(String(line))); + const error = spyOn(console, "error").mockImplementation((line: unknown) => void err.push(String(line))); + try { + return { code: run(), out, err }; + } finally { + log.mockRestore(); + error.mockRestore(); + } + } + + test("回復に成功したCLIは1行のJSONを出しexit 0を返す", () => { + const { projectDir } = track(seedSchema1Project(["run-now", "defer-with-risk"])); + + const result = captured(() => recoverSchema1AdvisoryStoreCli(projectDir)); + + expect(result.code).toBe(0); + expect(result.err).toEqual([]); + expect(result.out).toHaveLength(1); + expect(JSON.parse(result.out[0]!)).toEqual({ + recovered: true, + pending_salvaged: 1, + receipts_dropped: 2, + re_presentation_required: true, + formal_check_attempts_reset: 1, + }); + }); + + test("拒否されたCLIは理由をstderrへ出しexit 1を返す", () => { + const { projectDir } = track(seedSchema1Project(["run-now"])); + const store = readRawStore(projectDir); + (store.pending as PendingAdvisory[])[0]!.identity.intentRun = "019ffffff-dead-7000-b000-000000000000"; + writeRawStore(projectDir, store); + + const result = captured(() => recoverSchema1AdvisoryStoreCli(projectDir)); + + expect(result.code).toBe(1); + expect(result.out).toEqual([]); + expect(result.err.join("\n")).toContain("does not belong to the active intent"); + }); + + test("spawnしたCLIのrecover-schema-1腕が実際に配線されている", () => { + const { projectDir } = track(seedSchema1Project(["run-now"])); + + const spawned = spawnSync( + process.execPath, + [TOOL, "recover-schema-1", "--project-dir", projectDir], + { encoding: "utf8", env: { ...process.env } }, + ); + + expect(spawned.status).toBe(0); + expect(JSON.parse(spawned.stdout ?? "")).toMatchObject({ recovered: true, receipts_dropped: 1 }); + expect((readRawStore(projectDir) as unknown as AdvisoryChoiceStore).schema).toBe(2); + }); + + test("未知のsubcommandは従来どおりusageを出して拒否される", () => { + const { projectDir } = track(seedSchema1Project(["run-now"])); + + const spawned = spawnSync( + process.execPath, + [TOOL, "recover-schema-2", "--project-dir", projectDir], + { encoding: "utf8", env: { ...process.env } }, + ); + + expect(spawned.status).toBe(1); + expect(spawned.stderr ?? "").toContain("recover-schema-1"); + }); +});