diff --git a/packages/framework/core/tools/amadeus-bolt.ts b/packages/framework/core/tools/amadeus-bolt.ts index c62034a9a9..daf487759b 100644 --- a/packages/framework/core/tools/amadeus-bolt.ts +++ b/packages/framework/core/tools/amadeus-bolt.ts @@ -1054,6 +1054,9 @@ function handleSetAutonomy(args: string[], explicitProjectDir?: string): void { if (!["none", "semi", "full"].includes(flags.mode)) { error(`Invalid --mode: ${flags.mode}. Must be 'none', 'semi', or 'full'.`); } + // Reported before the file is read: a mode with no carrier for policies is a + // mismatch worth naming ahead of anything wrong inside the file itself. + if (flags.mode === "none" && flags["policies-file"] !== undefined) error("--policies-file is not accepted with --mode none (policies have no carrier in mode none)."); const pd = resolveBoltProjectDir(explicitProjectDir); diff --git a/packages/framework/core/tools/amadeus-intent-autonomy-production.ts b/packages/framework/core/tools/amadeus-intent-autonomy-production.ts index a230f0fce0..7c6bfcb1ab 100644 --- a/packages/framework/core/tools/amadeus-intent-autonomy-production.ts +++ b/packages/framework/core/tools/amadeus-intent-autonomy-production.ts @@ -10,11 +10,13 @@ import { basename } from "node:path"; import { autonomyDigest, + autonomyScopeFingerprint, authorizeInteraction, createAutonomyProjection, createDecisionOptionEffectRegistry, createInteractionOccurrence, grantIssuanceDisplayDigest, + nonFullCommandDisplayDigest, normalizeDecisionPolicies, SEMI_ROUTINE_INTERACTIONS, type AutonomyMode, @@ -286,7 +288,7 @@ export function fallbackFingerprints( scopeId: string, ): { readonly scopeFingerprint: string; readonly normFingerprint: string } { return { - scopeFingerprint: autonomyDigest({ intentUuid, scopeId }), + scopeFingerprint: autonomyScopeFingerprint(intentUuid, scopeId), normFingerprint: autonomyDigest({ scopeId, rules: "resolved-rules-in-context-v1" }), }; } @@ -393,19 +395,26 @@ function prepareFullGrantCommand(input: PrepareFullGrantCommandInput): { readonl }; } +// The policies stay raw here: planHumanAutonomyCommand owns the one +// normalization call, and the digest is computed over the same raw set on both +// sides so the confirmation compares like with like. function prepareNonFullCommand( before: AutonomyProjection, mode: Exclude, + policies: readonly DecisionPolicyInput[], ): { readonly command: HumanAutonomyCommand; readonly displayDigest: string } { - if (before.currentGrant !== null) { - return { - command: { kind: "revoke-full", targetMode: mode }, - displayDigest: autonomyDigest({ intentUuid: before.intentUuid, mode, revoke: before.currentGrant.grantId }), - }; - } + const revokedGrantId = before.currentGrant?.grantId ?? null; + const displayDigest = nonFullCommandDisplayDigest({ + intentUuid: before.intentUuid, + mode, + revokedGrantId, + policies, + }); return { - command: { kind: "set-mode", mode }, - displayDigest: autonomyDigest({ intentUuid: before.intentUuid, mode }), + command: revokedGrantId === null + ? { kind: "set-mode", mode, policies } + : { kind: "revoke-full", targetMode: mode, policies }, + displayDigest, }; } @@ -441,7 +450,7 @@ export function applyProductionAutonomyMode(input: ApplyProductionAutonomyModeIn command = prepared.command; confirmedDisplayDigest = prepared.issuanceDigest; } else { - const prepared = prepareNonFullCommand(before, input.mode); + const prepared = prepareNonFullCommand(before, input.mode, input.policies ?? []); command = prepared.command; confirmedDisplayDigest = prepared.displayDigest; } diff --git a/packages/framework/core/tools/amadeus-intent-autonomy-runtime.ts b/packages/framework/core/tools/amadeus-intent-autonomy-runtime.ts index 6d3bc71a76..e3ce01dfe5 100644 --- a/packages/framework/core/tools/amadeus-intent-autonomy-runtime.ts +++ b/packages/framework/core/tools/amadeus-intent-autonomy-runtime.ts @@ -17,6 +17,7 @@ import { resolveAutoDecision, revalidateGrantExerciseReservation, SemiAuthority, + semiPoliciesOf, validateResumeCondition, type AutoDecisionRecord, type AutonomyProjection, @@ -791,6 +792,9 @@ export interface IntentAutonomyStatusEnvelope { readonly resumeCondition: ResumeCondition | null; readonly legacyStandingGrantCount: number; readonly unreviewedAutoDecisionCount: number; + // Grant-independent: semi carries its own policies, so the count cannot be + // read off the grant alone. + readonly policyCount: number; readonly terminalLiveCompletionCapable: true; } @@ -810,6 +814,7 @@ export function projectIntentAutonomyStatus(projection: AutonomyProjection): Int resumeCondition: projection.parkEnvelope?.resumeCondition ?? null, legacyStandingGrantCount: projection.legacyStandingGrantIds.length, unreviewedAutoDecisionCount: projection.autoDecisions.filter((decision) => decision.reviewState === "unreviewed").length, + policyCount: grant?.policies.length ?? semiPoliciesOf(projection).length, terminalLiveCompletionCapable: true, }; } diff --git a/packages/framework/core/tools/amadeus-intent-autonomy.ts b/packages/framework/core/tools/amadeus-intent-autonomy.ts index 8be53806c0..4fd1365cb4 100644 --- a/packages/framework/core/tools/amadeus-intent-autonomy.ts +++ b/packages/framework/core/tools/amadeus-intent-autonomy.ts @@ -259,14 +259,25 @@ export function createAutonomyProjection(input: CreateAutonomyProjectionInput): return projection; } +// The non-full arms carry raw policy inputs: their normalization seed is the +// command occurrence, which only exists inside planHumanAutonomyCommand. The +// full arms are normalized earlier because grant issuance seeds off the grant. export type HumanAutonomyCommand = - | { readonly kind: "set-mode"; readonly mode: "none" | "semi" } + | { + readonly kind: "set-mode"; + readonly mode: "none" | "semi"; + readonly policies: readonly DecisionPolicyInput[]; + } | { readonly kind: "issue-full" | "replace-full"; readonly scope: GrantScopeDescriptor; readonly policies: readonly DecisionPolicy[]; } - | { readonly kind: "revoke-full"; readonly targetMode: "none" | "semi" }; + | { + readonly kind: "revoke-full"; + readonly targetMode: "none" | "semi"; + readonly policies: readonly DecisionPolicyInput[]; + }; export interface HumanCommandContext { readonly targetIntentUuid: string; @@ -349,6 +360,87 @@ export function grantIssuanceDisplayDigest(input: GrantIssuanceDisplayDigestInpu return autonomyDigest({ ...input, policySetDigest: autonomyDigest(input.policies) }); } +// The scope id semi policies are normalized against. semi holds no grant scope, +// so the carrier borrows the intent-wide fingerprint that SemiAuthorityScope +// also carries at decision time; were the two to diverge, the confirmed-policy +// rung would filter every policy out without saying so. +export const SEMI_POLICY_SCOPE_ID = "intent"; + +export function autonomyScopeFingerprint(intentUuid: string, scopeId: string): string { + return autonomyDigest({ intentUuid, scopeId }); +} + +interface NonFullCommandDisplayDigestInput { + readonly intentUuid: string; + readonly mode: Exclude; + readonly revokedGrantId: string | null; + readonly policies: readonly DecisionPolicyInput[]; +} + +// One definition for both non-full previews (plain mode set, and grant revoke). +// Same shape as grantIssuanceDisplayDigest, minus principalId and scope: semi +// has no grant scope, and folding the policy set in is what makes a swapped +// policy set visible to the confirmation check below. +export function nonFullCommandDisplayDigest(input: NonFullCommandDisplayDigestInput): string { + return autonomyDigest({ ...input, policySetDigest: autonomyDigest(input.policies) }); +} + +type NonFullAutonomyCommand = Extract; + +function isNonFullCommand(command: HumanAutonomyCommand): command is NonFullAutonomyCommand { + return command.kind === "set-mode" || command.kind === "revoke-full"; +} + +function nonFullTargetMode(command: NonFullAutonomyCommand): Exclude { + return command.kind === "set-mode" ? command.mode : command.targetMode; +} + +// Zero policies keeps the historic single-step confirmation shape; a carried +// policy set has to be the very set the human was shown. +function nonFullConfirmationHolds( + projection: AutonomyProjection, + command: NonFullAutonomyCommand, + context: HumanCommandContext, +): boolean { + if (command.policies.length === 0) return true; + return context.confirmedDisplayDigest === nonFullCommandDisplayDigest({ + intentUuid: projection.intentUuid, + mode: nonFullTargetMode(command), + revokedGrantId: projection.currentGrant?.grantId ?? null, + policies: command.policies, + }); +} + +// The single normalization call site for semi policies. Normalizing anywhere +// else would let the digest the human confirmed and the set that is stored +// drift apart. +function semiPoliciesAfter( + projection: AutonomyProjection, + command: HumanAutonomyCommand, + context: HumanCommandContext, +): readonly DecisionPolicy[] | undefined { + if (!isNonFullCommand(command)) return undefined; + if (nonFullTargetMode(command) !== "semi" || command.policies.length === 0) return undefined; + return normalizeDecisionPolicies({ + grantIdentitySeed: context.commandOccurrenceId, + scopeFingerprint: autonomyScopeFingerprint(projection.intentUuid, SEMI_POLICY_SCOPE_ID), + humanTurnId: context.humanTurn.turnId, + policies: command.policies, + }); +} + +// Absent, never present-and-undefined: an undefined-valued key changes the +// canonical digest but is dropped by JSON round trips, which would break the +// audit replay digest check. +function withSemiPolicies( + projection: AutonomyProjection, + policies: readonly DecisionPolicy[] | undefined, +): AutonomyProjection { + const rest: { semiPolicies?: readonly DecisionPolicy[] } & AutonomyProjection = { ...projection }; + delete rest.semiPolicies; + return policies === undefined ? rest : { ...rest, semiPolicies: policies }; +} + export function planHumanAutonomyCommand( projection: AutonomyProjection, command: HumanAutonomyCommand, @@ -362,12 +454,11 @@ export function planHumanAutonomyCommand( if ((command.kind === "replace-full" || command.kind === "revoke-full") && current === null) { return { ok: false, code: "INVALID_COMMAND" }; } + if (isNonFullCommand(command) && !nonFullConfirmationHolds(projection, command, context)) { + return { ok: false, code: "INVALID_COMMAND" }; + } try { - const afterMode: AutonomyMode = command.kind === "set-mode" - ? command.mode - : command.kind === "revoke-full" - ? command.targetMode - : "full"; + const afterMode: AutonomyMode = isNonFullCommand(command) ? nonFullTargetMode(command) : "full"; const provenance: ModeProvenance = { kind: "human-command", principalId: context.principalId, @@ -383,7 +474,7 @@ export function planHumanAutonomyCommand( ? issueGrant(projection, command, context) : null; const after: AutonomyProjection = { - ...projection, + ...withSemiPolicies(projection, semiPoliciesAfter(projection, command, context)), mode: afterMode, modeProvenance: provenance, currentGrant: issuedGrant, diff --git a/packages/framework/core/tools/amadeus-utility.ts b/packages/framework/core/tools/amadeus-utility.ts index d29edfa975..b229dff4bc 100644 --- a/packages/framework/core/tools/amadeus-utility.ts +++ b/packages/framework/core/tools/amadeus-utility.ts @@ -342,7 +342,7 @@ function renderAutonomyStatus( `Grant: ${autonomy.grant === null ? "none" : `${autonomy.grant.id} (${autonomy.grant.state})`}`, `Grant Scope: ${autonomy.grant?.scopeFingerprint ?? "none"}`, `Workflow State: ${autonomy.workflowExecutionState ?? "completed"}`, - `Policies: ${autonomy.grant?.policyCount ?? 0}`, + `Policies: ${autonomy.policyCount}`, `Unreviewed: ${autonomy.unreviewedAutoDecisionCount}`, `Stop Reason: ${autonomy.suspendedReason ?? "none"}`, `Resume: ${autonomy.resumeCondition === null ? "none" : JSON.stringify(autonomy.resumeCondition)}`, diff --git a/tests/.coverage-patch-allowlist.json b/tests/.coverage-patch-allowlist.json index e4359b2c4a..594f2e3cb4 100644 --- a/tests/.coverage-patch-allowlist.json +++ b/tests/.coverage-patch-allowlist.json @@ -5717,7 +5717,7 @@ "file": "packages/framework/core/tools/amadeus-utility.ts", "selector": { "function": "renderAutonomyStatus", - "fingerprint": "sha256:92a73d7aee92b2287fca5cfded8db53471325ce43e37ba7473fe46c23e071a8e", + "fingerprint": "sha256:afaafc3d3e26d2217d1cb82b26f13e1fb9adebf205ba10f421dda7017c3b94f8", "anchorLines": 13, "targetLines": "1-13" }, diff --git a/tests/.coverage-registry.json b/tests/.coverage-registry.json index 1bd33412af..80b51f516c 100644 --- a/tests/.coverage-registry.json +++ b/tests/.coverage-registry.json @@ -5811,6 +5811,10 @@ "file": "tests/integration/t435-intent-autonomy-production.integration.test.ts", "mechanism": "cli" }, + { + "file": "tests/integration/t455-semi-policy-cli.integration.test.ts", + "mechanism": "cli" + }, { "file": "tests/unit/t33.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 6aef8f2531..1357558e67 100644 --- a/tests/integration/t-coverage-mechanism-ratchet.test.ts +++ b/tests/integration/t-coverage-mechanism-ratchet.test.ts @@ -193,6 +193,7 @@ describe("repository-wide mechanism honesty ratchets", () => { "integration/t429-legacy-goal-migration.integration.test.ts", "integration/t433-autonomy-review-observability.test.ts", "integration/t45.test.ts", + "integration/t455-semi-policy-cli.integration.test.ts", "integration/t49.test.ts", "integration/t51.test.ts", "integration/t66.test.ts", diff --git a/tests/integration/t432-intent-autonomy-runtime.integration.test.ts b/tests/integration/t432-intent-autonomy-runtime.integration.test.ts index 91d6d8738d..2babb40315 100644 --- a/tests/integration/t432-intent-autonomy-runtime.integration.test.ts +++ b/tests/integration/t432-intent-autonomy-runtime.integration.test.ts @@ -284,7 +284,7 @@ describe("Intent autonomy durable coordinator", () => { const initial = createAutonomyProjection({ intentUuid: INTENT }); const repository = createMemoryIntentAutonomyRepository(); const coordinator = createIntentAutonomyCoordinator({ initialProjection: initial, repository }); - const result = coordinator.applyHumanCommand({ kind: "set-mode", mode: "semi" }, { + 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" }, @@ -312,7 +312,7 @@ describe("Intent autonomy durable coordinator", () => { initialProjection: initial, repository: createMemoryIntentAutonomyRepository(), }); - const semiCommand = semiCoordinator.applyHumanCommand({ kind: "set-mode", mode: "semi" }, { + const semiCommand = semiCoordinator.applyHumanCommand({ kind: "set-mode", mode: "semi", policies: [] }, { targetIntentUuid: INTENT, principalId: "principal-1", humanTurn: { verified: true, eventType: "HUMAN_TURN", actor: "human", turnId: "human-turn-1" }, diff --git a/tests/integration/t453-semi-ladder-runtime.integration.test.ts b/tests/integration/t453-semi-ladder-runtime.integration.test.ts index 05cd13755d..b745c5bcc8 100644 --- a/tests/integration/t453-semi-ladder-runtime.integration.test.ts +++ b/tests/integration/t453-semi-ladder-runtime.integration.test.ts @@ -109,7 +109,7 @@ function semiRuntime(): { const initial = createAutonomyProjection({ intentUuid: INTENT }); const repository = createMemoryIntentAutonomyRepository(); const coordinator = createIntentAutonomyCoordinator({ initialProjection: initial, repository }); - const applied = coordinator.applyHumanCommand({ kind: "set-mode", mode: "semi" }, { + const applied = 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" }, diff --git a/tests/integration/t455-semi-policy-cli.integration.test.ts b/tests/integration/t455-semi-policy-cli.integration.test.ts new file mode 100644 index 0000000000..e153574366 --- /dev/null +++ b/tests/integration/t455-semi-policy-cli.integration.test.ts @@ -0,0 +1,198 @@ +// covers: subcommand:amadeus-bolt:set-autonomy, file:packages/framework/core/tools/amadeus-intent-autonomy-production.ts, file:packages/framework/core/tools/amadeus-intent-autonomy.ts +// size: medium + +import { afterEach, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { cleanupTestProject, setupIntegrationProject } from "../harness/fixtures.ts"; +import { resetOtelPerProject } from "../harness/otel-reset.ts"; +import { + applyProductionAutonomyMode, + commitProductionQuestionDecision, + readProductionAutonomyProjection, +} from "../../packages/framework/core/tools/amadeus-intent-autonomy-production.ts"; +import { + autonomyDigest, + autonomyScopeFingerprint, + SEMI_POLICY_SCOPE_ID, + semiPoliciesOf, +} from "../../packages/framework/core/tools/amadeus-intent-autonomy.ts"; + +const BUN = process.execPath; +const SELECTOR = "repair-strategy"; +const OPTION = "minimal-fix"; +const POLICY_JSON = JSON.stringify([ + { sourceText: "Always take the minimal fix", selector: SELECTOR, optionId: OPTION }, +]); + +function run( + projectDir: string, + tool: string, + args: string[], +): { readonly status: number; readonly stdout: string; readonly stderr: string } { + const env = { ...process.env }; + env.AMADEUS_SKIP_ARTIFACT_GUARD = "1"; + env.AMADEUS_SKIP_HUMAN_PRESENCE_GUARD = "1"; + const result = spawnSync(BUN, [join(projectDir, ".claude", "tools", tool), ...args, "--project-dir", projectDir], { + cwd: projectDir, + encoding: "utf8", + env, + }); + return { status: result.status ?? -1, stdout: result.stdout ?? "", stderr: result.stderr ?? "" }; +} + +function recordDir(projectDir: string): string { + const intents = join(projectDir, "amadeus", "spaces", "default", "intents"); + return join(intents, readFileSync(join(intents, "active-intent"), "utf8").trim()); +} + +function state(projectDir: string): string { + return readFileSync(join(recordDir(projectDir), "amadeus-state.md"), "utf8"); +} + +function appendHumanTurn(projectDir: string): void { + const auditDir = join(recordDir(projectDir), "audit"); + mkdirSync(auditDir, { recursive: true }); + const path = join(auditDir, "semi-policy-cli-test.jsonl"); + const seq = existsSync(path) ? readFileSync(path, "utf8").split("\n").filter(Boolean).length + 1 : 1; + appendFileSync(path, `${JSON.stringify({ + schemaVersion: 1, + seq, + cloneId: "semi-policy-cli-test", + intentId: "semi-policy-cli-test", + timestamp: new Date().toISOString(), + heading: "Human Turn", + event: "HUMAN_TURN", + fields: {}, + })}\n`); +} + +function policiesFile(projectDir: string): string { + const path = join(projectDir, "semi-policies.json"); + writeFileSync(path, POLICY_JSON); + return path; +} + +function bornProject(): string { + const projectDir = setupIntegrationProject({ noAidlcDocs: true, stripEnvScope: true }); + const result = spawnSync( + BUN, + [ + join(projectDir, ".claude", "tools", "amadeus-utility.ts"), + "intent-birth", + "--scope", + "feature", + "--project-dir", + projectDir, + ], + { cwd: projectDir, encoding: "utf8", env: { ...process.env } }, + ); + expect(result.status ?? -1).toBe(0); + return projectDir; +} + +let projectDir = ""; +afterEach(() => { + resetOtelPerProject(); + if (projectDir) cleanupTestProject(projectDir); + projectDir = ""; +}); + +describe("semi policy carrier through the CLI", () => { + test("--mode semi --policies-file lands a policy that settles the question on the confirmed-policy rung", () => { + projectDir = bornProject(); + appendHumanTurn(projectDir); + + const applied = run(projectDir, "amadeus-bolt.ts", [ + "set-autonomy", + "--mode", + "semi", + "--policies-file", + policiesFile(projectDir), + ]); + expect(applied.status).toBe(0); + + const projection = readProductionAutonomyProjection(projectDir); + expect(projection).not.toBeNull(); + if (projection === null) return; + expect(projection.mode).toBe("semi"); + expect(projection.currentGrant).toBeNull(); + const stored = semiPoliciesOf(projection); + expect(stored.length).toBe(1); + expect(stored[0]!.selector).toBe(SELECTOR); + expect(stored[0]!.scopeFingerprint).toBe(autonomyScopeFingerprint(projection.intentUuid, SEMI_POLICY_SCOPE_ID)); + + const decision = commitProductionQuestionDecision({ + projectDir, + stage: "code-generation", + phase: "construction", + graphRevision: `sha256:${"0".repeat(64)}`, + questionId: "semi-policy-question-1", + selector: SELECTOR, + question: "Which repair strategy?", + optionIds: [OPTION, "broad-refactor"], + recommendedOptionId: OPTION, + }); + expect(decision.kind).toBe("decided"); + if (decision.kind !== "decided") return; + expect(decision.decision.basisKind).toBe("confirmed-policy"); + expect(decision.decision.selectedOptionId).toBe(OPTION); + }); + + test("the extended set-mode command survives the audit replay unchanged", () => { + projectDir = bornProject(); + appendHumanTurn(projectDir); + + const written = applyProductionAutonomyMode({ + projectDir, + stateContent: state(projectDir), + mode: "semi", + policies: JSON.parse(POLICY_JSON), + }); + expect(written.ok).toBe(true); + if (!written.ok) return; + + // A fresh read rebuilds the projection from the audit shards, so an + // identical digest is the replay round trip, not a cached object. + const replayed = readProductionAutonomyProjection(projectDir); + expect(replayed).not.toBeNull(); + if (replayed === null) return; + expect(autonomyDigest(replayed)).toBe(autonomyDigest(written.projection)); + expect(semiPoliciesOf(replayed)).toEqual(semiPoliciesOf(written.projection)); + expect(semiPoliciesOf(replayed).length).toBe(1); + }); + + test("--mode none --policies-file is refused out loud instead of dropping the policies", () => { + projectDir = bornProject(); + appendHumanTurn(projectDir); + + const refused = run(projectDir, "amadeus-bolt.ts", [ + "set-autonomy", + "--mode", + "none", + "--policies-file", + policiesFile(projectDir), + ]); + expect(refused.status).not.toBe(0); + expect(refused.stderr).toContain("--policies-file is not accepted with --mode none"); + }); + + test("--status counts the semi policies even though there is no grant", () => { + projectDir = bornProject(); + appendHumanTurn(projectDir); + expect(run(projectDir, "amadeus-bolt.ts", [ + "set-autonomy", + "--mode", + "semi", + "--policies-file", + policiesFile(projectDir), + ]).status).toBe(0); + + const status = run(projectDir, "amadeus-utility.ts", ["status"]); + expect(status.status).toBe(0); + expect(status.stdout).toContain("Autonomy: semi"); + expect(status.stdout).toContain("Grant: none"); + expect(status.stdout).toContain("Policies: 1"); + }); +}); diff --git a/tests/no-silent-drop/baseline.json b/tests/no-silent-drop/baseline.json index fca9eede2b..b35ab6fbf0 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": "ac38615cad84f14e8f26b107ef0099cf9c1a9a90dc416856e6a2c81885b5c7a1" + "previousDigest": "10ba93dfb6b67672fed17a7cbd41d732812108ad746596155530f7dd2af74093" }, "entries": [ { diff --git a/tests/no-silent-drop/exemptions.json b/tests/no-silent-drop/exemptions.json index 8f448346fe..9eb46dd22d 100644 --- a/tests/no-silent-drop/exemptions.json +++ b/tests/no-silent-drop/exemptions.json @@ -1,5 +1,5 @@ { "schemaVersion": 1, - "previousDigest": "7ebd1ff59a26aa7008fd3667bf1f8e3ae8996f63262df3a89e88332ad4800b7b", + "previousDigest": "b16bc510a1a482e913a5a4d31ef319028735a0ee4e8e30316df754065b9a2f9f", "entries": [] } diff --git a/tests/unit/t431-intent-autonomy.test.ts b/tests/unit/t431-intent-autonomy.test.ts index afa6ed875f..258c6b49da 100644 --- a/tests/unit/t431-intent-autonomy.test.ts +++ b/tests/unit/t431-intent-autonomy.test.ts @@ -208,7 +208,7 @@ describe("Intent autonomy mode and grant aggregate", () => { test("headless and harness facts are not accepted as a mode command", () => { const initial = createAutonomyProjection({ intentUuid: INTENT }); - const invalid = planHumanAutonomyCommand(initial, { kind: "set-mode", mode: "semi" }, { + const invalid = planHumanAutonomyCommand(initial, { kind: "set-mode", mode: "semi", policies: [] }, { ...context(initial, autonomyDigest("semi-display")), humanTurn: null as never, }); @@ -217,7 +217,7 @@ describe("Intent autonomy mode and grant aggregate", () => { test("none to semi is a human-only transition with no grant", () => { const initial = createAutonomyProjection({ intentUuid: INTENT }); - const plan = planHumanAutonomyCommand(initial, { kind: "set-mode", mode: "semi" }, context(initial, autonomyDigest("semi-display"))); + const plan = planHumanAutonomyCommand(initial, { kind: "set-mode", mode: "semi", policies: [] }, context(initial, autonomyDigest("semi-display"))); expect(plan.ok).toBe(true); if (!plan.ok) return; expect(plan.after.mode).toBe("semi"); @@ -337,14 +337,14 @@ describe("gate and question decision contract", () => { test("semi keeps the walking skeleton with the human", () => { const initial = createAutonomyProjection({ intentUuid: INTENT }); - const plan = planHumanAutonomyCommand(initial, { kind: "set-mode", mode: "semi" }, context(initial, autonomyDigest("semi"))); + const plan = planHumanAutonomyCommand(initial, { kind: "set-mode", mode: "semi", policies: [] }, context(initial, autonomyDigest("semi"))); if (!plan.ok) throw new Error(plan.code); expect(authorizeInteraction(plan.after, occurrence("walking-skeleton", ["approve"]), semiScope()).kind).toBe("human-required"); }); test("semi authorizes phase-internal stage gates and questions alike", () => { const initial = createAutonomyProjection({ intentUuid: INTENT }); - const plan = planHumanAutonomyCommand(initial, { kind: "set-mode", mode: "semi" }, context(initial, autonomyDigest("semi"))); + const plan = planHumanAutonomyCommand(initial, { kind: "set-mode", mode: "semi", policies: [] }, context(initial, autonomyDigest("semi"))); if (!plan.ok) throw new Error(plan.code); expect(authorizeInteraction(plan.after, occurrence("stage-gate", ["approve"]), semiScope()).kind).toBe("semi-authority"); expect(authorizeInteraction(plan.after, occurrence("question"), semiScope()).kind).toBe("semi-authority"); diff --git a/tests/unit/t451-semi-authority.test.ts b/tests/unit/t451-semi-authority.test.ts index a2ebb96570..e8137fd670 100644 --- a/tests/unit/t451-semi-authority.test.ts +++ b/tests/unit/t451-semi-authority.test.ts @@ -40,7 +40,7 @@ function context(projection: AutonomyProjection, confirmedDisplayDigest: string) function semiProjection(): AutonomyProjection { const initial = createAutonomyProjection({ intentUuid: INTENT }); - const plan = planHumanAutonomyCommand(initial, { kind: "set-mode", mode: "semi" }, context(initial, autonomyDigest("semi"))); + const plan = planHumanAutonomyCommand(initial, { kind: "set-mode", mode: "semi", policies: [] }, context(initial, autonomyDigest("semi"))); if (!plan.ok) throw new Error(plan.code); return plan.after; } diff --git a/tests/unit/t452-authorize-interaction-semi.test.ts b/tests/unit/t452-authorize-interaction-semi.test.ts index f5c17d44d2..26a2a627ea 100644 --- a/tests/unit/t452-authorize-interaction-semi.test.ts +++ b/tests/unit/t452-authorize-interaction-semi.test.ts @@ -49,7 +49,7 @@ function context(projection: AutonomyProjection, confirmedDisplayDigest: string) function semiProjection(): AutonomyProjection { const initial = createAutonomyProjection({ intentUuid: INTENT }); - const plan = planHumanAutonomyCommand(initial, { kind: "set-mode", mode: "semi" }, context(initial, autonomyDigest("semi"))); + const plan = planHumanAutonomyCommand(initial, { kind: "set-mode", mode: "semi", policies: [] }, context(initial, autonomyDigest("semi"))); if (!plan.ok) throw new Error(plan.code); return plan.after; } diff --git a/tests/unit/t454-semi-policy-carrier.test.ts b/tests/unit/t454-semi-policy-carrier.test.ts new file mode 100644 index 0000000000..c003f88a8b --- /dev/null +++ b/tests/unit/t454-semi-policy-carrier.test.ts @@ -0,0 +1,198 @@ +// covers: file:packages/framework/core/tools/amadeus-intent-autonomy.ts +// size: small + +import { describe, expect, test } from "bun:test"; + +import { + autonomyDigest, + autonomyScopeFingerprint, + createAutonomyProjection, + grantIssuanceDisplayDigest, + nonFullCommandDisplayDigest, + normalizeDecisionPolicies, + planHumanAutonomyCommand, + SEMI_POLICY_SCOPE_ID, + semiPoliciesOf, + type AutonomyProjection, + type DecisionPolicyInput, + type GrantScopeDescriptor, + type HumanCommandContext, +} from "../../packages/framework/core/tools/amadeus-intent-autonomy.ts"; + +const INTENT = "019fc5ac-f0bb-7a5f-8a64-c944b6f76ead"; +const HUMAN = { verified: true, eventType: "HUMAN_TURN", actor: "human", turnId: "human-turn-1" } as const; +const NORM = autonomyDigest("norm-v1"); +const SCOPE_FP = autonomyDigest("self-feature"); +const OCCURRENCE_ID = "command-1"; + +const POLICY: DecisionPolicyInput = { + sourceText: "Prefer the safe accepted option", + selector: "selector-1", + optionId: "accept", +}; + +function scope(): GrantScopeDescriptor { + return { + intentUuid: INTENT, + scopeId: "self-feature", + scopeFingerprint: SCOPE_FP, + normFingerprint: NORM, + allowedInteractionKinds: ["stage-gate", "phase-gate", "walking-skeleton", "question"], + permissionBoundaryFingerprint: autonomyDigest("host-policy"), + prohibitedEffects: ["new-permission", "irreversible", "scope-out", "norm-waiver", "quality-waiver"], + }; +} + +function context(projection: AutonomyProjection, confirmedDisplayDigest: string): HumanCommandContext { + return { + targetIntentUuid: INTENT, + principalId: "principal-1", + humanTurn: HUMAN, + commandOccurrenceId: OCCURRENCE_ID, + expectedProjectionRevision: projection.projectionRevision, + confirmedDisplayDigest, + }; +} + +function nonFullDigest( + mode: "none" | "semi", + policies: readonly DecisionPolicyInput[], + revokedGrantId: string | null = null, +): string { + return nonFullCommandDisplayDigest({ intentUuid: INTENT, mode, revokedGrantId, policies }); +} + +// The write side is only reachable through a human command, so every case +// starts from a fresh projection and names the digest the human confirmed. +function applySetMode( + mode: "none" | "semi", + policies: readonly DecisionPolicyInput[], +): AutonomyProjection { + const initial = createAutonomyProjection({ intentUuid: INTENT }); + const plan = planHumanAutonomyCommand( + initial, + { kind: "set-mode", mode, policies }, + context(initial, nonFullDigest(mode, policies)), + ); + if (!plan.ok) throw new Error(`plan rejected: ${plan.code}`); + return plan.after; +} + +function fullProjection(): AutonomyProjection { + const initial = createAutonomyProjection({ intentUuid: INTENT }); + const normalized = normalizeDecisionPolicies({ + grantIdentitySeed: "grant-seed", + scopeFingerprint: SCOPE_FP, + humanTurnId: HUMAN.turnId, + policies: [POLICY], + }); + const digest = grantIssuanceDisplayDigest({ + intentUuid: INTENT, + principalId: "principal-1", + scope: scope(), + policies: normalized, + }); + const plan = planHumanAutonomyCommand( + initial, + { kind: "issue-full", scope: scope(), policies: normalized }, + context(initial, digest), + ); + if (!plan.ok) throw new Error(`grant issuance rejected: ${plan.code}`); + return plan.after; +} + +describe("t454 semi policy carrier", () => { + // C8 write side: the five rows of the input -> after.semiPolicies table. + test("set-mode semi with policies stores the normalized policy set", () => { + const after = applySetMode("semi", [POLICY]); + const stored = semiPoliciesOf(after); + expect(stored.length).toBe(1); + expect(stored[0]!.selector).toBe("selector-1"); + expect(stored[0]!.normalizedOptionRule).toEqual({ kind: "exact-option", optionId: "accept" }); + expect(stored[0]!.confirmedByHumanTurnId).toBe(HUMAN.turnId); + // The policy has to land in the same fingerprint space the semi authority + // carries at decision time, or the confirmed-policy rung filters it out. + expect(stored[0]!.scopeFingerprint).toBe(autonomyScopeFingerprint(INTENT, SEMI_POLICY_SCOPE_ID)); + expect(stored[0]!.policyId).toBe( + normalizeDecisionPolicies({ + grantIdentitySeed: OCCURRENCE_ID, + scopeFingerprint: autonomyScopeFingerprint(INTENT, SEMI_POLICY_SCOPE_ID), + humanTurnId: HUMAN.turnId, + policies: [POLICY], + })[0]!.policyId, + ); + }); + + test("zero policies is the same state as no field at all", () => { + const after = applySetMode("semi", []); + expect(after.semiPolicies).toBeUndefined(); + expect(semiPoliciesOf(after)).toEqual([]); + }); + + test("set-mode none leaves the carrier unset", () => { + const after = applySetMode("none", []); + expect(after.mode).toBe("none"); + expect(after.semiPolicies).toBeUndefined(); + }); + + test("revoke-full to semi follows the same rule as set-mode semi", () => { + const before = fullProjection(); + const grantId = before.currentGrant!.grantId; + const plan = planHumanAutonomyCommand( + before, + { kind: "revoke-full", targetMode: "semi", policies: [POLICY] }, + context(before, nonFullDigest("semi", [POLICY], grantId)), + ); + expect(plan.ok).toBe(true); + if (!plan.ok) return; + expect(plan.after.mode).toBe("semi"); + expect(semiPoliciesOf(plan.after).length).toBe(1); + expect(plan.after.currentGrant).toBeNull(); + }); + + test("issue-full keeps its policies on the grant, not on the carrier", () => { + const after = fullProjection(); + expect(after.semiPolicies).toBeUndefined(); + expect(after.currentGrant!.policies.length).toBe(1); + }); + + // C9: one digest definition, policy set folded in. + test("the non-full display digest moves with the policy set and is stable", () => { + const other: DecisionPolicyInput = { ...POLICY, optionId: "defer" }; + expect(nonFullDigest("semi", [POLICY])).toBe(nonFullDigest("semi", [POLICY])); + expect(nonFullDigest("semi", [POLICY])).not.toBe(nonFullDigest("semi", [other])); + expect(nonFullDigest("semi", [POLICY])).not.toBe(nonFullDigest("semi", [])); + expect(nonFullDigest("semi", [POLICY])).not.toBe(nonFullDigest("none", [POLICY])); + expect(nonFullDigest("semi", [POLICY])).not.toBe(nonFullDigest("semi", [POLICY], "intent-grant-abc")); + }); + + // Q1: the confirmation digest is checked exactly when policies are carried. + test("a non-empty policy set must match the confirmed digest", () => { + const initial = createAutonomyProjection({ intentUuid: INTENT }); + const matched = planHumanAutonomyCommand( + initial, + { kind: "set-mode", mode: "semi", policies: [POLICY] }, + context(initial, nonFullDigest("semi", [POLICY])), + ); + expect(matched.ok).toBe(true); + + const mismatched = planHumanAutonomyCommand( + initial, + { kind: "set-mode", mode: "semi", policies: [POLICY] }, + context(initial, nonFullDigest("semi", [{ ...POLICY, optionId: "defer" }])), + ); + expect(mismatched.ok).toBe(false); + if (mismatched.ok) return; + expect(mismatched.code).toBe("INVALID_COMMAND"); + }); + + test("an empty policy set keeps the single-step confirmation shape", () => { + const initial = createAutonomyProjection({ intentUuid: INTENT }); + const plan = planHumanAutonomyCommand( + initial, + { kind: "set-mode", mode: "semi", policies: [] }, + context(initial, autonomyDigest("some-other-preview")), + ); + expect(plan.ok).toBe(true); + }); +});