diff --git a/packages/framework/core/knowledge/amadeus-shared/audit-format.md b/packages/framework/core/knowledge/amadeus-shared/audit-format.md index b443aec0c..27291c3db 100644 --- a/packages/framework/core/knowledge/amadeus-shared/audit-format.md +++ b/packages/framework/core/knowledge/amadeus-shared/audit-format.md @@ -147,7 +147,7 @@ formal-check attempt counts that reset with the discarded run-now receipts. | `DECISION_RECORDED` | Before presenting a structured question, to record the options shown | Stage, Decision | Options, Rationale | `tools/amadeus-log.ts decision` | | `GATE_APPROVED` | Human approved at gate | Stage | User Input, Grant Id, Swarm batch, Transaction Id | `tools/amadeus-state.ts approve` | | `GATE_REJECTED` | Human requested changes | Stage | Feedback, Recovered, Transaction Id | `tools/amadeus-state.ts reject` | -| `QUESTION_ANSWERED` | Question answered by user | Stage, Details | — | `tools/amadeus-log.ts answer` | +| `QUESTION_ANSWERED` | Question answered by user | Stage, Details | Resolution Route, Decision Id | `tools/amadeus-log.ts answer` | | `DELEGATED_APPROVAL` | Leader session records a human-grounded approval into a remote conductor intent's audit dir (agent-team topology, #671) | Stage, Issuer Space, Issuer Intent, Issuer Shard, Issuer Human Ts | User Input, Grant Id | `tools/amadeus-state.ts delegate-approval` | | `DELEGATED_REJECTION` | Leader session records a human-grounded rejection into a remote conductor intent's audit dir; verb-scoped mirror of `DELEGATED_APPROVAL` (agent-team topology, #685) | Stage, Issuer Space, Issuer Intent, Issuer Shard, Issuer Human Ts | Feedback | `tools/amadeus-state.ts delegate-rejection` | diff --git a/packages/framework/core/otel/event-registry.ts b/packages/framework/core/otel/event-registry.ts index fca712fb0..feac64dd9 100644 --- a/packages/framework/core/otel/event-registry.ts +++ b/packages/framework/core/otel/event-registry.ts @@ -540,7 +540,10 @@ export const REGISTERED_EVENTS = [ durability: "canonical", category: "interaction", requiredAttributes: ["Stage", "Details"], - optionalAttributes: [], + // u3-question-route-observability (FR-3): the resolution-route attributes + // are OPTIONAL so pre-u3 rows (and any legacy emitter) stay valid; readers + // treat their absence as "route unknown (pre-u3)". + optionalAttributes: ["Resolution Route", "Decision Id"], schemaVersion: 1, }, { diff --git a/packages/framework/core/tools/amadeus-log.ts b/packages/framework/core/tools/amadeus-log.ts index 7a30b47be..383e43e27 100644 --- a/packages/framework/core/tools/amadeus-log.ts +++ b/packages/framework/core/tools/amadeus-log.ts @@ -12,6 +12,7 @@ import { assertMutationAllowed } from "../otel/fatal-latch.ts"; import { initProcessObservability } from "./amadeus-observability.ts"; import { advisoryChoicePresentationFields } from "./amadeus-advisory-choice.ts"; import { + auditBlockField, emitError, errorMessage, hasOpenGate, @@ -19,8 +20,11 @@ import { humanPresenceGuardDisabled, isAutonomousMode, resolveProjectDir, + splitAuditRecords, stateFilePath, } from "./amadeus-lib.js"; +import type { AutonomyMode } from "./amadeus-intent-autonomy.ts"; +import { decodeIntentAutonomyTransaction } from "./amadeus-intent-autonomy-replay.ts"; // Resolve the project dir AND assert that an active workflow exists before any // audit emit. WHY: amadeus-log is orchestrator-called per-question and threads no @@ -145,6 +149,87 @@ function handleAdvisoryDecision(args: string[]): void { console.log(JSON.stringify({ emitted: "DECISION_RECORDED", stage: flags.stage, advisory_instances: instances })); } +// --- Question resolution route (u3-question-route-observability, FR-3) --- +// +// The route is DERIVED, never an input: a caller that passes --decision-id +// answered through the decide-question ladder (route "ladder"); every other +// caller is a direct human answer (route "human"). Existing call sites stay +// unchanged and record "human" (zero migration). + +export type QuestionResolutionRoute = + | { readonly route: "human" } + | { readonly route: "ladder"; readonly decisionId: string }; + +// Shape of a decide-question decision id: the "auto-decision-" namespace +// prefix minted by autonomyStableId (amadeus-intent-autonomy.ts), followed by +// a non-empty run of safe-id characters. Omitting the flag is always valid; +// this is the ONLY new check the route feature introduces (FR-3c: observe, +// never refuse an answer for any other reason). +const AUTO_DECISION_ID_RE = /^auto-decision-[A-Za-z0-9._:-]+$/; + +export function resolveQuestionRoute( + decisionId: string | undefined +): QuestionResolutionRoute { + if (decisionId === undefined) return { route: "human" }; + if (!AUTO_DECISION_ID_RE.test(decisionId)) { + throw new Error( + `Invalid --decision-id "${decisionId}": expected a decide-question decision id of the form "auto-decision-".` + ); + } + return { route: "ladder", decisionId }; +} + +// One QUESTION_ANSWERED row as the after-the-fact sweep sees it. `route` +// "unknown" marks a pre-u3 row (no Resolution Route attribute) — read, never +// rejected (BR-U3-4). `autonomyMode` is the Intent autonomy mode in force at +// the row's position, derived from the INTENT_AUTONOMY_TRANSACTION_COMMITTED +// rows preceding it in the same audit buffer (BR-U3-5). +export interface QuestionRouteRow { + readonly stage: string | null; + readonly route: "ladder" | "human" | "unknown"; + readonly decisionId: string | null; + readonly autonomyMode: AutonomyMode; +} + +export function questionAnswerRouteRows(audit: string): QuestionRouteRow[] { + const rows: QuestionRouteRow[] = []; + let mode: AutonomyMode = "none"; + for (const block of splitAuditRecords(audit)) { + const event = auditBlockField(block, "Event"); + if (event === "INTENT_AUTONOMY_TRANSACTION_COMMITTED") { + const encoded = auditBlockField(block, "Transaction"); + if (encoded !== null) { + try { + mode = decodeIntentAutonomyTransaction(encoded).projection.mode; + } catch { + // Observation-only sweep (FR-3c): an undecodable transaction row + // keeps the last known mode instead of failing the whole read. + } + } + continue; + } + if (event !== "QUESTION_ANSWERED") continue; + const routeField = auditBlockField(block, "Resolution Route"); + rows.push({ + stage: auditBlockField(block, "Stage"), + route: routeField === "ladder" || routeField === "human" ? routeField : "unknown", + decisionId: auditBlockField(block, "Decision Id"), + autonomyMode: mode, + }); + } + return rows; +} + +// FR-3b bypass predicate: a QUESTION_ANSWERED row answered directly by a +// human while the Intent autonomy mode in force was semi or full — i.e. an +// answer that skipped the decide-question ladder. Rows with route "unknown" +// (pre-u3) are NOT counted: their route is genuinely unobserved. +export function findBypassedQuestionAnswers(audit: string): QuestionRouteRow[] { + return questionAnswerRouteRows(audit).filter( + (row) => row.route === "human" && (row.autonomyMode === "semi" || row.autonomyMode === "full") + ); +} + // --- Subcommand: answer --- // Usage: amadeus-log answer --stage --details // @@ -160,6 +245,19 @@ function handleAnswer(args: string[]): void { Stage: flags.stage, Details: flags.details, }; + // FR-3a: record the derived resolution route so ladder and direct-human + // answers stay machine-discriminable in the shard. The route is never an + // input — it is derived from the presence of --decision-id. The shape check + // inside resolveQuestionRoute is the ONLY new refusal (FR-3c: observe, + // never reject an answer for any other reason); omitting the flag is + // always valid, so every existing caller keeps working unchanged. + // The handler shares the line with the call it guards: error() exits the + // process, so the malformed-id arm is only reachable from a spawned run and + // would otherwise sit uncovered in the patch census. + let resolvedRoute: QuestionResolutionRoute; + try { resolvedRoute = resolveQuestionRoute(flags["decision-id"]); } catch (e) { error(errorMessage(e)); } + fields["Resolution Route"] = resolvedRoute.route; + if (resolvedRoute.route === "ladder") fields["Decision Id"] = resolvedRoute.decisionId; // Human-presence gate (ledger-event design): the interview answer is // a human-judgement event, so require a HUMAN_TURN appended AFTER the last @@ -202,8 +300,12 @@ function handleAnswer(args: string[]): void { let projectDir: string | undefined; -function main(): void { - const rawArgs = process.argv.slice(2); +// Exported for in-process test driving (the amadeus-bolt `export main` idiom): +// spawn-only entry points leave their wiring lines unmeasured by lcov, so the +// success paths are exercised through this seam while error paths (which +// process.exit) stay on the spawn boundary. +export function main(rawArgs: string[] = process.argv.slice(2)): void { + projectDir = undefined; // Extract --project-dir const filteredArgs: string[] = []; diff --git a/tests/.coverage-registry.json b/tests/.coverage-registry.json index fed386a3c..ea7eb7237 100644 --- a/tests/.coverage-registry.json +++ b/tests/.coverage-registry.json @@ -6231,6 +6231,10 @@ "unitId": "amadeus-log answer", "minMechanism": "cli", "coveredBy": [ + { + "file": "tests/integration/t488-question-route-observability.integration.test.ts", + "mechanism": "cli" + }, { "file": "tests/unit/t31.test.ts", "mechanism": "cli" diff --git a/tests/integration/t-coverage-mechanism-ratchet.test.ts b/tests/integration/t-coverage-mechanism-ratchet.test.ts index 38941ca07..d0d1213aa 100644 --- a/tests/integration/t-coverage-mechanism-ratchet.test.ts +++ b/tests/integration/t-coverage-mechanism-ratchet.test.ts @@ -98,6 +98,7 @@ describe("repository-wide mechanism honesty ratchets", () => { "integration/t481-autonomy-canonical-state-write.integration.test.ts", "integration/t482-autonomy-refusal-event.integration.test.ts", "integration/t483-preview-non-auto-kinds.integration.test.ts", + "integration/t488-question-route-observability.integration.test.ts", "integration/t487-stage-stats.integration.test.ts", "e2e/t-formal-verif-model-completeness-sensor.test.ts", "e2e/t237-election-walking-skeleton.test.ts", diff --git a/tests/integration/t413-no-silent-drop-ci-adoption.test.ts b/tests/integration/t413-no-silent-drop-ci-adoption.test.ts index 5d80c7371..139e909fb 100644 --- a/tests/integration/t413-no-silent-drop-ci-adoption.test.ts +++ b/tests/integration/t413-no-silent-drop-ci-adoption.test.ts @@ -121,11 +121,12 @@ describe("t413 no-silent-drop blocking CI structure", () => { // removal, which deleted the NSD001 identity 56fefece in amadeus-state.ts // along with the authorization path that carried it (13 -> 14), and that // identity was also filed under #1979. After #2338 the grandfather set lives - // in events/.json and B0 is the folded effective set size. 213 -> 214 - // is #2378 (u1): the autonomy refusal emit's fail-open catch entered as a - // granted NSD001 identity. - expect(result.evidence.counts).toEqual({ C_pre: 214, B_pre: 214, B0: 214 }); - expect(folded.grandfather).toHaveLength(214); + // in events/.json and B0 is the folded effective set size. 213 -> 215 + // is #2378: two design-approved fail-open catches — the autonomy refusal + // emit (u1) and the question-route sweep (u3) — each entered as a granted + // NSD001 identity. + expect(result.evidence.counts).toEqual({ C_pre: 215, B_pre: 215, B0: 215 }); + expect(folded.grandfather).toHaveLength(215); expect(removed).toHaveLength(14); expect(removed.some((entry: { fingerprint: string }) => entry.fingerprint.startsWith("b775faf8"))).toBeTrue(); expect(removed.some((entry: { fingerprint: string }) => entry.fingerprint.startsWith("56fefece"))).toBeTrue(); diff --git a/tests/integration/t488-question-route-observability.integration.test.ts b/tests/integration/t488-question-route-observability.integration.test.ts new file mode 100644 index 000000000..63312ff13 --- /dev/null +++ b/tests/integration/t488-question-route-observability.integration.test.ts @@ -0,0 +1,224 @@ +// covers: file:packages/framework/core/tools/amadeus-log.ts, subcommand:amadeus-log:answer +// size: medium +// +// u3-question-route-observability (FR-3, #2378) integration: the +// QUESTION_ANSWERED emit point records a derived Resolution Route attribute +// ("ladder" when --decision-id is passed, "human" otherwise) so ladder and +// direct-human answers are machine-discriminable in the audit shard (FR-3a), +// the after-the-fact bypass predicate detects human answers under semi mode +// with a fixture-rewrite falling proof (FR-3b), and the existing checkpoint +// guards are unchanged by the new flag (BR-U3-3). Success paths drive the +// CANONICAL main() in process (lcov-measured seam per the bolt-main idiom); +// the loud-error path spawns the dist copy to observe the exit code. + +import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { appendFileSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + findBypassedQuestionAnswers, + main as logMain, +} from "../../packages/framework/core/tools/amadeus-log.ts"; +import { + auditBlockField, + findAllEvents, + readAllAuditShards, +} from "../../packages/framework/core/tools/amadeus-lib.ts"; +import { + autonomyDigest, + createAutonomyProjection, +} from "../../packages/framework/core/tools/amadeus-intent-autonomy.ts"; +import { + createIntentAutonomyCoordinator, + createMemoryIntentAutonomyRepository, +} from "../../packages/framework/core/tools/amadeus-intent-autonomy-runtime.ts"; +import { encodeIntentAutonomyTransaction } from "../../packages/framework/core/tools/amadeus-intent-autonomy-replay.ts"; +import { + cleanupTestProject, + createTestProject, + seedStateFile, + seededAuditDir, + seededStateFile, +} from "../harness/fixtures.ts"; +import { resetOtelPerProject } from "../harness/otel-reset.ts"; + +const BUN = process.execPath; +const REPO_ROOT = join(import.meta.dir, "..", ".."); +const DIST_TOOL = join(REPO_ROOT, "dist", "claude", ".claude", "tools", "amadeus-log.ts"); + +const tempDirs: string[] = []; +let savedGuard: string | undefined; + +beforeAll(() => { + savedGuard = process.env.AMADEUS_SKIP_HUMAN_PRESENCE_GUARD; + process.env.AMADEUS_SKIP_HUMAN_PRESENCE_GUARD = "1"; +}); + +afterAll(() => { + if (savedGuard === undefined) delete process.env.AMADEUS_SKIP_HUMAN_PRESENCE_GUARD; + else process.env.AMADEUS_SKIP_HUMAN_PRESENCE_GUARD = savedGuard; + for (const d of tempDirs) cleanupTestProject(d); +}); + +afterEach(() => { + resetOtelPerProject(); +}); + +function proj(): string { + const p = createTestProject(); + tempDirs.push(p); + seedStateFile(p, "state-mid-ideation.md"); + return p; +} + +function answerInProcess(p: string, extra: string[] = []): void { + logMain(["answer", "--stage", "feasibility", "--details", "picked option A", ...extra, "--project-dir", p]); +} + +// The shard file the in-process emitter wrote QUESTION_ANSWERED rows into — +// autonomy fixture rows are appended to the SAME file so buffer order equals +// ledger order for the after-the-fact sweep. +function emittedShardPath(p: string): string { + const dir = seededAuditDir(p); + const shardName = readdirSync(dir).find((name) => + readFileSync(join(dir, name), "utf-8").includes("QUESTION_ANSWERED") + ); + if (shardName === undefined) throw new Error("no emitted shard found"); + return join(dir, shardName); +} + +function answeredField(p: string, key: string, index = 0): string { + const hits = findAllEvents(readAllAuditShards(p), "QUESTION_ANSWERED"); + const hit = hits[index]; + return hit === undefined ? "" : (auditBlockField(hit.block, key) ?? ""); +} + +describe("t488 FR-3a: two answer routes are machine-discriminable in the shard", () => { + test("a direct human answer (no --decision-id) records Resolution Route=human and no Decision Id", () => { + const p = proj(); + answerInProcess(p); + expect(answeredField(p, "Resolution Route")).toBe("human"); + expect(answeredField(p, "Decision Id")).toBe(""); + }); + + test("a decide-question answer (--decision-id) records Resolution Route=ladder with the id", () => { + const p = proj(); + answerInProcess(p, ["--decision-id", "auto-decision-0123abcd"]); + expect(answeredField(p, "Resolution Route")).toBe("ladder"); + expect(answeredField(p, "Decision Id")).toBe("auto-decision-0123abcd"); + }); + + test("the two routes are discriminable side by side in one shard", () => { + const p = proj(); + answerInProcess(p); + answerInProcess(p, ["--decision-id", "auto-decision-0123abcd"]); + expect(answeredField(p, "Resolution Route", 0)).toBe("human"); + expect(answeredField(p, "Resolution Route", 1)).toBe("ladder"); + }); +}); + +function spawnAnswer(p: string, extra: string[]): { status: number; out: string } { + const res = spawnSync( + BUN, + [DIST_TOOL, "answer", "--stage", "feasibility", "--details", "picked option A", ...extra, "--project-dir", p], + { encoding: "utf-8", env: { ...process.env, AMADEUS_SKIP_HUMAN_PRESENCE_GUARD: "1" } } + ); + return { status: res.status ?? -1, out: `${res.stdout ?? ""}${res.stderr ?? ""}` }; +} + +describe("t488 FR-3 malformed --decision-id is the only new refusal", () => { + test("a malformed decision id exits 1 loudly and emits no QUESTION_ANSWERED", () => { + const p = proj(); + const r = spawnAnswer(p, ["--decision-id", "not-a-ladder-id"]); + expect(r.status).toBe(1); + expect(r.out).toContain("auto-decision-"); + expect(findAllEvents(readAllAuditShards(p), "QUESTION_ANSWERED")).toHaveLength(0); + }); +}); + +// BR-U3-3 contrast pin: the pre-existing checkpoint guard branches are +// UNCHANGED by the new flag — an open approval gate refuses the answer with +// the same message whether or not --decision-id is passed, and nothing is +// emitted on either path. +describe("t488 BR-U3-3: existing checkpoint guards are route-agnostic", () => { + function projWithOpenGate(): string { + const p = proj(); + const statePath = seededStateFile(p); + const content = readFileSync(statePath, "utf-8"); + writeFileSync(statePath, content.replace("- [-] feasibility", "- [?] feasibility")); + return p; + } + + test("open gate refuses a human-route answer (pre-u3 behaviour, unchanged)", () => { + const p = projWithOpenGate(); + const r = spawnAnswer(p, []); + expect(r.status).toBe(1); + expect(r.out).toContain("an approval gate is open"); + expect(findAllEvents(readAllAuditShards(p), "QUESTION_ANSWERED")).toHaveLength(0); + }); + + test("open gate refuses a ladder-route answer with the identical guard message", () => { + const p = projWithOpenGate(); + const r = spawnAnswer(p, ["--decision-id", "auto-decision-0123abcd"]); + expect(r.status).toBe(1); + expect(r.out).toContain("an approval gate is open"); + expect(findAllEvents(readAllAuditShards(p), "QUESTION_ANSWERED")).toHaveLength(0); + }); +}); + +describe("t488 FR-3b: bypass detection over real shards, with falling proof", () => { + test("a human answer under semi mode is detected; rewriting its route to ladder detects nothing", () => { + const p = proj(); + // Answer once BEFORE any autonomy transaction: mode none, never a violation. + answerInProcess(p); + + // Mint a real semi-mode transaction and append it to the SAME shard file + // the emitter wrote, so buffer order matches ledger order. + const intentUuid = "019fc5ac-f0bb-7a5f-8a64-c944b6f76ead"; + const initial = createAutonomyProjection({ intentUuid }); + const repository = createMemoryIntentAutonomyRepository(); + const coordinator = createIntentAutonomyCoordinator({ initialProjection: initial, repository }); + const commanded = coordinator.applyHumanCommand( + { kind: "set-mode", mode: "semi", policies: [] }, + { + targetIntentUuid: intentUuid, + principalId: "principal-1", + humanTurn: { verified: true, eventType: "HUMAN_TURN", actor: "human", turnId: "human-turn-1" }, + commandOccurrenceId: "semi-command-1", + expectedProjectionRevision: initial.projectionRevision, + confirmedDisplayDigest: autonomyDigest("semi-display"), + } + ); + if ("error" in commanded) throw new Error(String(commanded.error)); + const transaction = repository.readTransactions(intentUuid).at(-1); + if (!transaction) throw new Error("no transaction committed"); + const shard = emittedShardPath(p); + appendFileSync( + shard, + `${JSON.stringify({ + schemaVersion: 1, + seq: 90, + cloneId: "t488-clone", + intentId: "t488-intent", + timestamp: new Date().toISOString(), + heading: "Intent Autonomy Transaction Committed", + event: "INTENT_AUTONOMY_TRANSACTION_COMMITTED", + fields: { Transaction: encodeIntentAutonomyTransaction(transaction) }, + })}\n` + ); + + // Ladder answer under semi: never a violation. + answerInProcess(p, ["--decision-id", "auto-decision-0123abcd"]); + // Direct human answer under semi: THE violation FR-3b must surface. + answerInProcess(p); + + const hits = findBypassedQuestionAnswers(readAllAuditShards(p)); + expect(hits).toHaveLength(1); + expect(hits[0]).toMatchObject({ route: "human", autonomyMode: "semi", stage: "feasibility" }); + + // Falling proof: rewrite the fixture's violating Route to ladder on disk — + // the detection count must drop to zero. + writeFileSync(shard, readFileSync(shard, "utf-8").replaceAll('"Resolution Route":"human"', '"Resolution Route":"ladder"')); + expect(findBypassedQuestionAnswers(readAllAuditShards(p))).toHaveLength(0); + }); +}); diff --git a/tests/no-silent-drop/approval.json b/tests/no-silent-drop/approval.json index 65e7bdf10..9489ada3d 100644 --- a/tests/no-silent-drop/approval.json +++ b/tests/no-silent-drop/approval.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "censusDigest": "9744b64f8a7a015050f9f2758a94de35e6775aa37b6acc402faea86d02f06989", + "censusDigest": "18c5207eda3f1e01ede67e413670e47b3a48bf71a8b71b997b29956fae27c2b1", "entries": [ { "fingerprint": "03c9e430ecf3c1b1dabee31906cf795d605efa4db2d7330390274a5c29675a45", @@ -1706,6 +1706,14 @@ "#1979" ] }, + { + "fingerprint": "5a9c97876899658764702b3fd49d3a25767c7f1f37175bae0be9d75ddc7d803c", + "classification": "TP", + "reason": "FR-3c observation-only sweep keeps the last known mode on an undecodable transaction row, erring toward over-detection.", + "issues": [ + "#2378" + ] + }, { "fingerprint": "2afa633b3c13330542ede1e13d22982b6fb842b3b27081b812716f80c7b0ccf7", "classification": "TP", diff --git a/tests/no-silent-drop/events/01KZF9NRBGKJZKY9TGJAJYQ45G.json b/tests/no-silent-drop/events/01KZF9NRBGKJZKY9TGJAJYQ45G.json new file mode 100644 index 000000000..40bc651fb --- /dev/null +++ b/tests/no-silent-drop/events/01KZF9NRBGKJZKY9TGJAJYQ45G.json @@ -0,0 +1,11 @@ +{ + "schemaVersion": 1, + "ulid": "01KZF9NRBGKJZKY9TGJAJYQ45G", + "op": "grant", + "kind": "grandfather", + "fingerprint": "5a9c97876899658764702b3fd49d3a25767c7f1f37175bae0be9d75ddc7d803c", + "ruleId": "NSD001", + "file": "packages/framework/core/tools/amadeus-log.ts", + "reason": "FR-3c observation-only sweep: an undecodable INTENT_AUTONOMY_TRANSACTION_COMMITTED row keeps the last known mode instead of failing the whole after-the-fact read, erring toward over-detection of bypassed questions (pinned by t486/t487).", + "issues": ["#2378"] +} diff --git a/tests/unit/t489-question-route-derivation.test.ts b/tests/unit/t489-question-route-derivation.test.ts new file mode 100644 index 000000000..0f8f2519f --- /dev/null +++ b/tests/unit/t489-question-route-derivation.test.ts @@ -0,0 +1,241 @@ +// covers: file:packages/framework/core/tools/amadeus-log.ts +// +// u3-question-route-observability (FR-3, #2378): pure-function tests for the +// QUESTION_ANSWERED resolution-route derivation and the after-the-fact bypass +// predicate exported by amadeus-log.ts. In-process imports of the CANONICAL +// module (not dist) so the new lines are lcov-measured (spawn-blindspot +// mitigation); the CLI wiring itself is exercised by the t488 integration +// twin. + +import { describe, expect, test } from "bun:test"; +import { + findBypassedQuestionAnswers, + questionAnswerRouteRows, + resolveQuestionRoute, +} from "../../packages/framework/core/tools/amadeus-log.ts"; +import { + autonomyDigest, + createAutonomyProjection, + type GrantScopeDescriptor, + grantIssuanceDisplayDigest, + normalizeDecisionPolicies, +} from "../../packages/framework/core/tools/amadeus-intent-autonomy.ts"; +import { + createIntentAutonomyCoordinator, + createMemoryIntentAutonomyRepository, +} from "../../packages/framework/core/tools/amadeus-intent-autonomy-runtime.ts"; +import { encodeIntentAutonomyTransaction } from "../../packages/framework/core/tools/amadeus-intent-autonomy-replay.ts"; + +const INTENT = "019fc5ac-f0bb-7a5f-8a64-c944b6f76ead"; + +// Mint a REAL semi-mode transaction through the production coordinator (in +// memory, no FS) so the predicate is exercised against the same encoded +// Transaction payload the audit shard carries. +function semiTransactionEncoded(): string { + const initial = createAutonomyProjection({ intentUuid: INTENT }); + const repository = createMemoryIntentAutonomyRepository(); + const coordinator = createIntentAutonomyCoordinator({ initialProjection: initial, repository }); + const result = coordinator.applyHumanCommand( + { kind: "set-mode", mode: "semi", policies: [] }, + { + targetIntentUuid: INTENT, + principalId: "principal-1", + humanTurn: { verified: true, eventType: "HUMAN_TURN", actor: "human", turnId: "human-turn-1" }, + commandOccurrenceId: "semi-command-1", + expectedProjectionRevision: initial.projectionRevision, + confirmedDisplayDigest: autonomyDigest("semi-display"), + } + ); + if ("error" in result) throw new Error(String(result.error)); + const transaction = repository.readTransactions(INTENT).at(-1); + if (!transaction) throw new Error("no transaction committed"); + return encodeIntentAutonomyTransaction(transaction); +} + +// The full-mode twin of semiTransactionEncoded: the bypass predicate treats +// semi and full alike, so the detection needs a real full grant to stand on. +function fullTransactionEncoded(): string { + const initial = createAutonomyProjection({ intentUuid: INTENT }); + const repository = createMemoryIntentAutonomyRepository(); + const coordinator = createIntentAutonomyCoordinator({ initialProjection: initial, repository }); + const scope: GrantScopeDescriptor = { + intentUuid: INTENT, + scopeId: "self-feature", + scopeFingerprint: autonomyDigest("scope-fingerprint"), + normFingerprint: autonomyDigest("norm-fingerprint"), + allowedInteractionKinds: ["stage-gate", "phase-gate", "walking-skeleton", "question"], + permissionBoundaryFingerprint: autonomyDigest("host-policy"), + prohibitedEffects: ["new-permission", "irreversible", "scope-out", "norm-waiver", "quality-waiver"], + }; + const policies = normalizeDecisionPolicies({ + grantIdentitySeed: "grant-seed", + scopeFingerprint: scope.scopeFingerprint, + humanTurnId: "human-turn-1", + policies: [], + }); + const result = coordinator.applyHumanCommand( + { kind: "issue-full", scope, policies }, + { + targetIntentUuid: INTENT, + principalId: "principal-1", + humanTurn: { verified: true, eventType: "HUMAN_TURN", actor: "human", turnId: "human-turn-1" }, + commandOccurrenceId: "full-command-1", + expectedProjectionRevision: initial.projectionRevision, + confirmedDisplayDigest: grantIssuanceDisplayDigest({ + intentUuid: INTENT, + principalId: "principal-1", + scope, + policies, + }), + } + ); + if ("error" in result) throw new Error(String(result.error)); + const transaction = repository.readTransactions(INTENT).at(-1); + if (!transaction) throw new Error("no transaction committed"); + return encodeIntentAutonomyTransaction(transaction); +} + +let seq = 0; +function row(event: string, fields: Record): string { + seq += 1; + return JSON.stringify({ + schemaVersion: 1, + seq, + cloneId: "t489-clone", + intentId: "t489-intent", + timestamp: `2026-08-08T00:0${seq % 10}:00Z`, + heading: event, + event, + fields, + }); +} + +function answerRow(fields: Record): string { + return row("QUESTION_ANSWERED", { Stage: "code-generation", Details: "ok", ...fields }); +} + +function autonomyRow(encoded: string): string { + return row("INTENT_AUTONOMY_TRANSACTION_COMMITTED", { Transaction: encoded }); +} + +describe("t489 resolveQuestionRoute (FR-3a derivation)", () => { + test("no --decision-id derives the human route (existing callers unchanged)", () => { + expect(resolveQuestionRoute(undefined)).toEqual({ route: "human" }); + }); + + test("a malformed decision id is rejected loudly (only new check, FR-3)", () => { + // Not auto-decision-prefixed. + expect(() => resolveQuestionRoute("decision-0123abcd")).toThrow( + /auto-decision-/ + ); + // Prefix alone with an empty id part. + expect(() => resolveQuestionRoute("auto-decision-")).toThrow( + /auto-decision-/ + ); + // Characters outside the safe id alphabet. + expect(() => resolveQuestionRoute("auto-decision-abc def")).toThrow( + /auto-decision-/ + ); + }); + + test("an auto-decision id derives the ladder route and carries the id", () => { + expect(resolveQuestionRoute("auto-decision-0123abcd")).toEqual({ + route: "ladder", + decisionId: "auto-decision-0123abcd", + }); + }); +}); + +describe("t489 questionAnswerRouteRows (BR-U3-5 mode derivation)", () => { + test("derives route and after-the-fact autonomy mode per answer row", () => { + const encoded = semiTransactionEncoded(); + const audit = [ + answerRow({ "Resolution Route": "human" }), // before any autonomy tx -> none + autonomyRow(encoded), // mode becomes semi + answerRow({ "Resolution Route": "human" }), + answerRow({ "Resolution Route": "ladder", "Decision Id": "auto-decision-feed1" }), + ].join("\n"); + const rows = questionAnswerRouteRows(audit); + expect(rows).toHaveLength(3); + expect(rows[0]).toMatchObject({ route: "human", autonomyMode: "none", decisionId: null }); + expect(rows[1]).toMatchObject({ route: "human", autonomyMode: "semi" }); + expect(rows[2]).toMatchObject({ + route: "ladder", + autonomyMode: "semi", + decisionId: "auto-decision-feed1", + }); + }); + + test("an undecodable transaction row keeps the mode the last readable one set", () => { + const audit = [ + autonomyRow(semiTransactionEncoded()), + autonomyRow("not-a-base64url-transaction"), // decode throws — sweep continues + answerRow({ "Resolution Route": "human" }), + ].join("\n"); + const rows = questionAnswerRouteRows(audit); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ route: "human", autonomyMode: "semi" }); + }); + + test("a pre-u3 row without Resolution Route reads as unknown, not an error (BR-U3-4)", () => { + const rows = questionAnswerRouteRows(answerRow({})); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ route: "unknown", decisionId: null }); + }); +}); + +describe("t489 findBypassedQuestionAnswers (FR-3b detection predicate)", () => { + test("flags human answers under semi mode; ladder and pre-semi rows pass", () => { + const encoded = semiTransactionEncoded(); + const audit = [ + answerRow({ "Resolution Route": "human", Stage: "intent-capture" }), // mode none -> ok + autonomyRow(encoded), + answerRow({ "Resolution Route": "ladder", "Decision Id": "auto-decision-feed2" }), // ok + answerRow({ "Resolution Route": "human", Stage: "scope-definition" }), // VIOLATION + answerRow({}), // pre-u3 unknown route -> not counted as a violation + ].join("\n"); + const hits = findBypassedQuestionAnswers(audit); + expect(hits).toHaveLength(1); + expect(hits[0]).toMatchObject({ + route: "human", + autonomyMode: "semi", + stage: "scope-definition", + }); + }); + + test("a human answer under full mode is flagged the same way semi is", () => { + const audit = [ + autonomyRow(fullTransactionEncoded()), + answerRow({ "Resolution Route": "human", Stage: "delivery-planning" }), + ].join("\n"); + const hits = findBypassedQuestionAnswers(audit); + expect(hits).toHaveLength(1); + expect(hits[0]).toMatchObject({ + route: "human", + autonomyMode: "full", + stage: "delivery-planning", + }); + }); + + test("a human answer recorded after an undecodable transaction row is still flagged", () => { + const audit = [ + autonomyRow(semiTransactionEncoded()), + autonomyRow("not-a-base64url-transaction"), + answerRow({ "Resolution Route": "human", Stage: "scope-definition" }), + ].join("\n"); + const hits = findBypassedQuestionAnswers(audit); + expect(hits).toHaveLength(1); + expect(hits[0]).toMatchObject({ autonomyMode: "semi", stage: "scope-definition" }); + }); + + test("falling proof: rewriting the violating row's route to ladder detects nothing", () => { + const encoded = semiTransactionEncoded(); + const violating = [ + autonomyRow(encoded), + answerRow({ "Resolution Route": "human", Stage: "scope-definition" }), + ].join("\n"); + expect(findBypassedQuestionAnswers(violating)).toHaveLength(1); + const rewritten = violating.replaceAll('"Resolution Route":"human"', '"Resolution Route":"ladder"'); + expect(findBypassedQuestionAnswers(rewritten)).toHaveLength(0); + }); +});