Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/reference/12-state-machine.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(`<record>/.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` はその状態からの移行経路です:

```
bun .claude/tools/amadeus-advisory-choice.ts recover-schema-1 \
[--project-dir <path>]
```

対象は **単一** の 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 は拒否を差し控えるだけで、許可を与えることはありません。結果は変化した内容を明示します: `receipts_dropped`、`re_presentation_required`(open な advisory を salvage しなかった場合は false — store は次に備えて正常化されるだけです)、そして `formal_check_attempts_reset`(形式検査ルートの試行番号は、いま破棄された `run-now` receipt から導出されるため)。

### Scope and configuration

| Event | Emitter | Notes |
Expand Down
43 changes: 43 additions & 0 deletions docs/reference/12-state-machine.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,49 @@ 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 (`<record>/.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:

```
bun .claude/tools/amadeus-advisory-choice.ts recover-schema-1 \
[--project-dir <path>]
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

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, and a receipt too malformed to show one
only ever withholds a refusal, never grants a permission. 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 |
Expand Down
24 changes: 21 additions & 3 deletions packages/framework/core/knowledge/amadeus-shared/audit-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,11 @@ is.
Formal-model-check checkpoint choices use an authoritative side ledger at
`<record>/.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;
Expand All @@ -100,6 +102,22 @@ 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 — and its 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 |
Expand Down
107 changes: 107 additions & 0 deletions packages/framework/core/tools/amadeus-advisory-choice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1453,6 +1453,111 @@ 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. A receipts-only store has no pending row to carry
// the owner, which is exactly where the pending check goes vacuous and the whole
// content of the store is about to be thrown away. A receipt too malformed to
// show an intent run yields nothing here — this read only ever adds a refusal,
// never a permission, so an unreadable receipt cannot widen what is allowed.
function foreignReceiptIntentRuns(receipts: readonly unknown[], intentRun: string): string[] {
return receipts.flatMap((receipt) => {
const identity = isPlainObject(receipt) ? receipt.identity : undefined;
const owner = isPlainObject(identity) ? identity.intentRun : undefined;
return nonEmptyString(owner) && owner !== intentRun ? [owner] : [];
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// #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<AdvisoryStoreRecovery> {
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})`,
};
}
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.
Expand Down Expand Up @@ -1517,9 +1622,11 @@ const USAGE = [
"Usage:",
" amadeus-advisory-choice.ts record --advisory-instance <id> --choice <run-now|defer-with-risk> [--project-dir <path>]",
" amadeus-advisory-choice.ts correct-misattributed --advisory-instance <id> --human-turn <sha256> [--project-dir <path>]",
" amadeus-advisory-choice.ts recover-schema-1 [--project-dir <path>]",
].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") {
Expand Down
11 changes: 11 additions & 0 deletions tests/.coverage-patch-allowlist.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<module>",
"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"
}
]
1 change: 1 addition & 0 deletions tests/integration/t-coverage-mechanism-ratchet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading