diff --git a/packages/framework/core/hooks/amadeus-mint-presence.ts b/packages/framework/core/hooks/amadeus-mint-presence.ts index 532a950d2..dff84629a 100644 --- a/packages/framework/core/hooks/amadeus-mint-presence.ts +++ b/packages/framework/core/hooks/amadeus-mint-presence.ts @@ -51,7 +51,7 @@ import { } from "../tools/amadeus-lib.ts"; import { choiceFromExactPrompt, - recordProtectedAdvisoryChoice, + recordAdvisoryChoice, } from "../tools/amadeus-advisory-choice.ts"; import { detectHarnessType } from "../tools/amadeus-harness.ts"; import { initProcessObservability } from "../tools/amadeus-observability.ts"; @@ -139,11 +139,13 @@ try { requireReservationRoute: detectHarnessType() === "kimi", ...(context.route === null ? {} : { route: context.route }), }); - if (context.prompt !== null) { + const promptChoice = context.prompt === null ? null : choiceFromExactPrompt(context.prompt); + if (promptChoice !== null) { const turns = findAllEvents(readFileSync(auditFilePath(projectDir), "utf-8"), "HUMAN_TURN"); const latest = turns[turns.length - 1]; if (latest !== undefined) { - recordProtectedAdvisoryChoice(projectDir, context.prompt, { + recordAdvisoryChoice(projectDir, promptChoice, { + kind: "human-turn", timestamp: latest.timestamp, shard: auditShardName(projectDir), eventIdentity: createHash("sha256").update(latest.block).digest("hex"), diff --git a/packages/framework/core/tools/amadeus-advisory-choice.ts b/packages/framework/core/tools/amadeus-advisory-choice.ts index 073c183d2..ede36f93e 100644 --- a/packages/framework/core/tools/amadeus-advisory-choice.ts +++ b/packages/framework/core/tools/amadeus-advisory-choice.ts @@ -29,6 +29,35 @@ import { type Advisory, type AdvisoryCode, } from "./amadeus-plugin-activation.ts"; +import type { + AutoDecisionRecord, + DecisionBasisKind, + EffectClassification, + createInteractionOccurrence as CreateInteractionOccurrence, +} from "./amadeus-intent-autonomy.ts"; +import type { + AutonomyDecisionResult, + IntentAutonomyTransaction, +} from "./amadeus-intent-autonomy-runtime.ts"; +import type { readIntentAutonomyTransactionsFromAudit as ReadAutonomyTransactions } from "./amadeus-intent-autonomy-replay.ts"; +import type { commitProductionQuestionDecision as CommitQuestionDecision } from "./amadeus-intent-autonomy-production.ts"; + +// The autonomy stack is reached only on the unattended paths (C16's ruling and +// the auto arm of acceptance). The UserPromptSubmit mint hook imports THIS +// module on every human prompt and has a sub-300ms budget, so those modules are +// required at the call rather than at load: types above are erased, and the +// three bindings below cost nothing until an advisory is actually resolved. +function autonomyModule(): { createInteractionOccurrence: typeof CreateInteractionOccurrence } { + return require("./amadeus-intent-autonomy.ts"); +} + +function autonomyReplayModule(): { readIntentAutonomyTransactionsFromAudit: typeof ReadAutonomyTransactions } { + return require("./amadeus-intent-autonomy-replay.ts"); +} + +function autonomyProductionModule(): { commitProductionQuestionDecision: typeof CommitQuestionDecision } { + return require("./amadeus-intent-autonomy-production.ts"); +} export const ADVISORY_CHOICE_OPTIONS = [ { choice: "run-now", label: "今すぐ実行する" }, @@ -61,11 +90,39 @@ export type HumanTurnProvenance = { eventIdentity: string; }; +// How a receipt earned the right to exist. ONE acceptance function reads this +// union (#2253 FR-ADV-3): there is no second implementation for the unattended +// route, only a second arm. Each arm carries exactly what its own three +// acceptance checks (grounding, single-spend, presentation) consume — nothing +// decorative. +// +// `phase` and `graphRevision` on the auto arm are NOT trusted assertions: both +// are digest inputs of the occurrence id, so a caller that misstates either one +// produces an occurrence id that cannot match the AUTO_DECIDED record the +// journal holds, and acceptance refuses. That is what binds a decision to THIS +// advisory instance rather than to any decision the ladder ever made. +export type AdvisoryChoiceProvenance = + | { + kind: "human-turn"; + timestamp: string; + shard: string; + eventIdentity: string; + } + | { + kind: "auto-decision"; + decisionId: string; + basisKind: DecisionBasisKind; + basisFingerprint: string; + projectionRevision: number; + phase: string; + graphRevision: string; + }; + export type AdvisoryChoiceReceipt = { - schema: 1; + schema: 2; identity: AdvisoryIdentity; choice: AdvisoryChoice; - humanTurn: HumanTurnProvenance; + provenance: AdvisoryChoiceProvenance; recordedAt: string; revokedAt?: string; revocationReason?: "misattributed-unpresented-choice"; @@ -79,7 +136,7 @@ export type AdvisoryHoldVerdict = | { kind: "hold"; unresolved: PendingAdvisory[] }; export type AdvisoryChoiceStore = { - schema: 1; + schema: 2; pending: PendingAdvisory[]; receipts: AdvisoryChoiceReceipt[]; }; @@ -342,21 +399,35 @@ function parseIdentity(value: unknown): ParseResult { return { ok: true, value: value as unknown as AdvisoryIdentity }; } +function provenanceProblem(value: unknown): string | null { + if (!isPlainObject(value)) return "provenance must be an object"; + if (value.kind === "human-turn") { + if (!nonEmptyString(value.timestamp) || Number.isNaN(Date.parse(value.timestamp))) { + return "provenance.timestamp is invalid"; + } + if (!nonEmptyString(value.shard)) return "provenance.shard is invalid"; + return nonEmptyString(value.eventIdentity) ? null : "provenance.eventIdentity is invalid"; + } + if (value.kind !== "auto-decision") return "provenance kind is invalid"; + if (!nonEmptyString(value.decisionId)) return "provenance.decisionId is invalid"; + if (!nonEmptyString(value.basisKind)) return "provenance.basisKind is invalid"; + if (!nonEmptyString(value.basisFingerprint)) return "provenance.basisFingerprint is invalid"; + if (typeof value.projectionRevision !== "number" || !Number.isInteger(value.projectionRevision)) { + return "provenance.projectionRevision is invalid"; + } + if (!nonEmptyString(value.phase)) return "provenance.phase is invalid"; + return nonEmptyString(value.graphRevision) ? null : "provenance.graphRevision is invalid"; +} + export function parseAdvisoryChoiceReceipt(value: unknown): ParseResult { - if (!isPlainObject(value) || value.schema !== 1) return { ok: false, reason: "receipt schema is invalid" }; + if (!isPlainObject(value) || value.schema !== 2) return { ok: false, reason: "receipt schema is invalid" }; const identity = parseIdentity(value.identity); if (!identity.ok) return identity; if (!nonEmptyString(value.choice) || !CHOICES.has(value.choice)) { return { ok: false, reason: "receipt choice is invalid" }; } - if (!isPlainObject(value.humanTurn)) return { ok: false, reason: "humanTurn must be an object" }; - if (!nonEmptyString(value.humanTurn.timestamp) || Number.isNaN(Date.parse(value.humanTurn.timestamp))) { - return { ok: false, reason: "humanTurn.timestamp is invalid" }; - } - if (!nonEmptyString(value.humanTurn.shard)) return { ok: false, reason: "humanTurn.shard is invalid" }; - if (!nonEmptyString(value.humanTurn.eventIdentity)) { - return { ok: false, reason: "humanTurn.eventIdentity is invalid" }; - } + const provenance = provenanceProblem(value.provenance); + if (provenance !== null) return { ok: false, reason: provenance }; if (!nonEmptyString(value.recordedAt) || Number.isNaN(Date.parse(value.recordedAt))) { return { ok: false, reason: "recordedAt is invalid" }; } @@ -392,6 +463,129 @@ export function createPendingAdvisory( }; } +// --- C16: the unattended resolution of an advisory choice (#2253) --- +// +// An advisory choice is mapped onto the EXISTING `question` interaction kind +// rather than a new one (ADR-6): the ladder, the audit codec, the review queue +// and the scope vocabulary all keep working unchanged. What makes two raises of +// the same advisory distinct is the advisory INSTANCE, carried in both the +// interaction id and the selector. + +export function advisoryInteractionId(identity: AdvisoryIdentity): string { + return `advisory-${identity.advisoryInstance}`; +} + +export function advisorySelector(identity: AdvisoryIdentity): string { + return `advisory:${identity.plugin}:${identity.code}:${identity.advisoryInstance}`; +} + +// FR-ADV-4, PRIMARY mechanism: a run-required advisory offers ONE option, so +// `defer-with-risk` is not something the unattended route declines to pick — it +// is not in the space it picks from. The human route's two-option presentation +// (ADVISORY_CHOICE_OPTIONS) is untouched. +export function advisoryChoiceOptionIds(runRequired: boolean): readonly string[] { + return runRequired ? ["run-now"] : ["run-now", "defer-with-risk"]; +} + +// FR-ADV-4, SECONDARY mechanism: deferring past a raised advisory waives a +// quality signal, and `quality-waiver` is a prohibited effect classification, so +// even a ladder that somehow selected it would be refused at effect +// authorization (amadeus-intent-autonomy.ts authorizeDecisionEffect / +// SemiAuthority.authorizeEffect, both of which admit `workflow-reversible` +// only). Two independent barriers, each with its own falsification. +export const ADVISORY_CHOICE_EFFECT_CLASSIFICATIONS: Readonly> = { + "run-now": "workflow-reversible", + "defer-with-risk": "quality-waiver", +}; + +// --- C17: the acceptance predicates the unattended provenance has to clear --- + +// Grounding, part one: what the journal actually decided. A receipt's own claim +// about a decision id proves nothing; only an AUTO_DECIDED record committed to +// the audit trail does. +export function autoDecisionsFromTransactions( + transactions: readonly IntentAutonomyTransaction[], +): readonly AutoDecisionRecord[] { + return transactions.flatMap((transaction) => + transaction.events.flatMap((event) => (event.type === "AUTO_DECIDED" ? [event.decision] : [])) + ); +} + +// Presentation, unattended side. The human route asks "was this advisory shown +// to the human in this turn?"; the unattended route asks the same question of +// the ladder: is the decision the journal holds the decision for THIS advisory +// instance? The occurrence id is a digest over the interaction id — which +// carries the instance — and over the phase and graph revision, so a decision +// made about anything else cannot be pointed at this advisory by asserting it. +export function advisoryOccurrenceMatchesDecision(input: { + readonly intentUuid: string; + readonly identity: AdvisoryIdentity; + readonly decision: AutoDecisionRecord; + readonly phase: string; + readonly graphRevision: string; +}): boolean { + try { + return autonomyModule().createInteractionOccurrence({ + intentUuid: input.intentUuid, + kind: "question", + stage: input.identity.checkpoint, + phase: input.phase, + bolt: null, + interactionId: advisoryInteractionId(input.identity), + selector: advisorySelector(input.identity), + question: input.decision.question, + optionIds: input.decision.optionIds, + graphRevision: input.graphRevision, + }).occurrenceId === input.decision.occurrenceId; + } catch { + return false; + } +} + +// Single spend, both provenance kinds through one key. A human turn is spent by +// its (shard, event) pair; a ladder decision by its decision id. The keys live +// in disjoint namespaces, so one kind can never consume the other's budget. +function provenanceSpendKey(provenance: AdvisoryChoiceProvenance): string { + return provenance.kind === "human-turn" + ? JSON.stringify(["human-turn", provenance.shard, provenance.eventIdentity]) + : JSON.stringify(["auto-decision", provenance.decisionId]); +} + +export function advisoryProvenanceAlreadySpent( + receipts: readonly AdvisoryChoiceReceipt[], + provenance: AdvisoryChoiceProvenance, +): boolean { + const key = provenanceSpendKey(provenance); + return receipts.some((receipt) => provenanceSpendKey(receipt.provenance) === key); +} + +export type AdvisoryAutoResolution = + | { + readonly kind: "resolved"; + readonly choice: AdvisoryChoice; + readonly decision: AutoDecisionRecord; + readonly projectionRevision: number; + } + | { readonly kind: "human-required"; readonly reason: string }; + +// FR-ADV-2, fail-closed: an allow-list of ONE shape. `decided` AND `run-now` is +// the only way out; everything else — a decided `defer-with-risk`, a park, a +// conflict, an abort, a reservation, an outcome kind that does not exist yet — +// falls to the human route without being enumerated. A new ladder outcome added +// tomorrow is human-required by construction, not by remembering to list it. +export function translateAdvisoryDecision(result: AutonomyDecisionResult): AdvisoryAutoResolution { + if (result.kind !== "decided") return { kind: "human-required", reason: `ladder-outcome-${result.kind}` }; + if (result.decision.selectedOptionId !== "run-now") { + return { kind: "human-required", reason: `unattended-choice-not-run-now:${result.decision.selectedOptionId}` }; + } + return { + kind: "resolved", + choice: "run-now", + decision: result.decision, + projectionRevision: result.receipt.projectionRevision, + }; +} + function identityKey(identity: AdvisoryIdentity): string { return JSON.stringify([ identity.plugin, @@ -456,8 +650,13 @@ function parsePending(value: unknown): ParseResult { return { ok: true, value: value as unknown as PendingAdvisory }; } +// Schema 2 (#2253). A schema 1 store on disk is NOT translated: it fails to +// parse, and the caller's existing `!storeResult.ok` arm turns that into a +// fail-closed hold. Reading an old receipt shape would mean deciding what a +// `humanTurn`-only receipt means under a provenance union, and the safe answer +// to that question is to ask the human again — which the hold already does. function parseStore(value: unknown): ParseResult { - if (!isPlainObject(value) || value.schema !== 1 || !Array.isArray(value.pending) || !Array.isArray(value.receipts)) { + if (!isPlainObject(value) || value.schema !== 2 || !Array.isArray(value.pending) || !Array.isArray(value.receipts)) { return { ok: false, reason: "advisory choice store shape is invalid" }; } const pending: PendingAdvisory[] = []; @@ -472,7 +671,7 @@ function parseStore(value: unknown): ParseResult { if (!parsed.ok) return parsed; receipts.push(parsed.value); } - return { ok: true, value: { schema: 1, pending, receipts } }; + return { ok: true, value: { schema: 2, pending, receipts } }; } function storePath(projectDir: string): string { @@ -481,7 +680,7 @@ function storePath(projectDir: string): string { function readStore(projectDir: string): ParseResult { const path = storePath(projectDir); - if (!existsSync(path)) return { ok: true, value: { schema: 1, pending: [], receipts: [] } }; + if (!existsSync(path)) return { ok: true, value: { schema: 2, pending: [], receipts: [] } }; try { return parseStore(JSON.parse(readFileSync(path, "utf-8"))); } catch (error) { @@ -956,38 +1155,76 @@ function isGroundedHumanTurn(projectDir: string, humanTurn: HumanTurnProvenance) } } -export function recordProtectedAdvisoryChoice( +// Grounding for the unattended arm: the decision id has to name an AUTO_DECIDED +// record the journal holds, AND that record has to be the one this advisory +// instance produces. Both halves are needed — the first stops an invented +// decision id, the second stops a real decision about something else from being +// re-pointed at an advisory. +function groundedAutoDecision( projectDir: string, - prompt: string, - humanTurn: HumanTurnProvenance, + provenance: Extract, + open: readonly PendingAdvisory[], +): boolean { + const intentUuid = activeIntentUuid(projectDir); + if (intentUuid === null) return false; + let decisions: readonly AutoDecisionRecord[]; + try { + decisions = autoDecisionsFromTransactions(autonomyReplayModule().readIntentAutonomyTransactionsFromAudit(projectDir)); + } catch { + return false; + } + const decision = decisions.find((candidate) => candidate.decisionId === provenance.decisionId); + if (decision === undefined) return false; + return open.some((pending) => + advisoryOccurrenceMatchesDecision({ + intentUuid, + identity: pending.identity, + decision, + phase: provenance.phase, + graphRevision: provenance.graphRevision, + }) + ); +} + +// The ONE acceptance function (#2253 FR-ADV-3). Both provenance kinds clear the +// same three checks at the same depth; only what counts as evidence differs. +// There is no second function for the unattended route, so there is no way for +// one route's guarantees to drift away from the other's. +export function recordAdvisoryChoice( + projectDir: string, + choice: AdvisoryChoice, + provenance: AdvisoryChoiceProvenance, now: string = new Date().toISOString(), ): boolean { - const choice = choiceFromExactPrompt(prompt); - if (choice === null) return false; return withAuditLock(projectDir, () => { const storeResult = readStore(projectDir); if (!storeResult.ok) return false; const store = storeResult.value; - if (humanTurn.shard !== auditShardName(projectDir)) return false; - if (!isGroundedHumanTurn(projectDir, humanTurn)) return false; - if (store.receipts.some((receipt) => - receipt.humanTurn.eventIdentity === humanTurn.eventIdentity - && receipt.humanTurn.shard === humanTurn.shard - )) return false; + if (provenance.kind === "human-turn" && provenance.shard !== auditShardName(projectDir)) return false; + // Single spend, hoisted ahead of the kind-specific checks so it holds across + // provenance kinds (FR-ADV-3): one decision, or one turn, backs one receipt. + if (advisoryProvenanceAlreadySpent(store.receipts, provenance)) return false; + // The instance-level gate, also ahead of the kind-specific checks: an + // advisory already answered does not accept a second answer from EITHER + // route until its own evidence says the answer did not settle it. const open = store.pending.filter( (pending) => pending.closedAt === undefined && - Math.floor(Date.parse(humanTurn.timestamp) / 1000) >= Math.floor(Date.parse(pending.createdAt) / 1000) && + (provenance.kind === "auto-decision" || + Math.floor(Date.parse(provenance.timestamp) / 1000) >= Math.floor(Date.parse(pending.createdAt) / 1000)) && acceptsFreshChoice(projectDir, pending, store.receipts), ); if (open.length === 0) return false; - if (!hasMatchingAdvisoryPresentation(projectDir, open, humanTurn)) return false; + if (provenance.kind === "human-turn") { + if (!isGroundedHumanTurn(projectDir, provenance)) return false; + if (!hasMatchingAdvisoryPresentation(projectDir, open, provenance)) return false; + } else if (!groundedAutoDecision(projectDir, provenance, open)) return false; for (const pending of open) { store.receipts.push({ - schema: 1, + schema: 2, identity: pending.identity, choice, - humanTurn, + provenance, recordedAt: now, }); } @@ -1065,7 +1302,7 @@ function activeReceiptFor( // Every condition a FIRST record of an instance has to clear, in one place. // Returns the refusal reason, or null when the choice may be written. The // provenance checks (shard, grounding, single-spend) are the same ones the -// prompt route applies in recordProtectedAdvisoryChoice; only the presentation +// prompt route applies in recordAdvisoryChoice; only the presentation // check is relaxed from adjacency to existence. function freshRecordRefusal( projectDir: string, @@ -1079,11 +1316,9 @@ function freshRecordRefusal( if (!isGroundedHumanTurn(projectDir, humanTurn)) { return "the latest human turn is not grounded in the audit trail"; } - const spent = store.receipts.some((receipt) => - receipt.humanTurn.eventIdentity === humanTurn.eventIdentity - && receipt.humanTurn.shard === humanTurn.shard - ); - if (spent) return "the latest human turn is already consumed by another advisory receipt"; + if (advisoryProvenanceAlreadySpent(store.receipts, { kind: "human-turn", ...humanTurn })) { + return "the latest human turn is already consumed by another advisory receipt"; + } if (!hasRecordedAdvisoryPresentation(projectDir, open)) { const instance = open[0]?.identity.advisoryInstance ?? "this instance"; return `no advisory presentation is recorded for ${instance}; present it before recording the choice`; @@ -1163,10 +1398,10 @@ export function recordAdvisoryChoiceDecision( if (refusal !== null) return { ok: false as const, reason: refusal }; const receipt: AdvisoryChoiceReceipt = { - schema: 1, + schema: 2, identity: open[0]!.identity, choice: choice as AdvisoryChoice, - humanTurn, + provenance: { kind: "human-turn", ...humanTurn }, recordedAt: now, }; store.receipts.push(receipt); @@ -1188,9 +1423,12 @@ export function revokeMisattributedAdvisoryChoice( item.closedAt === undefined && item.identity.advisoryInstance === advisoryInstance ); if (open.length === 0) return { ok: false, reason: "open advisory instance not found" }; + // Correction covers the human route only: an unattended receipt has no + // human turn to have been misattributed to. const matching = storeResult.value.receipts.filter((receipt) => receipt.revokedAt === undefined - && receipt.humanTurn.eventIdentity === humanTurnIdentity + && receipt.provenance.kind === "human-turn" + && receipt.provenance.eventIdentity === humanTurnIdentity && open.some((pending) => identityKey(receipt.identity) === identityKey(pending.identity)) ); const receipt = matching.at(-1); @@ -1200,7 +1438,8 @@ export function revokeMisattributedAdvisoryChoice( const pending = open.find((item) => identityKey(item.identity) === identityKey(receipt.identity)); if (pending === undefined) return { ok: false, reason: "open advisory identity not found" }; if (receipt.choice !== "run-now") return { ok: false, reason: "only run-now receipts can be corrected" }; - if (hasMatchingAdvisoryPresentation(projectDir, [pending], receipt.humanTurn)) { + if (receipt.provenance.kind !== "human-turn") return { ok: false, reason: "matching latest receipt not found" }; + if (hasMatchingAdvisoryPresentation(projectDir, [pending], receipt.provenance)) { return { ok: false, reason: "receipt is grounded in a matching advisory presentation" }; } const attempt = matching.filter((item) => item.choice === "run-now").length; @@ -1214,6 +1453,61 @@ export function revokeMisattributedAdvisoryChoice( }); } +// 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. +// +// Every advisory in the hold is put to the ladder separately, and ALL of them +// must come back `run-now`: one advisory the ladder will not decide keeps the +// whole checkpoint with the human, which is the same thing the human route does +// (one answer covers the whole presented set, or none of it does). +// +// The ruling itself is NOT re-implemented here — commitProductionQuestionDecision +// is the one path a question travels, so semi and full reach the ladder through +// the same authorization they always did. +export function resolveAdvisoryChoiceAutonomously(input: { + readonly projectDir: string; + readonly hold: Extract; + readonly phase: string; + readonly graphRevision: string; +}): AdvisoryAutoResolution { + if (input.hold.advisories.length === 0) return { kind: "human-required", reason: "empty-advisory-hold" }; + const optionIds = advisoryChoiceOptionIds(input.hold.runRequired); + let first: Extract | null = null; + for (const item of input.hold.advisories) { + const identity: AdvisoryIdentity = { + plugin: item.plugin, + code: item.code, + checkpoint: item.checkpoint, + target: item.target, + specIdentity: item.spec_identity, + intentRun: item.intent_run, + advisoryInstance: item.advisory_instance, + }; + let outcome: AutonomyDecisionResult; + try { + outcome = autonomyProductionModule().commitProductionQuestionDecision({ + projectDir: input.projectDir, + stage: item.checkpoint, + phase: input.phase, + graphRevision: input.graphRevision, + questionId: advisoryInteractionId(identity), + selector: advisorySelector(identity), + question: item.message, + optionIds, + recommendedOptionId: "run-now", + effectClassifications: ADVISORY_CHOICE_EFFECT_CLASSIFICATIONS, + }); + } catch (error) { + return { kind: "human-required", reason: `advisory-decision-failed:${String(error)}` }; + } + const translated = translateAdvisoryDecision(outcome); + if (translated.kind !== "resolved") return translated; + first ??= translated; + } + return first ?? { kind: "human-required", reason: "empty-advisory-hold" }; +} + function cliFlag(args: string[], name: string): string | null { const index = args.indexOf(name); return index >= 0 && index + 1 < args.length ? args[index + 1]! : null; @@ -1246,16 +1540,15 @@ if (import.meta.main) { console.error(recorded.reason); process.exit(1); } + const bound = recorded.value.receipt.provenance; console.log(JSON.stringify({ recorded: true, idempotent: recorded.value.idempotent, advisory_instance: advisoryInstance, choice: recorded.value.receipt.choice, - human_turn: { - shard: recorded.value.receipt.humanTurn.shard, - event_identity: recorded.value.receipt.humanTurn.eventIdentity, - timestamp: recorded.value.receipt.humanTurn.timestamp, - }, + human_turn: bound.kind === "human-turn" + ? { shard: bound.shard, event_identity: bound.eventIdentity, timestamp: bound.timestamp } + : null, })); process.exit(0); } diff --git a/packages/framework/core/tools/amadeus-intent-autonomy-production.ts b/packages/framework/core/tools/amadeus-intent-autonomy-production.ts index 7c6bfcb1a..f8e08a594 100644 --- a/packages/framework/core/tools/amadeus-intent-autonomy-production.ts +++ b/packages/framework/core/tools/amadeus-intent-autonomy-production.ts @@ -23,6 +23,7 @@ import { type AutonomyProjection, type DecisionPolicyInput, type DecisionFact, + type EffectClassification, type GrantScopeDescriptor, type HumanAutonomyCommand, type InteractionKind, @@ -70,7 +71,10 @@ const ALL_INTERACTIONS: readonly InteractionKind[] = [ "question", ]; -const PROHIBITED_EFFECTS = [ +// Exported so a caller that builds its own option effects can prove, in a test +// it owns, that the classification it assigns to a refusable option is still +// one this scope forbids (#2253 FR-ADV-4 secondary barrier). +export const PROHIBITED_EFFECTS = [ "new-permission", "irreversible", "scope-out", @@ -541,6 +545,13 @@ export interface ProductionQuestionDecisionInput { readonly applicableNormFacts?: readonly DecisionFact[]; readonly pastHumanRulings?: readonly DecisionFact[]; readonly election?: { readonly optionId: string; readonly evidenceFingerprint: string }; + // Per-option effect classification. A question whose options are all ordinary + // workflow moves needs none — the default is `workflow-reversible`, which is + // what every caller before #2253 relied on. A caller whose option space + // contains a move that WAIVES something (advisory `defer-with-risk`) names it + // here, and effect authorization then refuses that option on its own, without + // this adapter growing a policy branch. + readonly effectClassifications?: Readonly>; } export function commitProductionQuestionDecision(input: ProductionQuestionDecisionInput): AutonomyDecisionResult { @@ -570,7 +581,7 @@ export function commitProductionQuestionDecision(input: ProductionQuestionDecisi optionId, payload, payloadFingerprint: autonomyDigest(payload), - classification: "workflow-reversible" as const, + classification: input.effectClassifications?.[optionId] ?? ("workflow-reversible" as const), requiredScopeFingerprint: scopeFingerprint, applicableNormFingerprint: normFingerprint, }; diff --git a/packages/framework/core/tools/amadeus-orchestrate.ts b/packages/framework/core/tools/amadeus-orchestrate.ts index 90f4343bd..7af3eb7f0 100644 --- a/packages/framework/core/tools/amadeus-orchestrate.ts +++ b/packages/framework/core/tools/amadeus-orchestrate.ts @@ -102,6 +102,8 @@ import { advisoryReportHoldReason, closeAdvisoryInstancesForStage, guardAdvisoryChoices, + recordAdvisoryChoice, + resolveAdvisoryChoiceAutonomously, } from "./amadeus-advisory-choice.ts"; import { buildIntentSelectionSnapshot, @@ -795,13 +797,43 @@ function applyPendingAdvisoryGuard(directive: Directive): Directive { const pending = takePendingAdvisories(); if (pending.length === 0) return directive; if (directive.kind !== "run-stage" && directive.kind !== "dispatch-subagent") return directive; + const advisoryProjectDir = resolveProjectDir(_handlerProjectDir); const guard = guardAdvisoryChoices( - resolveProjectDir(_handlerProjectDir), + advisoryProjectDir, directive.stage, pending, pluginActivationHostRoot(), ); if (guard.kind === "allow") return directive; + // #2253 FR-ADV-1/2. A hold is offered to the autonomy ladder before it is + // turned into a question for the human. There are exactly TWO ways out: the + // ladder decided `run-now` and the receipt was accepted, in which case the + // ORIGINAL directive is returned untouched and the run continues unattended; + // or anything else at all — no grant, an expired one, a scope that does not + // cover this interaction, a park, a conflict, a deferral, a refused receipt — + // in which case the human is asked, exactly as before. Two branches is the + // whole fail-closed argument: there is no third place to land. + const graphRevision = autonomyDigest(loadGraph()); + const auto = resolveAdvisoryChoiceAutonomously({ + projectDir: advisoryProjectDir, + hold: guard, + phase: directive.phase, + graphRevision, + }); + if ( + auto.kind === "resolved" + && recordAdvisoryChoice(advisoryProjectDir, auto.choice, { + kind: "auto-decision", + decisionId: auto.decision.decisionId, + basisKind: auto.decision.basisKind, + basisFingerprint: auto.decision.basisFingerprint, + projectionRevision: auto.projectionRevision, + phase: directive.phase, + graphRevision, + }) + ) { + return directive; + } const choiceDirective: AwaitAdvisoryChoiceDirective = { kind: "await-advisory-choice", stage: guard.stage, diff --git a/packages/framework/harness/codex/hooks/amadeus-codex-adapter.ts b/packages/framework/harness/codex/hooks/amadeus-codex-adapter.ts index a1c165151..cd77cea1e 100644 --- a/packages/framework/harness/codex/hooks/amadeus-codex-adapter.ts +++ b/packages/framework/harness/codex/hooks/amadeus-codex-adapter.ts @@ -63,7 +63,7 @@ import { isMachineInjectedTurnText, stateFilePath, } from "../tools/amadeus-lib.ts"; -import { recordProtectedAdvisoryChoice } from "../tools/amadeus-advisory-choice.ts"; +import { choiceFromExactPrompt, recordAdvisoryChoice } from "../tools/amadeus-advisory-choice.ts"; import { hostSessionCapability, mintHumanPresence } from "../tools/amadeus-presence-reservation.ts"; import { spawnHookWithRuntime } from "./amadeus-codex-hook-runtime.ts"; @@ -387,11 +387,13 @@ switch (target) { projectDir, capability: hostSessionCapability(codex.session_id), }); - if (typeof codex.prompt === "string") { + const promptChoice = typeof codex.prompt === "string" ? choiceFromExactPrompt(codex.prompt) : null; + if (promptChoice !== null) { const turns = findAllEvents(readFileSync(auditFilePath(projectDir), "utf-8"), "HUMAN_TURN"); const latest = turns[turns.length - 1]; if (latest !== undefined) { - recordProtectedAdvisoryChoice(projectDir, codex.prompt, { + recordAdvisoryChoice(projectDir, promptChoice, { + kind: "human-turn", timestamp: latest.timestamp, shard: auditShardName(projectDir), eventIdentity: createHash("sha256").update(latest.block).digest("hex"), diff --git a/tests/.coverage-patch-allowlist.json b/tests/.coverage-patch-allowlist.json index 594f2e3cb..f7e28643d 100644 --- a/tests/.coverage-patch-allowlist.json +++ b/tests/.coverage-patch-allowlist.json @@ -3844,6 +3844,28 @@ "reason": "Defensive startup-failure classification for git show. The integration suite drives the observable non-zero lookup failure; making spawnSync itself fail to start would require removing the running test process's git executable or injecting a process port. The distinct INTERNAL_ERROR branch is retained fail closed.", "expiry": "remove when baselineAtRevision receives an injected process port that can model startup failure" }, + { + "file": "packages/framework/core/tools/amadeus-advisory-choice.ts", + "selector": { + "function": "advisoryOccurrenceMatchesDecision", + "fingerprint": "sha256:799d1c5f4401c6ade45fffbe154d25ef353610a8f9fd6261328a51ecdd9fa451", + "anchorLines": 5, + "targetLines": "1-5" + }, + "reason": "These lines contain only the multiline TypeScript input type. The function body — the true, false, and fail-closed catch outcomes — is covered directly by t459 unit tests, but Bun emits zero-hit DA records for the type-only signature only in the merged suite.", + "expiry": "remove when the patch gate excludes type-only lines or Bun stops emitting DA records for them" + }, + { + "file": "packages/framework/core/tools/amadeus-advisory-choice.ts", + "selector": { + "function": "resolveAdvisoryChoiceAutonomously", + "fingerprint": "sha256:0a9e7bf8c6eaf43ece44a49989a8bb312185246b2baab4538c033935c5d94a9e", + "anchorLines": 4, + "targetLines": "1-4" + }, + "reason": "These lines contain only the multiline TypeScript input type. The function body — the resolved, human-required, and decision-failure catch outcomes — is covered directly by t458 integration tests, but Bun emits zero-hit DA records for the type-only signature only in the merged suite.", + "expiry": "remove when the patch gate excludes type-only lines or Bun stops emitting DA records for them" + }, { "file": "packages/framework/core/tools/amadeus-advisory-choice.ts", "selector": { @@ -6304,7 +6326,7 @@ "anchorLines": 1, "targetLines": "1" }, - "reason": "humanTurn.shard is derived from auditShardName(projectDir) inside latestHumanTurn one call earlier, so the mismatch arm is unreachable through the record flow; kept for contract parity with recordProtectedAdvisoryChoice, whose caller-supplied provenance CAN mismatch.", + "reason": "humanTurn.shard is derived from auditShardName(projectDir) inside latestHumanTurn one call earlier, so the mismatch arm is unreachable through the record flow; kept for contract parity with recordAdvisoryChoice, whose caller-supplied provenance CAN mismatch.", "expiry": "remove when freshRecordRefusal accepts caller-supplied provenance" }, { @@ -6333,7 +6355,7 @@ "file": "packages/framework/core/tools/amadeus-advisory-choice.ts", "selector": { "function": "", - "fingerprint": "sha256:bda22f12c1bef0a92b2d9e1cc7584392cb9b5308725357f96695c373768afb13", + "fingerprint": "sha256:438bc9257a619c60699f99b7a7a1c2ce77c06bda3f0d1bd85d807d15251fd826", "anchorLines": 23, "targetLines": "1-23" }, diff --git a/tests/.coverage-registry.json b/tests/.coverage-registry.json index 80b51f516..30b04a438 100644 --- a/tests/.coverage-registry.json +++ b/tests/.coverage-registry.json @@ -340,6 +340,10 @@ { "file": "tests/integration/t453-semi-ladder-runtime.integration.test.ts", "mechanism": "none" + }, + { + "file": "tests/integration/t458-advisory-auto-resolution.integration.test.ts", + "mechanism": "cli" } ], "status": "covered" diff --git a/tests/integration/t-advisory-choice-record.test.ts b/tests/integration/t-advisory-choice-record.test.ts index 27d777794..68b3ff406 100644 --- a/tests/integration/t-advisory-choice-record.test.ts +++ b/tests/integration/t-advisory-choice-record.test.ts @@ -32,7 +32,8 @@ import { createPendingAdvisory, guardAdvisoryChoices, recordAdvisoryChoiceDecision, - recordProtectedAdvisoryChoice, + choiceFromExactPrompt, + recordAdvisoryChoice, type AdvisoryChoiceStore, type PendingAdvisory, } from "../../packages/framework/core/tools/amadeus-advisory-choice.ts"; @@ -52,6 +53,25 @@ import { import { resetOtelPerProject } from "../harness/otel-reset.ts"; import { plantV1AuditRow } from "../harness/v1-audit-fixture.ts"; +// #2253 replaced the prompt-classifying acceptance entry point with one that +// takes an already-classified choice and a provenance union. These tests were +// written against the prompt shape, and what they pin — which prompts count and +// which provenance is refused — is unchanged, so they keep exercising the same +// route through the same two steps the hook now performs. +function recordAdvisoryChoiceViaPrompt( + projectDir: string, + prompt: string, + humanTurn: { timestamp: string; shard: string; eventIdentity: string }, + now?: string, +): boolean { + const choice = choiceFromExactPrompt(prompt); + if (choice === null) return false; + return now === undefined + ? recordAdvisoryChoice(projectDir, choice, { kind: "human-turn", ...humanTurn }) + : recordAdvisoryChoice(projectDir, choice, { kind: "human-turn", ...humanTurn }, now); +} + + const identity = { plugin: "formal-model-check", code: "changed" as const, @@ -133,10 +153,10 @@ describe("advisory choice record: acceptance", () => { const receipts = readStore(projectDir).receipts; expect(receipts).toHaveLength(1); expect(receipts[0]).toMatchObject({ - schema: 1, + schema: 2, identity: pending.identity, choice: "defer-with-risk", - humanTurn: turn, + provenance: { kind: "human-turn", ...turn }, recordedAt: "2026-08-05T00:00:00.000Z", }); }); @@ -160,13 +180,16 @@ describe("advisory choice record: acceptance", () => { expect(result.ok).toBe(true); if (!result.ok) return; - expect(readStore(projectDir).receipts[0]).toMatchObject({ choice: "run-now", humanTurn: turn }); + expect(readStore(projectDir).receipts[0]).toMatchObject({ + choice: "run-now", + provenance: { kind: "human-turn", ...turn }, + }); }); test("the prompt route still works and is not disturbed by the new one", () => { const { projectDir, pending } = track(seedPendingProject()); plantPresentation(projectDir, pending); - expect(recordProtectedAdvisoryChoice(projectDir, "1", plantHumanTurn(projectDir))).toBe(true); + expect(recordAdvisoryChoiceViaPrompt(projectDir, "1", plantHumanTurn(projectDir))).toBe(true); expect(readStore(projectDir).receipts[0]).toMatchObject({ identity: pending.identity, choice: "run-now", diff --git a/tests/integration/t-advisory-human-choice-boundaries.test.ts b/tests/integration/t-advisory-human-choice-boundaries.test.ts index 4cab6dc28..1b8267048 100644 --- a/tests/integration/t-advisory-human-choice-boundaries.test.ts +++ b/tests/integration/t-advisory-human-choice-boundaries.test.ts @@ -21,7 +21,7 @@ import { closeAdvisoryInstancesForStage, createPendingAdvisory, guardAdvisoryChoices, - recordProtectedAdvisoryChoice, + recordAdvisoryChoice, revokeMisattributedAdvisoryChoice, verifyAdvisoryModelCheckOutcome, type AdvisoryChoiceStore, @@ -43,6 +43,25 @@ import { } from "../harness/fixtures.ts"; import { plantV1AuditRow } from "../harness/v1-audit-fixture.ts"; +// #2253 replaced the prompt-classifying acceptance entry point with one that +// takes an already-classified choice and a provenance union. These tests were +// written against the prompt shape, and what they pin — which prompts count and +// which provenance is refused — is unchanged, so they keep exercising the same +// route through the same two steps the hook now performs. +function recordAdvisoryChoiceViaPrompt( + projectDir: string, + prompt: string, + humanTurn: { timestamp: string; shard: string; eventIdentity: string }, + now?: string, +): boolean { + const choice = choiceFromExactPrompt(prompt); + if (choice === null) return false; + return now === undefined + ? recordAdvisoryChoice(projectDir, choice, { kind: "human-turn", ...humanTurn }) + : recordAdvisoryChoice(projectDir, choice, { kind: "human-turn", ...humanTurn }, now); +} + + const identity = { plugin: "formal-model-check", code: "changed" as const, @@ -407,13 +426,13 @@ describe("protected advisory choice persistence", () => { test("pendingだけでは別gateの1を消費せず、提示直後の1だけを相関する", () => { const { projectDir, pending } = project(); - expect(recordProtectedAdvisoryChoice(projectDir, "1", plantHumanTurn(projectDir))).toBe(false); + expect(recordAdvisoryChoiceViaPrompt(projectDir, "1", plantHumanTurn(projectDir))).toBe(false); plantAdvisoryPresentation(projectDir, pending); const humanTurn = plantHumanTurn(projectDir); - expect(recordProtectedAdvisoryChoice(projectDir, "1", humanTurn, "2026-08-03T12:00:00.001Z")).toBe(true); - expect(recordProtectedAdvisoryChoice(projectDir, "1", humanTurn)).toBe(false); - expect(recordProtectedAdvisoryChoice(projectDir, "1", plantHumanTurn(projectDir))).toBe(false); + expect(recordAdvisoryChoiceViaPrompt(projectDir, "1", humanTurn, "2026-08-03T12:00:00.001Z")).toBe(true); + expect(recordAdvisoryChoiceViaPrompt(projectDir, "1", humanTurn)).toBe(false); + expect(recordAdvisoryChoiceViaPrompt(projectDir, "1", plantHumanTurn(projectDir))).toBe(false); const rerun = guardAdvisoryChoices(projectDir, identity.checkpoint, [advisory]); expect(rerun).toMatchObject({ kind: "hold", runRequired: true }); if (rerun.kind === "hold") { @@ -426,8 +445,8 @@ describe("protected advisory choice persistence", () => { test("advisory提示は次の人間ターンだけに有効で、非choiceを挟むと失効する", () => { const { projectDir, pending } = project(); plantAdvisoryPresentation(projectDir, pending); - expect(recordProtectedAdvisoryChoice(projectDir, "not a choice", plantHumanTurn(projectDir))).toBe(false); - expect(recordProtectedAdvisoryChoice(projectDir, "1", plantHumanTurn(projectDir))).toBe(false); + expect(recordAdvisoryChoiceViaPrompt(projectDir, "not a choice", plantHumanTurn(projectDir))).toBe(false); + expect(recordAdvisoryChoiceViaPrompt(projectDir, "1", plantHumanTurn(projectDir))).toBe(false); expect(readStore(projectDir).receipts).toHaveLength(0); }); @@ -437,10 +456,10 @@ describe("protected advisory choice persistence", () => { const humanTurn = plantHumanTurn(projectDir); const store = readStore(projectDir); store.receipts.push({ - schema: 1, + schema: 2, identity: pending.identity, choice: "run-now", - humanTurn, + provenance: { kind: "human-turn", ...humanTurn }, recordedAt: "2026-08-03T12:00:00.001Z", }); writeJson(storePath(projectDir), store); @@ -457,10 +476,10 @@ describe("protected advisory choice persistence", () => { const humanTurn = plantHumanTurn(projectDir); const store = readStore(projectDir); store.receipts.push({ - schema: 1, + schema: 2, identity: pending.identity, choice: "run-now", - humanTurn, + provenance: { kind: "human-turn", ...humanTurn }, recordedAt: "2026-08-03T12:00:00.001Z", }); writeJson(storePath(projectDir), store); @@ -477,15 +496,15 @@ describe("protected advisory choice persistence", () => { { const { projectDir } = project(); const turn = plantHumanTurn(projectDir); - expect(recordProtectedAdvisoryChoice(projectDir, "approve", turn)).toBe(false); - expect(recordProtectedAdvisoryChoice(projectDir, "1", { ...turn, shard: "other.jsonl" })).toBe(false); - expect(recordProtectedAdvisoryChoice(projectDir, "1", { ...turn, eventIdentity: "not-grounded" })).toBe(false); + expect(recordAdvisoryChoiceViaPrompt(projectDir, "approve", turn)).toBe(false); + expect(recordAdvisoryChoiceViaPrompt(projectDir, "1", { ...turn, shard: "other.jsonl" })).toBe(false); + expect(recordAdvisoryChoiceViaPrompt(projectDir, "1", { ...turn, eventIdentity: "not-grounded" })).toBe(false); } { const { projectDir } = project(); const turn = plantHumanTurn(projectDir); writeFileSync(storePath(projectDir), "{"); - expect(recordProtectedAdvisoryChoice(projectDir, "1", turn)).toBe(false); + expect(recordAdvisoryChoiceViaPrompt(projectDir, "1", turn)).toBe(false); expect(advisoryReportHoldReason(projectDir, identity.checkpoint)).toContain("evidence is invalid"); closeAdvisoryInstancesForStage(projectDir, identity.checkpoint); } @@ -493,21 +512,21 @@ describe("protected advisory choice persistence", () => { const { projectDir } = project(); const turn = plantHumanTurn(projectDir); unlinkSync(auditFilePath(projectDir)); - expect(recordProtectedAdvisoryChoice(projectDir, "1", turn)).toBe(false); + expect(recordAdvisoryChoiceViaPrompt(projectDir, "1", turn)).toBe(false); } { const { projectDir } = project(); closeAdvisoryInstancesForStage(projectDir, identity.checkpoint, "2026-08-03T12:00:00.000Z"); closeAdvisoryInstancesForStage(projectDir, identity.checkpoint, "2026-08-03T12:00:01.000Z"); expect(readStore(projectDir).pending[0]?.closedAt).toBe("2026-08-03T12:00:00.000Z"); - expect(recordProtectedAdvisoryChoice(projectDir, "1", plantHumanTurn(projectDir))).toBe(false); + expect(recordAdvisoryChoiceViaPrompt(projectDir, "1", plantHumanTurn(projectDir))).toBe(false); expect(advisoryReportHoldReason(projectDir, identity.checkpoint)).toBeNull(); } { const { projectDir, pending } = project(); plantAdvisoryPresentation(projectDir, pending); - expect(recordProtectedAdvisoryChoice(projectDir, "2", plantHumanTurn(projectDir))).toBe(true); - expect(recordProtectedAdvisoryChoice(projectDir, "1", plantHumanTurn(projectDir))).toBe(false); + expect(recordAdvisoryChoiceViaPrompt(projectDir, "2", plantHumanTurn(projectDir))).toBe(true); + expect(recordAdvisoryChoiceViaPrompt(projectDir, "1", plantHumanTurn(projectDir))).toBe(false); expect(advisoryReportHoldReason(projectDir, identity.checkpoint)).toBeNull(); } }); @@ -542,7 +561,7 @@ describe("protected advisory choice persistence", () => { (store: AdvisoryChoiceStore) => { store.pending[0]!.identity.code = "not a code" as "changed"; }, (store: AdvisoryChoiceStore) => { store.pending[0]!.message = ""; }, (store: AdvisoryChoiceStore) => { store.pending[0]!.closedAt = ""; }, - (store: AdvisoryChoiceStore) => { (store as unknown as Record).schema = 2; }, + (store: AdvisoryChoiceStore) => { (store as unknown as Record).schema = 3; }, ]) { const { projectDir } = project(); const store = readStore(projectDir); @@ -554,10 +573,10 @@ describe("protected advisory choice persistence", () => { const { projectDir } = project(); const store = readStore(projectDir); store.receipts.push({ - schema: 1, + schema: 2, identity: store.pending[0]!.identity, choice: "run-now", - humanTurn: { timestamp: "", shard: "shard", eventIdentity: "event" }, + provenance: { kind: "human-turn", timestamp: "", shard: "shard", eventIdentity: "event" }, recordedAt: "2026-08-03T12:00:00.000Z", }); writeJson(storePath(projectDir), store); @@ -567,10 +586,10 @@ describe("protected advisory choice persistence", () => { const { projectDir } = project(); const store = readStore(projectDir); store.receipts.push({ - schema: 1, + schema: 2, identity: store.pending[0]!.identity, choice: "run-now", - humanTurn: { timestamp: "2026-08-03T12:00:00.000Z", shard: "shard", eventIdentity: "" }, + provenance: { kind: "human-turn", timestamp: "2026-08-03T12:00:00.000Z", shard: "shard", eventIdentity: "" }, recordedAt: "invalid", }); writeJson(storePath(projectDir), store); @@ -580,10 +599,11 @@ describe("protected advisory choice persistence", () => { const { projectDir } = project(); const store = readStore(projectDir); store.receipts.push({ - schema: 1, + schema: 2, identity: store.pending[0]!.identity, choice: "run-now", - humanTurn: { + provenance: { + kind: "human-turn", timestamp: "2026-08-03T12:00:00.000Z", shard: "shard", eventIdentity: "event", @@ -598,7 +618,7 @@ describe("protected advisory choice persistence", () => { test("run-now hold reports detected, harness-error, invalid, and verified outcomes", () => { const { projectDir, pending } = project(); plantAdvisoryPresentation(projectDir, pending); - expect(recordProtectedAdvisoryChoice(projectDir, "1", plantHumanTurn(projectDir))).toBe(true); + expect(recordAdvisoryChoiceViaPrompt(projectDir, "1", plantHumanTurn(projectDir))).toBe(true); writeEvidence(projectDir, pending, "DETECTED"); expect(advisoryReportHoldReason(projectDir, identity.checkpoint)).toContain("DETECTED counterexample-1"); diff --git a/tests/integration/t-advisory-human-choice-domain.test.ts b/tests/integration/t-advisory-human-choice-domain.test.ts index 3757086a6..301570563 100644 --- a/tests/integration/t-advisory-human-choice-domain.test.ts +++ b/tests/integration/t-advisory-human-choice-domain.test.ts @@ -29,10 +29,11 @@ function receipt( choice: AdvisoryChoiceReceipt["choice"] = "defer-with-risk", ): AdvisoryChoiceReceipt { return { - schema: 1, + schema: 2, identity: pending.identity, choice, - humanTurn: { + provenance: { + kind: "human-turn", timestamp: "2026-08-03T12:00:00.000Z", shard: "host-clone.jsonl", eventIdentity: "human-turn-event-1", @@ -62,13 +63,37 @@ describe("advisory human choice domain", () => { ); }); - test("receipt parserは完全なhuman-turn provenanceだけを受理する", () => { + test("receipt parserは完全なprovenanceだけを受理する", () => { expect(parseAdvisoryChoiceReceipt(receipt()).ok).toBe(true); - expect(parseAdvisoryChoiceReceipt({ ...receipt(), humanTurn: { timestamp: "", shard: "x" } }).ok) - .toBe(false); + expect(parseAdvisoryChoiceReceipt({ + ...receipt(), + provenance: { kind: "human-turn", timestamp: "", shard: "x" }, + }).ok).toBe(false); + expect(parseAdvisoryChoiceReceipt({ ...receipt(), provenance: { kind: "unknown" } }).ok).toBe(false); expect(parseAdvisoryChoiceReceipt({ ...receipt(), choice: "approve" }).ok).toBe(false); }); + test("auto-decision provenanceのprojectionRevisionは整数でなければ拒否する", () => { + const autoProvenance = { + kind: "auto-decision" as const, + decisionId: "decision-1", + basisKind: "norm", + basisFingerprint: `sha256:${"a".repeat(64)}`, + projectionRevision: 3, + phase: "construction", + graphRevision: `sha256:${"b".repeat(64)}`, + }; + expect(parseAdvisoryChoiceReceipt({ ...receipt(), provenance: autoProvenance }).ok).toBe(true); + expect(parseAdvisoryChoiceReceipt({ + ...receipt(), + provenance: { ...autoProvenance, projectionRevision: 3.5 }, + })).toEqual({ ok: false, reason: "provenance.projectionRevision is invalid" }); + expect(parseAdvisoryChoiceReceipt({ + ...receipt(), + provenance: { ...autoProvenance, projectionRevision: "3" }, + })).toEqual({ ok: false, reason: "provenance.projectionRevision is invalid" }); + }); + test("receiptなし・別instance・別specはfail-closed hold", () => { const pending = createPendingAdvisory(base, () => "019fc698-ba1f-7000-8000-000000000001"); expect(evaluateAdvisoryHold([pending], [])).toEqual({ kind: "hold", unresolved: [pending] }); diff --git a/tests/integration/t-coverage-mechanism-ratchet.test.ts b/tests/integration/t-coverage-mechanism-ratchet.test.ts index 1357558e6..65d92e28f 100644 --- a/tests/integration/t-coverage-mechanism-ratchet.test.ts +++ b/tests/integration/t-coverage-mechanism-ratchet.test.ts @@ -194,6 +194,7 @@ describe("repository-wide mechanism honesty ratchets", () => { "integration/t433-autonomy-review-observability.test.ts", "integration/t45.test.ts", "integration/t455-semi-policy-cli.integration.test.ts", + "integration/t458-advisory-auto-resolution.integration.test.ts", "integration/t49.test.ts", "integration/t51.test.ts", "integration/t66.test.ts", diff --git a/tests/integration/t378-advisories-directive-field.integration.test.ts b/tests/integration/t378-advisories-directive-field.integration.test.ts index 3b9c0cde3..914965d43 100644 --- a/tests/integration/t378-advisories-directive-field.integration.test.ts +++ b/tests/integration/t378-advisories-directive-field.integration.test.ts @@ -23,9 +23,13 @@ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, import { tmpdir } from "node:os"; import { join } from "node:path"; import { __resetGraphCache } from "../../packages/framework/core/tools/amadeus-graph.ts"; -import { _resetStageGraphForTests } from "../../packages/framework/core/tools/amadeus-lib.ts"; +import { _resetStageGraphForTests, docsRoot } from "../../packages/framework/core/tools/amadeus-lib.ts"; import { validateDirective } from "../../packages/framework/core/tools/amadeus-directive.ts"; import { handleNext } from "../../packages/framework/core/tools/amadeus-orchestrate.ts"; +import { + applyProductionAutonomyMode, + previewProductionAutonomyGrant, +} from "../../packages/framework/core/tools/amadeus-intent-autonomy-production.ts"; import { ACTIVATION_PLUGIN, ACTIVATION_WATCH_GLOBS, @@ -39,8 +43,11 @@ import { FIXTURES_DIR, resetAidlcEnv, seedStateFile, + seededStateFile, } from "../harness/fixtures.ts"; import { writeActivationModelMap } from "../harness/formal-model-fixture.ts"; +import { plantV1AuditRow } from "../harness/v1-audit-fixture.ts"; +import { resetOtelPerProject } from "../harness/otel-reset.ts"; const REPO_ROOT = join(import.meta.dir, "..", ".."); const STOCK_GRAPH = join(REPO_ROOT, "dist", "claude", ".claude", "tools", "data", "stage-graph.json"); @@ -117,6 +124,7 @@ afterEach(() => { __resetGraphCache(); _resetStageGraphForTests(); resetAidlcEnv(); + resetOtelPerProject(); if (host) rmSync(host, { recursive: true, force: true }); cleanupTestProject(proj); }); @@ -361,4 +369,41 @@ describe("t378 next holds before stage body", () => { expect(raw).toContain('"stage":"build-and-test"'); expect(raw).not.toContain("advisories"); }); + + // #2253 FR-ADV-1: under a full grant the hold is ruled by the autonomy ladder + // first, and a `run-now` ruling lets the ORIGINAL directive through untouched + // — the run continues unattended instead of waiting on a human turn. + test("full grant -> the ladder rules run-now and run-stage passes through with a receipt", () => { + host = makeChangedHost(); + setEnv("AMADEUS_STAGE_GRAPH", STOCK_GRAPH); + setEnv("AMADEUS_PLUGINS_HOST_ROOT", host); + __resetGraphCache(); + _resetStageGraphForTests(); + proj = createTestProject(); + seedStateFile(proj, FIX_BUILD_STAGE); + plantV1AuditRow("HUMAN_TURN", {}, proj); + const stateContent = readFileSync(seededStateFile(proj), "utf8"); + const preview = previewProductionAutonomyGrant({ projectDir: proj, stateContent }); + expect(preview.ok).toBe(true); + if (!preview.ok) return; + expect(applyProductionAutonomyMode({ + projectDir: proj, + stateContent, + mode: "full", + confirmedDisplayDigest: preview.preview.displayDigest, + })).toMatchObject({ ok: true, projection: { mode: "full" } }); + + handleNext([], proj); + + const directive = JSON.parse(logs.join("\n").trim()) as { kind: string; stage?: string }; + expect(directive.kind).toBe("run-stage"); + expect(directive.stage).toBe("build-and-test"); + // The ruling was accepted as a receipt whose provenance is the decision. + const store = JSON.parse( + readFileSync(join(docsRoot(proj), ".amadeus-advisory-choice.json"), "utf-8"), + ) as { receipts: { choice: string; provenance: { kind: string } }[] }; + expect(store.receipts).toHaveLength(1); + expect(store.receipts[0].choice).toBe("run-now"); + expect(store.receipts[0].provenance.kind).toBe("auto-decision"); + }); }); diff --git a/tests/integration/t445-advisory-declaration-supply.integration.test.ts b/tests/integration/t445-advisory-declaration-supply.integration.test.ts index 204c1c954..a9e2dda26 100644 --- a/tests/integration/t445-advisory-declaration-supply.integration.test.ts +++ b/tests/integration/t445-advisory-declaration-supply.integration.test.ts @@ -16,7 +16,8 @@ import { advisoryChoicePresentationFields, advisoryReportHoldReason, guardAdvisoryChoices, - recordProtectedAdvisoryChoice, + choiceFromExactPrompt, + recordAdvisoryChoice, type AdvisoryChoiceStore, } from "../../packages/framework/core/tools/amadeus-advisory-choice.ts"; import { @@ -29,6 +30,25 @@ import type { Advisory } from "../../packages/framework/core/tools/amadeus-plugi import { cleanupTestProject, createTestProject, FIXTURES_DIR, seedStateFile } from "../harness/fixtures.ts"; import { plantV1AuditRow } from "../harness/v1-audit-fixture.ts"; +// #2253 replaced the prompt-classifying acceptance entry point with one that +// takes an already-classified choice and a provenance union. These tests were +// written against the prompt shape, and what they pin — which prompts count and +// which provenance is refused — is unchanged, so they keep exercising the same +// route through the same two steps the hook now performs. +function recordAdvisoryChoiceViaPrompt( + projectDir: string, + prompt: string, + humanTurn: { timestamp: string; shard: string; eventIdentity: string }, + now?: string, +): boolean { + const choice = choiceFromExactPrompt(prompt); + if (choice === null) return false; + return now === undefined + ? recordAdvisoryChoice(projectDir, choice, { kind: "human-turn", ...humanTurn }) + : recordAdvisoryChoice(projectDir, choice, { kind: "human-turn", ...humanTurn }, now); +} + + // U2 generalization point 1 (ADR-6 revision): the engine supplies advisories a // composed plugin declares, evaluated by that plugin's own evaluator. The // spec-hash route is untouched, so this drives a host that composes only the @@ -226,7 +246,7 @@ describe("declared advisory hold symmetry across next and report", () => { const planted = plantV1AuditRow("HUMAN_TURN", {}, projectDir); const event = findAllEvents(readFileSync(auditFilePath(projectDir), "utf-8"), "HUMAN_TURN").at(-1); if (event === undefined) throw new Error("no HUMAN_TURN was planted"); - const recorded = recordProtectedAdvisoryChoice(projectDir, prompt, { + const recorded = recordAdvisoryChoiceViaPrompt(projectDir, prompt, { shard: auditShardName(projectDir), timestamp: planted.timestamp, eventIdentity: createHash("sha256").update(event.block).digest("hex"), diff --git a/tests/integration/t458-advisory-auto-resolution.integration.test.ts b/tests/integration/t458-advisory-auto-resolution.integration.test.ts new file mode 100644 index 000000000..af34f587d --- /dev/null +++ b/tests/integration/t458-advisory-auto-resolution.integration.test.ts @@ -0,0 +1,367 @@ +// covers: file:packages/framework/core/tools/amadeus-advisory-choice.ts +// covers: audit:INTENT_AUTONOMY_TRANSACTION_COMMITTED +// size: medium +// +// t458 — the unattended resolution of a pending advisory, end to end (#2253). +// +// Before this, one raised advisory was enough to stop an unattended run: the +// engine swapped `run-stage` for `await-advisory-choice` and waited for a human +// turn that, in a headless run, never came. FR-ADV-1 puts the hold to the +// autonomy ladder first. +// +// What has to be true, and is asserted here against a real store, a real audit +// trail and a real projection: +// +// FR-ADV-1 under a full grant the choice is decided, an AUTO_DECIDED lands in +// the journal, and a schema-2 receipt with `auto-decision` +// provenance is written. +// FR-ADV-2 with no authorization — mode `none`, or an authorization whose +// scope does not cover this intent — nothing is decided and nothing +// is recorded. The human route is the only way forward. +// FR-ADV-3 a receipt already held by one provenance kind blocks the other. +// ADR-9 a schema 1 store on disk is not translated; it fails to parse and +// the guard's existing arm turns that into a hold. + +import { afterEach, describe, expect, test } from "bun:test"; +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { + advisoryChoicePresentationFields, + guardAdvisoryChoices, + recordAdvisoryChoice, + resolveAdvisoryChoiceAutonomously, + type AdvisoryChoiceGuardResult, + type AdvisoryChoiceStore, + type PendingAdvisory, +} from "../../packages/framework/core/tools/amadeus-advisory-choice.ts"; +import { + autonomyDigest, + type AutoDecisionRecord, +} from "../../packages/framework/core/tools/amadeus-intent-autonomy.ts"; +import { + applyProductionAutonomyMode, + previewProductionAutonomyGrant, + readProductionAutonomyProjection, +} from "../../packages/framework/core/tools/amadeus-intent-autonomy-production.ts"; +import { readIntentAutonomyTransactionsFromAudit } from "../../packages/framework/core/tools/amadeus-intent-autonomy-replay.ts"; +import { + auditFilePath, + auditShardName, + docsRoot, + findAllEvents, +} from "../../packages/framework/core/tools/amadeus-lib.ts"; +import type { Advisory } from "../../packages/framework/core/tools/amadeus-plugin-activation.ts"; +import { cleanupTestProject, setupIntegrationProject } from "../harness/fixtures.ts"; +import { resetOtelPerProject } from "../harness/otel-reset.ts"; +import { plantV1AuditRow } from "../harness/v1-audit-fixture.ts"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; + +const STAGE = "code-generation"; +const PHASE = "construction"; +const GRAPH = autonomyDigest("graph-t458"); + +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 readStore(projectDir: string): AdvisoryChoiceStore { + return JSON.parse(readFileSync(storePath(projectDir), "utf-8")) as AdvisoryChoiceStore; +} + +function bornProject(): string { + const projectDir = setupIntegrationProject({ noAidlcDocs: true, stripEnvScope: true }); + const birth = spawnSync( + process.execPath, + [ + join(projectDir, ".claude", "tools", "amadeus-utility.ts"), + "intent-birth", + "--scope", + "feature", + "--project-dir", + projectDir, + ], + { cwd: projectDir, encoding: "utf8", env: { ...process.env } }, + ); + expect(birth.status).toBe(0); + return projectDir; +} + +function stateContentOf(projectDir: string): string { + const intents = join(projectDir, "amadeus", "spaces", "default", "intents"); + const active = readFileSync(join(intents, "active-intent"), "utf8").trim(); + return readFileSync(join(intents, active, "amadeus-state.md"), "utf8"); +} + +function grantFullAutonomy(projectDir: string): void { + plantV1AuditRow("HUMAN_TURN", {}, projectDir); + const stateContent = stateContentOf(projectDir); + const preview = previewProductionAutonomyGrant({ projectDir, stateContent }); + expect(preview.ok).toBe(true); + if (!preview.ok) return; + expect(applyProductionAutonomyMode({ + projectDir, + stateContent, + mode: "full", + confirmedDisplayDigest: preview.preview.displayDigest, + })).toMatchObject({ ok: true, projection: { mode: "full" } }); +} + +function hold(projectDir: string): Extract { + const guard = guardAdvisoryChoices(projectDir, STAGE, [advisory]); + if (guard.kind !== "hold") throw new Error(`expected a hold, got ${guard.kind}`); + return guard; +} + +function autoDecisionIds(projectDir: string): string[] { + return readIntentAutonomyTransactionsFromAudit(projectDir).flatMap((transaction) => + transaction.events.flatMap((event) => + event.type === "AUTO_DECIDED" ? [(event.decision as AutoDecisionRecord).decisionId] : [] + ) + ); +} + +function plantHumanTurn(projectDir: string): { timestamp: string; shard: string; eventIdentity: string } { + const planted = plantV1AuditRow("HUMAN_TURN", {}, projectDir); + const event = findAllEvents(readFileSync(auditFilePath(projectDir), "utf-8"), "HUMAN_TURN").at(-1)!; + return { + timestamp: planted.timestamp, + shard: auditShardName(projectDir), + eventIdentity: createHash("sha256").update(event.block).digest("hex"), + }; +} + +function plantPresentation(projectDir: string, pending: PendingAdvisory): void { + const fields = advisoryChoicePresentationFields( + projectDir, + pending.identity.checkpoint, + [pending.identity.advisoryInstance], + ); + if (!fields.ok) throw new Error(fields.reason); + plantV1AuditRow("DECISION_RECORDED", fields.value, projectDir); +} + +let projectDir = ""; +afterEach(() => { + resetOtelPerProject(); + if (projectDir) cleanupTestProject(projectDir); + projectDir = ""; +}); + +describe("advisory auto-resolution: authorized (FR-ADV-1)", () => { + test("full grant下でpending advisoryが無人裁定されreceiptが記録される", () => { + projectDir = bornProject(); + grantFullAutonomy(projectDir); + const guard = hold(projectDir); + + const resolution = resolveAdvisoryChoiceAutonomously({ + projectDir, + hold: guard, + phase: PHASE, + graphRevision: GRAPH, + }); + expect(resolution.kind).toBe("resolved"); + if (resolution.kind !== "resolved") return; + expect(resolution.choice).toBe("run-now"); + + // The ruling is in the journal, not merely in the returned value. + expect(autoDecisionIds(projectDir)).toContain(resolution.decision.decisionId); + + expect(recordAdvisoryChoice(projectDir, resolution.choice, { + kind: "auto-decision", + decisionId: resolution.decision.decisionId, + basisKind: resolution.decision.basisKind, + basisFingerprint: resolution.decision.basisFingerprint, + projectionRevision: resolution.projectionRevision, + phase: PHASE, + graphRevision: GRAPH, + })).toBe(true); + + const receipts = readStore(projectDir).receipts; + expect(receipts).toHaveLength(1); + expect(receipts[0]).toMatchObject({ + schema: 2, + choice: "run-now", + provenance: { kind: "auto-decision", decisionId: resolution.decision.decisionId }, + }); + + // The choice itself is settled: what the checkpoint still wants is the model + // check the `run-now` choice asked for, carried as a formal-check route + // rather than as an unanswered question. (Executing that route is not this + // unit's business — FR-ADV-5 keeps it plugin-specific.) + const after = guardAdvisoryChoices(projectDir, STAGE, [advisory]); + expect(after.kind).toBe("hold"); + if (after.kind !== "hold") return; + expect(after.runRequired).toBe(true); + expect(after.formalChecks).toHaveLength(1); + }); +}); + +describe("advisory auto-resolution: unauthorized (FR-ADV-2 fail-closed)", () => { + test("mode=noneではhuman-requiredになりreceiptを書かない", () => { + projectDir = bornProject(); + expect(readProductionAutonomyProjection(projectDir)?.mode).toBe("none"); + const guard = hold(projectDir); + + const resolution = resolveAdvisoryChoiceAutonomously({ + projectDir, + hold: guard, + phase: PHASE, + graphRevision: GRAPH, + }); + expect(resolution.kind).toBe("human-required"); + expect(readStore(projectDir).receipts).toHaveLength(0); + expect(guardAdvisoryChoices(projectDir, STAGE, [advisory]).kind).toBe("hold"); + }); + + test("認可の無いintentでは捏造したdecisionIdの受理も拒否される", () => { + projectDir = bornProject(); + hold(projectDir); + expect(recordAdvisoryChoice(projectDir, "run-now", { + kind: "auto-decision", + decisionId: "fabricated-decision", + basisKind: "norm", + basisFingerprint: autonomyDigest("fabricated"), + projectionRevision: 1, + phase: PHASE, + graphRevision: GRAPH, + })).toBe(false); + expect(readStore(projectDir).receipts).toHaveLength(0); + }); + + test("別occurrenceの実在裁定をadvisoryへ付け替えることはできない", () => { + projectDir = bornProject(); + grantFullAutonomy(projectDir); + const guard = hold(projectDir); + const resolution = resolveAdvisoryChoiceAutonomously({ + projectDir, + hold: guard, + phase: PHASE, + graphRevision: GRAPH, + }); + expect(resolution.kind).toBe("resolved"); + if (resolution.kind !== "resolved") return; + + // The decision is real, but the phase it was taken in is misstated — which + // changes the occurrence id it must match. + expect(recordAdvisoryChoice(projectDir, resolution.choice, { + kind: "auto-decision", + decisionId: resolution.decision.decisionId, + basisKind: resolution.decision.basisKind, + basisFingerprint: resolution.decision.basisFingerprint, + projectionRevision: resolution.projectionRevision, + phase: "inception", + graphRevision: GRAPH, + })).toBe(false); + expect(readStore(projectDir).receipts).toHaveLength(0); + }); +}); + +describe("advisory auto-resolution: provenance crossing (FR-ADV-3)", () => { + test("human-turnで受理済みのadvisoryへauto-decisionの2件目は書けない", () => { + projectDir = bornProject(); + grantFullAutonomy(projectDir); + const guard = hold(projectDir); + const pending = readStore(projectDir).pending[0]!; + plantPresentation(projectDir, pending); + expect(recordAdvisoryChoice(projectDir, "defer-with-risk", { + kind: "human-turn", + ...plantHumanTurn(projectDir), + })).toBe(true); + expect(readStore(projectDir).receipts).toHaveLength(1); + + const resolution = resolveAdvisoryChoiceAutonomously({ + projectDir, + hold: guard, + phase: PHASE, + graphRevision: GRAPH, + }); + if (resolution.kind === "resolved") { + expect(recordAdvisoryChoice(projectDir, resolution.choice, { + kind: "auto-decision", + decisionId: resolution.decision.decisionId, + basisKind: resolution.decision.basisKind, + basisFingerprint: resolution.decision.basisFingerprint, + projectionRevision: resolution.projectionRevision, + phase: PHASE, + graphRevision: GRAPH, + })).toBe(false); + } + expect(readStore(projectDir).receipts).toHaveLength(1); + }); +}); + +describe("advisory auto-resolution: schema 1 store (ADR-9)", () => { + test("schema 1のstoreは読み替えられずfail-closed holdになる", () => { + projectDir = bornProject(); + grantFullAutonomy(projectDir); + hold(projectDir); + + const store = readStore(projectDir) as unknown as Record; + store.schema = 1; + writeFileSync(storePath(projectDir), `${JSON.stringify(store, null, 2)}\n`); + + // The guard cannot read the store, so it falls back to a hold rather than + // guessing what an old receipt meant. + expect(guardAdvisoryChoices(projectDir, STAGE, [advisory]).kind).toBe("hold"); + // And acceptance refuses outright — no receipt is appended to a store this + // build does not understand. + expect(recordAdvisoryChoice(projectDir, "run-now", { + kind: "auto-decision", + decisionId: "any-decision", + basisKind: "norm", + basisFingerprint: autonomyDigest("any"), + projectionRevision: 1, + phase: PHASE, + graphRevision: GRAPH, + })).toBe(false); + }); +}); + +describe("advisory auto-resolution: unreadable autonomy journal (fail-closed)", () => { + test("ジャーナルが読めない場合、受理はfail-closedで拒否される", () => { + projectDir = bornProject(); + hold(projectDir); + // A malformed INTENT_AUTONOMY_TRANSACTION_COMMITTED row makes the journal + // unreadable; acceptance must fail closed rather than trust the claim. + plantV1AuditRow("INTENT_AUTONOMY_TRANSACTION_COMMITTED", {}, projectDir); + + expect(recordAdvisoryChoice(projectDir, "run-now", { + kind: "auto-decision", + decisionId: "any-decision", + basisKind: "norm", + basisFingerprint: autonomyDigest("any"), + projectionRevision: 1, + phase: PHASE, + graphRevision: GRAPH, + })).toBe(false); + expect(readStore(projectDir).receipts).toHaveLength(0); + }); + + test("ジャーナルが読めない場合、裁定そのものもhuman-requiredに落ちる", () => { + projectDir = bornProject(); + const guard = hold(projectDir); + plantV1AuditRow("INTENT_AUTONOMY_TRANSACTION_COMMITTED", {}, projectDir); + + const resolution = resolveAdvisoryChoiceAutonomously({ + projectDir, + hold: guard, + phase: PHASE, + graphRevision: GRAPH, + }); + expect(resolution.kind).toBe("human-required"); + if (resolution.kind !== "human-required") return; + expect(resolution.reason).toStartWith("advisory-decision-failed:"); + expect(readStore(projectDir).receipts).toHaveLength(0); + }); +}); diff --git a/tests/no-silent-drop/baseline.json b/tests/no-silent-drop/baseline.json index b35ab6fbf..9ff7ccccc 100644 --- a/tests/no-silent-drop/baseline.json +++ b/tests/no-silent-drop/baseline.json @@ -5,7 +5,7 @@ "revision": "2e990c45a4cf034c9b4c6a68b1cafed0bea48fcd", "censusDigest": "b6bf74ffc7810e2a619424bf3992eeeab321b511f9fe8cce9876808b5ea34c2c", "approvalDigest": "0e92854570e9dacf4664fd19ff7074b8538b32fd59283d1d48252925015ca6df", - "previousDigest": "10ba93dfb6b67672fed17a7cbd41d732812108ad746596155530f7dd2af74093" + "previousDigest": "d720cdf6212898f6b9dff65644edc6deef81bb8702b742f5a241593e900525c2" }, "entries": [ { diff --git a/tests/no-silent-drop/exemptions.json b/tests/no-silent-drop/exemptions.json index 9eb46dd22..10940d76b 100644 --- a/tests/no-silent-drop/exemptions.json +++ b/tests/no-silent-drop/exemptions.json @@ -1,5 +1,5 @@ { "schemaVersion": 1, - "previousDigest": "b16bc510a1a482e913a5a4d31ef319028735a0ee4e8e30316df754065b9a2f9f", + "previousDigest": "0514dc4b7250ba8a2bb07ab2322eb20beb2ab72c3d76eff800de0c1705a96304", "entries": [] } diff --git a/tests/unit/t203-mint-presence-classify.test.ts b/tests/unit/t203-mint-presence-classify.test.ts index 8cd2f9c0f..f0b2148fd 100644 --- a/tests/unit/t203-mint-presence-classify.test.ts +++ b/tests/unit/t203-mint-presence-classify.test.ts @@ -174,18 +174,23 @@ function publishAdvisoryOutcome( if (!published.ok) throw new Error(published.error.detail); } -function advisoryReceipts(proj: string): Array<{ +type StoredReceipt = { identity: { advisoryInstance: string }; choice: string; - humanTurn: { eventIdentity: string }; -}> { + provenance: { kind: string; eventIdentity?: string }; +}; + +// #2253 moved the receipt's binding under a provenance union; what these tests +// pin is that two receipts for one instance came from two DIFFERENT human turns, +// which is still the human-turn arm's event identity. +function humanTurnIdentityOf(receipt: StoredReceipt | undefined): string | undefined { + return receipt?.provenance.kind === "human-turn" ? receipt.provenance.eventIdentity : undefined; +} + +function advisoryReceipts(proj: string): StoredReceipt[] { const store = JSON.parse( readFileSync(join(seededRecordDir(proj), ".amadeus-advisory-choice.json"), "utf-8"), - ) as { receipts: Array<{ - identity: { advisoryInstance: string }; - choice: string; - humanTurn: { eventIdentity: string }; - }> }; + ) as { receipts: StoredReceipt[] }; return store.receipts; } @@ -366,7 +371,7 @@ describe("t203: mint-presence classifies stdin before minting HUMAN_TURN (#708)" const receipts = advisoryReceipts(proj); expect(receipts.map((receipt) => receipt.choice)).toEqual(["run-now", "run-now"]); expect(receipts.every((receipt) => receipt.identity.advisoryInstance === instance)).toBe(true); - expect(receipts[0]?.humanTurn.eventIdentity).not.toBe(receipts[1]?.humanTurn.eventIdentity); + expect(humanTurnIdentityOf(receipts[0])).not.toBe(humanTurnIdentityOf(receipts[1])); publishAdvisoryOutcome(proj, retry.formalChecks[0]!, "NOT_DETECTED"); expect(guardAdvisoryChoices(proj, "functional-design", [formalAdvisory])).toEqual({ kind: "allow" }); }); @@ -389,7 +394,7 @@ describe("t203: mint-presence classifies stdin before minting HUMAN_TURN (#708)" const receipts = advisoryReceipts(proj); expect(receipts.map((receipt) => receipt.choice)).toEqual(["run-now", "defer-with-risk"]); expect(receipts.every((receipt) => receipt.identity.advisoryInstance === instance)).toBe(true); - expect(receipts[0]?.humanTurn.eventIdentity).not.toBe(receipts[1]?.humanTurn.eventIdentity); + expect(humanTurnIdentityOf(receipts[0])).not.toBe(humanTurnIdentityOf(receipts[1])); expect(guardAdvisoryChoices(proj, "functional-design", [formalAdvisory])).toEqual({ kind: "allow" }); }); diff --git a/tests/unit/t457-advisory-auto-resolve.test.ts b/tests/unit/t457-advisory-auto-resolve.test.ts new file mode 100644 index 000000000..407ee6a31 --- /dev/null +++ b/tests/unit/t457-advisory-auto-resolve.test.ts @@ -0,0 +1,184 @@ +// covers: file:packages/framework/core/tools/amadeus-advisory-choice.ts +// size: small +// +// t457 — the pure core of C16 (`resolveAdvisoryChoiceAutonomously`), #2253. +// +// Three mechanisms live here, and each is independently falsifiable: +// +// 1. the occurrence mapping (FR-ADV-1): an advisory becomes a `question` +// occurrence whose interactionId and selector both carry the advisory +// INSTANCE, so two raises of the same advisory never share an occurrence; +// 2. the option space (FR-ADV-4, PRIMARY mechanism): `run_required: true` +// removes `defer-with-risk` from the option ids entirely, so the unattended +// route cannot select it — it is not in the space at all; +// 3. the translation (FR-ADV-2): `decided` AND `run-now` is the ONLY path to +// `resolved`. Every other ladder outcome — including a `decided` that chose +// `defer-with-risk` — becomes `human-required`, which is what makes the +// guard's two-branch structure fail-closed. + +import { describe, expect, test } from "bun:test"; + +import { + ADVISORY_CHOICE_EFFECT_CLASSIFICATIONS, + advisoryChoiceOptionIds, + advisoryInteractionId, + advisorySelector, + translateAdvisoryDecision, + type AdvisoryIdentity, +} from "../../packages/framework/core/tools/amadeus-advisory-choice.ts"; +import type { + AutoDecisionRecord, + DecisionOptionEffect, + WorkflowResult, +} from "../../packages/framework/core/tools/amadeus-intent-autonomy.ts"; +import type { + AutonomyDecisionResult, + IntentAutonomyCommitReceipt, +} from "../../packages/framework/core/tools/amadeus-intent-autonomy-runtime.ts"; + +const INTENT = "019fc5ac-f0bb-7a5f-8a64-c944b6f76ead"; + +const identity: AdvisoryIdentity = { + plugin: "formal-model-check", + code: "changed", + checkpoint: "functional-design", + target: "specs/tla", + specIdentity: "sha256:abc", + intentRun: "019fc698-ba1f-7467-b6b6-57c4b5b50140", + advisoryInstance: "019fc698-ba1f-7000-8000-000000000001", +}; + +// SAFE_ID from amadeus-intent-autonomy.ts:43 — createInteractionOccurrence +// rejects anything outside it, so the mapping has to stay inside it. +const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,191}$/; + +const receipt: IntentAutonomyCommitReceipt = { + transactionId: "transaction-1", + transactionDigest: `sha256:${"e".repeat(64)}`, + intentUuid: INTENT, + projectionRevision: 3, +}; + +const parkedResult: WorkflowResult = { + outcome: "parked", + reasonCode: "NORM_CONFLICT", + retryable: true, + intentUuid: INTENT, + autonomyMode: "full", + grant: null, + evidenceFingerprint: null, + resumeCondition: { kind: "norm-change", identity: "resume-1", status: "pending", evidenceFingerprint: `sha256:${"f".repeat(64)}` }, + failureRef: null, +}; + +function decision(selectedOptionId: string, optionIds: readonly string[]): AutoDecisionRecord { + return { + decisionId: "decision-1", + occurrenceId: "occurrence-1", + question: "advisory", + optionIds, + selectedOptionId, + decider: "deterministic-engine", + basisKind: "norm", + basisFingerprint: `sha256:${"a".repeat(64)}`, + principalId: "amadeus-engine", + actorId: "amadeus-conductor", + grantId: null, + degradedCapability: null, + reviewState: "not-applicable", + }; +} + +function effect(optionId: string, classification: DecisionOptionEffect["classification"]): DecisionOptionEffect { + return { + effectId: `advisory-${optionId}`, + optionId, + payload: { optionId }, + payloadFingerprint: `sha256:${"b".repeat(64)}`, + classification, + requiredScopeFingerprint: `sha256:${"c".repeat(64)}`, + applicableNormFingerprint: `sha256:${"d".repeat(64)}`, + }; +} + +describe("advisory auto-resolution: occurrence mapping", () => { + test("interactionIdとselectorはadvisory instanceを含んで一意化される", () => { + expect(advisoryInteractionId(identity)).toBe("advisory-019fc698-ba1f-7000-8000-000000000001"); + expect(advisorySelector(identity)).toBe( + "advisory:formal-model-check:changed:019fc698-ba1f-7000-8000-000000000001", + ); + }); + + test("写像した識別子はSAFE_IDに適合する", () => { + expect(SAFE_ID.test(advisoryInteractionId(identity))).toBe(true); + expect(SAFE_ID.test(advisorySelector(identity))).toBe(true); + }); + + test("別instanceは別selectorになる", () => { + const second = { ...identity, advisoryInstance: "019fc698-ba1f-7000-8000-000000000002" }; + expect(advisorySelector(second)).not.toBe(advisorySelector(identity)); + expect(advisoryInteractionId(second)).not.toBe(advisoryInteractionId(identity)); + }); +}); + +describe("advisory auto-resolution: option space (FR-ADV-4 primary)", () => { + test("run_required:trueならdefer-with-riskは選択肢空間に存在しない", () => { + expect(advisoryChoiceOptionIds(true)).toEqual(["run-now"]); + expect(advisoryChoiceOptionIds(true)).not.toContain("defer-with-risk"); + }); + + test("run_required:falseなら2択のまま", () => { + expect(advisoryChoiceOptionIds(false)).toEqual(["run-now", "defer-with-risk"]); + }); +}); + +describe("advisory auto-resolution: effect classification (FR-ADV-4 secondary)", () => { + test("defer-with-riskはquality-waiverに分類される", () => { + expect(ADVISORY_CHOICE_EFFECT_CLASSIFICATIONS).toEqual({ + "run-now": "workflow-reversible", + "defer-with-risk": "quality-waiver", + }); + }); +}); + +describe("advisory auto-resolution: translation (FR-ADV-2 fail-closed)", () => { + test("decided かつ run-now だけがresolvedになる", () => { + const result: AutonomyDecisionResult = { + kind: "decided", + decision: decision("run-now", ["run-now"]), + effect: effect("run-now", "workflow-reversible"), + receipt, + }; + expect(translateAdvisoryDecision(result)).toEqual({ + kind: "resolved", + choice: "run-now", + decision: result.kind === "decided" ? result.decision : decision("run-now", ["run-now"]), + projectionRevision: 3, + }); + }); + + test("decidedでもdefer-with-riskはhuman-requiredへ落ちる", () => { + const result: AutonomyDecisionResult = { + kind: "decided", + decision: decision("defer-with-risk", ["run-now", "defer-with-risk"]), + effect: effect("defer-with-risk", "quality-waiver"), + receipt, + }; + expect(translateAdvisoryDecision(result).kind).toBe("human-required"); + }); + + test("human-required・parked・conflict・aborted・reservedはすべてhuman-requiredへ落ちる", () => { + const outcomes: AutonomyDecisionResult[] = [ + { kind: "human-required", reason: "MODE_REQUIRES_HUMAN", result: null }, + { kind: "parked", result: parkedResult }, + { kind: "conflict", reason: "expected-revision-mismatch" }, + { kind: "aborted", reason: "effect-not-authorized", receipt }, + { kind: "reserved", reservationId: "reservation-1", receipt }, + ]; + for (const outcome of outcomes) { + const translated = translateAdvisoryDecision(outcome); + expect(translated.kind).toBe("human-required"); + if (translated.kind === "human-required") expect(translated.reason.length).toBeGreaterThan(0); + } + }); +}); diff --git a/tests/unit/t459-advisory-receipt.test.ts b/tests/unit/t459-advisory-receipt.test.ts new file mode 100644 index 000000000..02748eee7 --- /dev/null +++ b/tests/unit/t459-advisory-receipt.test.ts @@ -0,0 +1,236 @@ +// covers: file:packages/framework/core/tools/amadeus-advisory-choice.ts +// size: small +// +// t459 — the acceptance predicates of C17 (#2253). +// +// The human route earns a receipt with three checks: the turn is grounded in +// the audit trail, the turn is not spent twice, and the advisory was actually +// presented. The unattended route must be no weaker, so it gets three of equal +// depth, and this file pins the two that are pure: +// +// grounding — the decision id must name an AUTO_DECIDED record that the +// journal actually holds. A receipt's own word for it is +// worth nothing (NFR-6). +// presentation — that record's occurrence id must be the one THIS advisory +// instance produces. A real decision about something else +// cannot be re-pointed at an advisory, because the occurrence +// id is a digest over the interaction id (which carries the +// instance) and over the phase and graph revision the +// provenance claims. +// single-spend — one decision id backs one receipt. +// +// The effect-classification barrier (FR-ADV-4 secondary) is pinned here too: +// `quality-waiver` has to remain a prohibited classification, or deferring past +// a run-required advisory would stop being refused at effect authorization. + +import { describe, expect, test } from "bun:test"; + +import { + advisoryOccurrenceMatchesDecision, + advisoryProvenanceAlreadySpent, + autoDecisionsFromTransactions, + type AdvisoryChoiceProvenance, + type AdvisoryChoiceReceipt, + type AdvisoryIdentity, +} from "../../packages/framework/core/tools/amadeus-advisory-choice.ts"; +import { + autonomyDigest, + createInteractionOccurrence, + type AutoDecisionRecord, +} from "../../packages/framework/core/tools/amadeus-intent-autonomy.ts"; +import { PROHIBITED_EFFECTS } from "../../packages/framework/core/tools/amadeus-intent-autonomy-production.ts"; +import type { IntentAutonomyTransaction } from "../../packages/framework/core/tools/amadeus-intent-autonomy-runtime.ts"; + +const INTENT = "019fc5ac-f0bb-7a5f-8a64-c944b6f76ead"; +const GRAPH = autonomyDigest("graph-v1"); +const PHASE = "construction"; + +const identity: AdvisoryIdentity = { + plugin: "formal-model-check", + code: "changed", + checkpoint: "functional-design", + target: "specs/tla", + specIdentity: "sha256:abc", + intentRun: INTENT, + advisoryInstance: "019fc698-ba1f-7000-8000-000000000001", +}; + +function decisionFor( + target: AdvisoryIdentity, + overrides: Partial = {}, + phase = PHASE, + graphRevision = GRAPH, +): AutoDecisionRecord { + const optionIds = ["run-now"]; + const occurrence = createInteractionOccurrence({ + intentUuid: INTENT, + kind: "question", + stage: target.checkpoint, + phase, + bolt: null, + interactionId: `advisory-${target.advisoryInstance}`, + selector: `advisory:${target.plugin}:${target.code}:${target.advisoryInstance}`, + question: "advisory", + optionIds, + graphRevision, + }); + return { + decisionId: "decision-1", + occurrenceId: occurrence.occurrenceId, + question: "advisory", + optionIds, + selectedOptionId: "run-now", + decider: "deterministic-engine", + basisKind: "norm", + basisFingerprint: autonomyDigest("evidence"), + principalId: "amadeus-engine", + actorId: "amadeus-conductor", + grantId: null, + degradedCapability: null, + reviewState: "not-applicable", + ...overrides, + }; +} + +function transaction(...decisions: AutoDecisionRecord[]): IntentAutonomyTransaction { + return { + events: decisions.map((decision) => ({ type: "AUTO_DECIDED", decision })), + } as unknown as IntentAutonomyTransaction; +} + +function autoProvenance(overrides: Partial> = {}) { + return { + kind: "auto-decision" as const, + decisionId: "decision-1", + basisKind: "norm" as const, + basisFingerprint: autonomyDigest("evidence"), + projectionRevision: 3, + phase: PHASE, + graphRevision: GRAPH, + ...overrides, + }; +} + +function receipt(provenance: AdvisoryChoiceProvenance): AdvisoryChoiceReceipt { + return { + schema: 2, + identity, + choice: "run-now", + provenance, + recordedAt: "2026-08-05T12:00:00.000Z", + }; +} + +describe("advisory receipt: grounding (auto-decision)", () => { + test("journalのAUTO_DECIDEDだけが裁定として読み出される", () => { + const decision = decisionFor(identity); + expect(autoDecisionsFromTransactions([transaction(decision)])).toEqual([decision]); + }); + + test("AUTO_DECIDEDを持たないtransactionからは何も読み出さない", () => { + const empty = { events: [{ type: "AUTONOMY_MODE_CHANGED" }] } as unknown as IntentAutonomyTransaction; + expect(autoDecisionsFromTransactions([empty])).toEqual([]); + }); + + test("捏造したdecisionIdはjournalに実在しないので解決できない", () => { + const decisions = autoDecisionsFromTransactions([transaction(decisionFor(identity))]); + expect(decisions.some((decision) => decision.decisionId === "fabricated-decision")).toBe(false); + }); +}); + +describe("advisory receipt: presentation binding (auto-decision)", () => { + test("当該instanceのoccurrenceを持つ裁定だけが一致する", () => { + expect(advisoryOccurrenceMatchesDecision({ + intentUuid: INTENT, + identity, + decision: decisionFor(identity), + phase: PHASE, + graphRevision: GRAPH, + })).toBe(true); + }); + + test("別instanceの裁定は一致しない(誤帰属の封鎖)", () => { + const other = { ...identity, advisoryInstance: "019fc698-ba1f-7000-8000-000000000002" }; + expect(advisoryOccurrenceMatchesDecision({ + intentUuid: INTENT, + identity, + decision: decisionFor(other), + phase: PHASE, + graphRevision: GRAPH, + })).toBe(false); + }); + + test("phase・graphRevisionの詐称はoccurrence idが合わず一致しない", () => { + const decision = decisionFor(identity); + expect(advisoryOccurrenceMatchesDecision({ + intentUuid: INTENT, + identity, + decision, + phase: "inception", + graphRevision: GRAPH, + })).toBe(false); + expect(advisoryOccurrenceMatchesDecision({ + intentUuid: INTENT, + identity, + decision, + phase: PHASE, + graphRevision: autonomyDigest("graph-v2"), + })).toBe(false); + }); + + test("occurrence idを構成できない入力はfail-closedで不一致", () => { + expect(advisoryOccurrenceMatchesDecision({ + intentUuid: INTENT, + identity, + decision: decisionFor(identity, { optionIds: [] }), + phase: PHASE, + graphRevision: GRAPH, + })).toBe(false); + }); +}); + +describe("advisory receipt: single spend across provenance kinds", () => { + test("同一decisionIdの2件目は拒否される", () => { + const provenance = autoProvenance(); + expect(advisoryProvenanceAlreadySpent([], provenance)).toBe(false); + expect(advisoryProvenanceAlreadySpent([receipt(provenance)], provenance)).toBe(true); + }); + + test("別decisionIdは別のspendとして扱われる", () => { + const spent = receipt(autoProvenance()); + expect(advisoryProvenanceAlreadySpent([spent], autoProvenance({ decisionId: "decision-2" }))).toBe(false); + }); + + test("human-turn receiptはauto-decisionのspend判定に混線しない", () => { + const human = receipt({ + kind: "human-turn", + timestamp: "2026-08-05T12:00:00.000Z", + shard: "host-clone.jsonl", + eventIdentity: "decision-1", + }); + expect(advisoryProvenanceAlreadySpent([human], autoProvenance())).toBe(false); + expect(advisoryProvenanceAlreadySpent([receipt(autoProvenance())], { + kind: "human-turn", + timestamp: "2026-08-05T12:00:00.000Z", + shard: "host-clone.jsonl", + eventIdentity: "decision-1", + })).toBe(false); + }); + + test("human-turnの二重消費も従来どおり拒否される", () => { + const turn: AdvisoryChoiceProvenance = { + kind: "human-turn", + timestamp: "2026-08-05T12:00:00.000Z", + shard: "host-clone.jsonl", + eventIdentity: "event-1", + }; + expect(advisoryProvenanceAlreadySpent([receipt(turn)], turn)).toBe(true); + expect(advisoryProvenanceAlreadySpent([receipt(turn)], { ...turn, shard: "other.jsonl" })).toBe(false); + }); +}); + +describe("advisory receipt: prohibited effect barrier (FR-ADV-4 secondary)", () => { + test("quality-waiverは禁止効果として収載され続ける", () => { + expect(PROHIBITED_EFFECTS).toContain("quality-waiver"); + }); +});