feat: add compaction enforcement controls

This commit is contained in:
v-openrig-build committed 2026-08-09 09:55:27 +00:00
1 parent fa81f15e06
commit 56858cbea5
19 files changed
+2185 -35

No files matched your search

@@ -0,0 +1,124 @@
import { Command } from "commander";
import { DaemonClient, terminalAuthHeaders } from "../client.js";
import { getDaemonStatus, getDaemonUrl, type LifecycleDeps } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
export interface CompactionControlDeps {
lifecycleDeps: LifecycleDeps;
clientFactory: (url: string) => Pick<DaemonClient, "get" | "post">;
}
function defaultDeps(): CompactionControlDeps {
return {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
}
async function getClient(deps: CompactionControlDeps) {
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
console.error("Daemon is not running. Start it with: rig daemon start");
process.exitCode = 1;
return null;
}
return deps.clientFactory(getDaemonUrl(status));
}
function printResponse(
response: { status: number; data: unknown },
json: boolean | undefined,
success: string,
): void {
if (json) {
console.log(JSON.stringify(response.data));
} else if (response.status < 400) {
console.log(success);
} else {
const data = response.data as { error?: string; message?: string };
console.error(data.message ?? data.error ?? `Request failed (HTTP ${response.status})`);
}
if (response.status >= 400) process.exitCode = response.status >= 500 ? 2 : 1;
}
export function compactionControlCommand(depsOverride?: CompactionControlDeps): Command {
const command = new Command("compaction-control")
.description("Create, inspect, and clear generation-scoped Claude compaction decisions");
const deps = () => depsOverride ?? defaultDeps();
command.addCommand(
new Command("hold")
.argument("<session>", "Target Claude session")
.requiredOption("--reason <text>", "Why automatic compaction must not act")
.option("--json", "JSON output for agents")
.action(async (session: string, options: { reason: string; json?: boolean }) => {
const client = await getClient(deps());
if (!client) return;
const response = await client.post("/api/compaction/control", {
session,
direction: "hold",
reason: options.reason,
}, { headers: terminalAuthHeaders() });
printResponse(response, options.json, `Compaction hold created for ${session}.`);
}),
);
command.addCommand(
new Command("authorize")
.argument("<session>", "Target Claude session")
.requiredOption(
"--automatic-reason <reason>",
"One refusal to lift: disabled, post_restore_cooldown, or stale_generation",
)
.requiredOption("--reason <text>", "Why this one attempt is authorized")
.option("--json", "JSON output for agents")
.action(async (
session: string,
options: { automaticReason: string; reason: string; json?: boolean },
) => {
const client = await getClient(deps());
if (!client) return;
const response = await client.post("/api/compaction/control", {
session,
direction: "authorize",
automaticReason: options.automaticReason,
reason: options.reason,
}, { headers: terminalAuthHeaders() });
printResponse(response, options.json, `One compaction attempt authorized for ${session}.`);
}),
);
command.addCommand(
new Command("list")
.option("--session <session>", "Limit decisions to one session")
.option("--json", "JSON output for agents")
.action(async (options: { session?: string; json?: boolean }) => {
const client = await getClient(deps());
if (!client) return;
const query = options.session ? `?session=${encodeURIComponent(options.session)}` : "";
const response = await client.get(`/api/compaction/control${query}`, {
headers: terminalAuthHeaders(),
});
printResponse(response, options.json, "Compaction decisions listed.");
}),
);
command.addCommand(
new Command("clear")
.argument("<decision-id>", "Decision ID")
.requiredOption("--reason <text>", "Why the decision is being cleared")
.option("--json", "JSON output for agents")
.action(async (decisionId: string, options: { reason: string; json?: boolean }) => {
const client = await getClient(deps());
if (!client) return;
const response = await client.post(
`/api/compaction/control/${encodeURIComponent(decisionId)}/clear`,
{ reason: options.reason },
{ headers: terminalAuthHeaders() },
);
printResponse(response, options.json, `Compaction decision ${decisionId} cleared.`);
}),
);
return command;
}
+2 -1
View File
@@ -72,7 +72,8 @@ Keys:
workflow.* exception_routing (orchestrator | human_only — the maturity-dial host default)
policies.claude_compaction.*
enabled, threshold_percent, compact_instruction,
message_inline, message_file_path
message_inline, message_file_path,
authorize_ttl_minutes
policies.idle_gate_qitem.scan_interval_seconds
policies.idle_gate_qitem.active_wake_interval_seconds
snapshots.periodic.* enabled, interval_seconds, retention_keep
+19
View File
@@ -116,6 +116,7 @@ export interface RiggedConfig {
messageInline: string;
messageFilePath: string;
postRestoreAuditInstruction: string;
authorizeTtlMinutes: number;
};
idleGateQitem: {
scanIntervalSeconds: number;
@@ -233,6 +234,7 @@ const DEFAULTS = {
messageInline: DEFAULT_CLAUDE_COMPACTION_RESTORE_INSTRUCTION,
messageFilePath: DEFAULT_CLAUDE_COMPACTION_EXTRA_INSTRUCTION_FILE_PATH,
postRestoreAuditInstruction: DEFAULT_CLAUDE_COMPACTION_POST_RESTORE_AUDIT_INSTRUCTION,
authorizeTtlMinutes: 15,
},
idleGateQitem: {
scanIntervalSeconds: 60,
@@ -326,6 +328,7 @@ export const VALID_KEYS = [
"policies.claude_compaction.message_inline",
"policies.claude_compaction.message_file_path",
"policies.claude_compaction.post_restore_audit_instruction",
"policies.claude_compaction.authorize_ttl_minutes",
"policies.idle_gate_qitem.scan_interval_seconds",
"policies.idle_gate_qitem.active_wake_interval_seconds",
"snapshots.periodic.enabled",
@@ -397,6 +400,7 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
"policies.claude_compaction.message_inline": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_INLINE" },
"policies.claude_compaction.message_file_path": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_FILE_PATH" },
"policies.claude_compaction.post_restore_audit_instruction": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_POST_RESTORE_AUDIT_INSTRUCTION" },
"policies.claude_compaction.authorize_ttl_minutes": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_AUTHORIZE_TTL_MINUTES" },
"policies.idle_gate_qitem.scan_interval_seconds": { primary: "OPENRIG_POLICIES_IDLE_GATE_QITEM_SCAN_INTERVAL_SECONDS" },
"policies.idle_gate_qitem.active_wake_interval_seconds": { primary: "OPENRIG_POLICIES_IDLE_GATE_QITEM_ACTIVE_WAKE_INTERVAL_SECONDS" },
"snapshots.periodic.enabled": { primary: "OPENRIG_SNAPSHOTS_PERIODIC_ENABLED" },
@@ -456,6 +460,7 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
"policies.claude_compaction.message_inline": ["policies", "claudeCompaction", "messageInline"],
"policies.claude_compaction.message_file_path": ["policies", "claudeCompaction", "messageFilePath"],
"policies.claude_compaction.post_restore_audit_instruction": ["policies", "claudeCompaction", "postRestoreAuditInstruction"],
"policies.claude_compaction.authorize_ttl_minutes": ["policies", "claudeCompaction", "authorizeTtlMinutes"],
"policies.idle_gate_qitem.scan_interval_seconds": ["policies", "idleGateQitem", "scanIntervalSeconds"],
"policies.idle_gate_qitem.active_wake_interval_seconds": ["policies", "idleGateQitem", "activeWakeIntervalSeconds"],
"snapshots.periodic.enabled": ["snapshots", "periodic", "enabled"],
@@ -635,6 +640,19 @@ const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string |
);
}
},
"policies.claude_compaction.authorize_ttl_minutes": (raw, coerced) => {
const trimmed = (raw ?? "").trim();
if (!/^-?\d+$/.test(trimmed) || typeof coerced !== "number" || !Number.isInteger(coerced)) {
throw new Error(
`Invalid value for policies.claude_compaction.authorize_ttl_minutes: expected an integer in [1, 60], got "${raw}"`,
);
}
if (coerced < 1 || coerced > 60) {
throw new Error(
`Invalid value for policies.claude_compaction.authorize_ttl_minutes: must be in [1, 60], got ${coerced}`,
);
}
},
"snapshots.periodic.interval_seconds": (raw, coerced) => {
if (!/^-?\d+$/.test((raw ?? "").trim())) {
throw new Error(
@@ -839,6 +857,7 @@ export class ConfigStore {
messageInline: v("policies.claude_compaction.message_inline") as string,
messageFilePath: v("policies.claude_compaction.message_file_path") as string,
postRestoreAuditInstruction: v("policies.claude_compaction.post_restore_audit_instruction") as string,
authorizeTtlMinutes: v("policies.claude_compaction.authorize_ttl_minutes") as number,
},
idleGateQitem: {
scanIntervalSeconds: v("policies.idle_gate_qitem.scan_interval_seconds") as number,
+6
View File
@@ -77,6 +77,10 @@ import { restoreCheckCommand } from "./commands/restore-check.js";
import { restorePacketCommand, type RestorePacketDeps } from "./commands/restore-packet.js";
import { compactPlanCommand, type CompactPlanDeps } from "./commands/compact-plan.js";
import { compactCommand, type CompactDeps } from "./commands/compact.js";
import {
compactionControlCommand,
type CompactionControlDeps,
} from "./commands/compaction-control.js";
import { heartbeatCommand, type HeartbeatDeps } from "./commands/heartbeat.js";
import { seatCommand, handoverCommand, type SeatDeps } from "./commands/seat.js";
import { rigPolicyCommand, type RigPolicyDeps } from "./commands/rig-policy.js";
@@ -144,6 +148,7 @@ export interface ProgramDeps {
restorePacketDeps?: RestorePacketDeps;
compactPlanDeps?: CompactPlanDeps;
compactDeps?: CompactDeps;
compactionControlDeps?: CompactionControlDeps;
heartbeatDeps?: HeartbeatDeps;
seatDeps?: SeatDeps;
rigPolicyDeps?: RigPolicyDeps;
@@ -235,6 +240,7 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
program.addCommand(restorePacketCommand(depsOverride?.restorePacketDeps));
program.addCommand(compactPlanCommand(depsOverride?.compactPlanDeps));
program.addCommand(compactCommand(depsOverride?.compactDeps));
program.addCommand(compactionControlCommand(depsOverride?.compactionControlDeps));
program.addCommand(heartbeatCommand(depsOverride?.heartbeatDeps));
program.addCommand(seatCommand(depsOverride?.seatDeps));
program.addCommand(handoverCommand(depsOverride?.seatDeps));
+112
View File
@@ -6,6 +6,7 @@ import http from "node:http";
import { Command } from "commander";
import { ConfigStore, VALID_KEYS } from "../src/config-store.js";
import { configCommand } from "../src/commands/config.js";
import { SETTINGS_VALID_KEYS } from "../../daemon/src/domain/user-settings/settings-store.js";
import { DaemonClient } from "../src/client.js";
import { STATE_FILE, type DaemonState } from "../src/daemon-lifecycle.js";
import type { StatusDeps } from "../src/commands/status.js";
@@ -426,3 +427,114 @@ describe("Config CLI", () => {
expect(combined).toContain("policies.idle_gate_qitem.active_wake_interval_seconds");
});
});
describe("W4 compaction authorization TTL config", () => {
const key = "policies.claude_compaction.authorize_ttl_minutes";
const envKey = "OPENRIG_POLICIES_CLAUDE_COMPACTION_AUTHORIZE_TTL_MINUTES";
let tmpDir: string;
let configPath: string;
let savedEnv: string | undefined;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "w4-config-test-"));
configPath = join(tmpDir, "config.json");
savedEnv = process.env[envKey];
delete process.env[envKey];
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
if (savedEnv === undefined) delete process.env[envKey];
else process.env[envKey] = savedEnv;
});
it("keeps the daemon and CLI compaction key registries exactly aligned", () => {
const compactionKeys = (keys: readonly string[]) =>
keys.filter((candidate) => candidate.startsWith("policies.claude_compaction."));
expect(compactionKeys(VALID_KEYS)).toEqual(compactionKeys(SETTINGS_VALID_KEYS));
expect(VALID_KEYS).toContain(key);
});
it("defaults to 15 and exposes the typed policy field", () => {
const store = new ConfigStore(configPath);
expect(store.resolveWithSource(key)).toEqual({
value: 15,
source: "default",
defaultValue: 15,
});
expect(store.resolve().policies.claudeCompaction.authorizeTtlMinutes).toBe(15);
});
it("accepts a valid environment override", () => {
process.env[envKey] = "30";
expect(new ConfigStore(configPath).resolveWithSource(key)).toEqual({
value: 30,
source: "env",
defaultValue: 15,
});
});
it.each(["0", "61", "15.5", "bad"])(
"invalid env %s falls back to 15 and reports loudly",
(raw) => {
process.env[envKey] = raw;
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
try {
expect(new ConfigStore(configPath).resolveWithSource(key)).toEqual({
value: 15,
source: "default",
defaultValue: 15,
});
expect(stderr.mock.calls.some((call) => String(call[0]).includes(
`env override for ${key} rejected`,
))).toBe(true);
} finally {
stderr.mockRestore();
}
},
);
it.each([0, 61, 15.5, "bad"])(
"invalid file value %s falls back to 15 and reports loudly",
(raw) => {
writeFileSync(configPath, JSON.stringify({
policies: { claudeCompaction: { authorizeTtlMinutes: raw } },
}));
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
try {
expect(new ConfigStore(configPath).resolveWithSource(key)).toEqual({
value: 15,
source: "default",
defaultValue: 15,
});
expect(stderr.mock.calls.some((call) => String(call[0]).includes(
`file value for ${key} rejected`,
))).toBe(true);
} finally {
stderr.mockRestore();
}
},
);
it("accepts exactly 1..60 through set and rejects values outside the range", () => {
const store = new ConfigStore(configPath);
for (const value of ["1", "15", "60"]) {
store.set(key, value);
expect(store.get(key)).toBe(Number(value));
}
for (const value of ["0", "61", "15.5", "bad"]) {
expect(() => store.set(key, value)).toThrow(/integer|\[1, 60\]|number/i);
}
});
it("prints the literal key in rig config help", () => {
const command = configCommand(configPath);
let output = "";
command.configureOutput({
writeOut: (text) => { output += text; },
writeErr: () => {},
});
command.outputHelp();
expect(output).toContain(key);
});
});
@@ -0,0 +1,165 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Command } from "commander";
import { createProgram } from "../src/index.js";
const COMMAND_MODULE = ["../src/commands", "compaction-control.js"].join("/");
vi.mock("../src/daemon-lifecycle.js", async () => {
const actual = await vi.importActual<Record<string, unknown>>("../src/daemon-lifecycle.js");
return {
...actual,
getDaemonStatus: vi.fn(async () => ({ state: "running", healthy: true, pid: 1234, port: 7433 })),
getDaemonUrl: vi.fn(() => "http://localhost:7433"),
};
});
interface TestDeps {
lifecycleDeps: Record<string, never>;
clientFactory: () => {
get(path: string): Promise<{ status: number; data: unknown }>;
post(path: string, body: unknown): Promise<{ status: number; data: unknown }>;
};
}
type CommandFactory = (deps: TestDeps) => Command;
async function loadCommand(): Promise<CommandFactory> {
let loaded: { compactionControlCommand?: CommandFactory } | null = null;
try {
loaded = await import(/* @vite-ignore */ COMMAND_MODULE);
} catch {
// Assertion below keeps the RED feature-shaped instead of surfacing a loader error.
}
expect(loaded, "compaction-control command module must exist").not.toBeNull();
expect(loaded?.compactionControlCommand).toBeTypeOf("function");
return loaded!.compactionControlCommand!;
}
function makeDeps() {
const calls: Array<{ method: string; path: string; body?: unknown }> = [];
const deps: TestDeps = {
lifecycleDeps: {},
clientFactory: () => ({
get: vi.fn(async (path: string) => {
calls.push({ method: "GET", path });
return {
status: 200,
data: {
ok: true,
decisions: [{
decisionId: "hold-1",
sessionName: "claude-seat@rig",
direction: "hold",
lastObservedAt: "2026-08-08T18:00:30.000Z",
lastObservedOutcome: "human_hold",
}],
},
};
}),
post: vi.fn(async (path: string, body: unknown) => {
calls.push({ method: "POST", path, body });
return { status: path.endsWith("/clear") ? 200 : 201, data: { ok: true, decisionId: "decision-1" } };
}),
}),
};
return { calls, deps };
}
describe("rig compaction-control", () => {
let logs: string[];
beforeEach(() => {
logs = [];
vi.spyOn(console, "log").mockImplementation((...args) => logs.push(args.join(" ")));
vi.spyOn(console, "error").mockImplementation(() => {});
process.exitCode = undefined;
});
it("is registered as one grouped command with hold, authorize, list, and clear", () => {
const program = createProgram({ compactionControlDeps: makeDeps().deps } as never);
const command = program.commands.find((candidate) => candidate.name() === "compaction-control");
expect(command).toBeDefined();
expect(command!.commands.map((child) => child.name()).sort()).toEqual([
"authorize",
"clear",
"hold",
"list",
]);
});
it("hold posts the target and required human reason without a body actor", async () => {
const factory = await loadCommand();
const { calls, deps } = makeDeps();
const command = factory(deps);
await command.parseAsync([
"node", "rig", "hold", "claude-seat@rig",
"--reason", "finish the atomic action",
"--json",
]);
expect(calls).toEqual([{
method: "POST",
path: "/api/compaction/control",
body: {
session: "claude-seat@rig",
direction: "hold",
reason: "finish the atomic action",
},
}]);
});
it("authorize posts exactly one named lift and no caller-controlled expiry", async () => {
const factory = await loadCommand();
const { calls, deps } = makeDeps();
const command = factory(deps);
await command.parseAsync([
"node", "rig", "authorize", "claude-seat@rig",
"--automatic-reason", "disabled",
"--reason", "allow one attempt",
"--json",
]);
expect(calls).toEqual([{
method: "POST",
path: "/api/compaction/control",
body: {
session: "claude-seat@rig",
direction: "authorize",
automaticReason: "disabled",
reason: "allow one attempt",
},
}]);
});
it("list uses the scoped read route and renders durable hold observation", async () => {
const factory = await loadCommand();
const { calls, deps } = makeDeps();
const command = factory(deps);
await command.parseAsync([
"node", "rig", "list", "--session", "claude-seat@rig", "--json",
]);
expect(calls).toEqual([{
method: "GET",
path: "/api/compaction/control?session=claude-seat%40rig",
}]);
expect(JSON.parse(logs.join(""))).toMatchObject({
decisions: [{
decisionId: "hold-1",
lastObservedAt: "2026-08-08T18:00:30.000Z",
lastObservedOutcome: "human_hold",
}],
});
});
it("clear records a human reason through the decision-specific route", async () => {
const factory = await loadCommand();
const { calls, deps } = makeDeps();
const command = factory(deps);
await command.parseAsync([
"node", "rig", "clear", "hold-1", "--reason", "atomic action completed", "--json",
]);
expect(calls).toEqual([{
method: "POST",
path: "/api/compaction/control/hold-1/clear",
body: { reason: "atomic action completed" },
}]);
});
});
+2
View File
@@ -70,6 +70,7 @@ import { projectionManifestSchema } from "./migrations/064_projection_manifest.j
import { identityProvenanceSchema } from "./migrations/065_identity_provenance.js";
import { watchdogTargetGenerationSchema } from "./migrations/066_watchdog_target_generation.js";
import { i3IdentityProvenanceSchema } from "./migrations/067_i3_identity_provenance.js";
import { enforcerDecisionsSchema } from "./migrations/068_enforcer_decisions.js";
import type { Migration } from "./migrate.js";
/** Ordered 001→067 (064/066 from main, 065 + 067 the P21 identity_provenance era-stamps — all additive/independent). */
@@ -141,4 +142,5 @@ export const ALL_MIGRATIONS: Migration[] = [
identityProvenanceSchema,
watchdogTargetGenerationSchema,
i3IdentityProvenanceSchema,
enforcerDecisionsSchema,
];
@@ -0,0 +1,41 @@
import type { Migration } from "../migrate.js";
/** W4 — durable, attributable human hold/authorize decisions for enforcers. */
export const enforcerDecisionsSchema: Migration = {
name: "068_enforcer_decisions.sql",
sql: `
CREATE TABLE enforcer_decisions (
decision_id TEXT PRIMARY KEY,
enforcer_kind TEXT NOT NULL,
session_name TEXT NOT NULL,
generation_uuid TEXT NOT NULL,
direction TEXT NOT NULL CHECK (direction IN ('hold', 'authorize')),
automatic_reason TEXT,
reason TEXT NOT NULL,
actor_session TEXT NOT NULL,
identity_provenance TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT,
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
release_kind TEXT,
released_at TEXT,
released_by_session TEXT,
release_identity_provenance TEXT,
release_reason TEXT,
consumed_at TEXT,
consumed_by_enforcer_kind TEXT,
lifted_reason TEXT,
attempt_outcome TEXT,
attempt_failure_reason TEXT,
last_observed_at TEXT,
last_observed_outcome TEXT
);
CREATE UNIQUE INDEX idx_enforcer_decisions_one_active
ON enforcer_decisions(enforcer_kind, session_name, generation_uuid)
WHERE active = 1;
CREATE INDEX idx_enforcer_decisions_lookup
ON enforcer_decisions(enforcer_kind, session_name, direction, active, generation_uuid);
`,
};
@@ -1,5 +1,10 @@
import type { SessionTransport } from "./session-transport.js";
import type { SendOpts, SendResult, SessionTransport } from "./session-transport.js";
import type { SettingsStore } from "./user-settings/settings-store.js";
import type {
AuthorizableCompactionReason,
EnforcerDecision,
EnforcerDecisionStore,
} from "./enforcer-decision-store.js";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
@@ -68,8 +73,8 @@ export interface EnforcerInput {
}
export type EnforcerOutcome =
| { triggered: true }
| { triggered: false; reason: EnforcerSkipReason };
| { triggered: true; decisionId?: string; liftedReason?: AuthorizableCompactionReason }
| { triggered: false; reason: EnforcerSkipReason; decisionId?: string };
/**
* OPR.0.4.3.14 — manual compaction trigger surfaced stages (AC-3). `preparing`
@@ -109,7 +114,8 @@ export type EnforcerSkipReason =
| "post_restore_cooldown"
| "send_failed"
| "invalid_policy"
| "stale_generation";
| "stale_generation"
| "human_hold";
function buildCompactCommand(compactInstruction: string): string {
const normalized = compactInstruction.trim().replace(/\s+/g, " ");
@@ -312,11 +318,34 @@ export class ClaudeCompactionEnforcer {
// retired-generation stage → refuse. Injected resolver (atom-B's currentOccupantTenure by session).
private readonly pendingStageGeneration = new Map<string, string | null>();
private readonly resolveOccupantGeneration?: (sessionName: string) => string | null;
private readonly decisionStore?: Pick<
EnforcerDecisionStore,
| "findActiveHold"
| "findMatchingAuthorization"
| "observeHold"
| "consumeAuthorizationForAttempt"
| "recordAuthorizationAttempt"
>;
constructor(
settingsStore: SettingsStore,
sessionTransport: SessionTransport,
opts?: { dedupWindowMs?: number; openrigHome?: string; postCompactRestoreCooldownMs?: number; manualPrepWaitMs?: number; postCompactSendWaitMs?: number; resolveOccupantGeneration?: (sessionName: string) => string | null },
opts?: {
dedupWindowMs?: number;
openrigHome?: string;
postCompactRestoreCooldownMs?: number;
manualPrepWaitMs?: number;
postCompactSendWaitMs?: number;
resolveOccupantGeneration?: (sessionName: string) => string | null;
decisionStore?: Pick<
EnforcerDecisionStore,
| "findActiveHold"
| "findMatchingAuthorization"
| "observeHold"
| "consumeAuthorizationForAttempt"
| "recordAuthorizationAttempt"
>;
},
) {
this.settingsStore = settingsStore;
this.sessionTransport = sessionTransport;
@@ -326,6 +355,7 @@ export class ClaudeCompactionEnforcer {
this.manualPrepWaitMs = opts?.manualPrepWaitMs ?? MANUAL_PREP_WAIT_MS_DEFAULT;
this.postCompactSendWaitMs = opts?.postCompactSendWaitMs ?? POST_COMPACT_SEND_WAIT_MS_DEFAULT;
this.resolveOccupantGeneration = opts?.resolveOccupantGeneration;
this.decisionStore = opts?.decisionStore;
}
/**
@@ -341,6 +371,102 @@ export class ClaudeCompactionEnforcer {
return { triggered: false, reason: "no_usage_data" };
}
const liveGenerationUuid = this.resolveOccupantGeneration?.(input.sessionName) ?? null;
const activeHold = this.decisionStore?.findActiveHold({
enforcerKind: "claude_compaction",
sessionName: input.sessionName,
liveGenerationUuid,
});
if (activeHold) {
this.decisionStore?.observeHold({
decisionId: activeHold.decisionId,
outcome: "human_hold",
});
return {
triggered: false,
reason: "human_hold",
decisionId: activeHold.decisionId,
};
}
let authorization: {
decisionId: string;
liftedReason: AuthorizableCompactionReason;
} | null = null;
const lift = (automaticReason: AuthorizableCompactionReason): boolean => {
const found = this.decisionStore?.findMatchingAuthorization({
enforcerKind: "claude_compaction",
sessionName: input.sessionName,
liveGenerationUuid,
automaticReason,
}) as EnforcerDecision | null | undefined;
if (!found) return false;
authorization = { decisionId: found.decisionId, liftedReason: automaticReason };
return true;
};
const sendAttempt = async (
message: string,
opts?: SendOpts,
): Promise<{ ok: boolean; result: SendResult | null; outcome: EnforcerOutcome }> => {
const lifted = authorization as {
decisionId: string;
liftedReason: AuthorizableCompactionReason;
} | null;
if (lifted && !this.decisionStore?.consumeAuthorizationForAttempt({
decisionId: lifted.decisionId,
enforcerKind: "claude_compaction",
liftedReason: lifted.liftedReason,
})) {
return {
ok: false,
result: null,
outcome: { triggered: false, reason: lifted.liftedReason },
};
}
let result: SendResult;
try {
result = await this.sessionTransport.send(input.sessionName, message, opts);
} catch (error) {
if (!lifted) throw error;
this.decisionStore?.recordAuthorizationAttempt({
decisionId: lifted.decisionId,
outcome: "failed",
failureReason: error instanceof Error ? error.message : String(error),
});
return {
ok: false,
result: null,
outcome: { triggered: false, reason: "send_failed" },
};
}
if (lifted) {
this.decisionStore?.recordAuthorizationAttempt({
decisionId: lifted.decisionId,
outcome: result.ok ? "succeeded" : "failed",
...(result.ok ? {} : { failureReason: result.reason ?? result.error ?? "send_failed" }),
});
}
if (!result.ok) {
return {
ok: false,
result,
outcome: { triggered: false, reason: "send_failed" },
};
}
return {
ok: true,
result,
outcome: lifted
? {
triggered: true,
decisionId: lifted.decisionId,
liftedReason: lifted.liftedReason,
}
: { triggered: true },
};
};
const policy = this.settingsStore.resolveClaudeCompactionPolicy();
// Defense in depth: the CLI + daemon set() paths reject invalid
// threshold values, but a hand-edited ~/.openrig/config.json could
@@ -371,7 +497,7 @@ export class ClaudeCompactionEnforcer {
// generations residue is covered by fix (b)'s generation gate (layered defense). Interpretation
// surfaced in the handoff for the PM evidence read (veto there if the literal reading was meant).
if (!policy.enabled && this.manualCompactionState.get(input.sessionName)?.operatorInitiated !== true) {
return { triggered: false, reason: "disabled" };
if (!lift("disabled")) return { triggered: false, reason: "disabled" };
}
// GHOST-STAGE (b): gen-scoped stages. A stage minted by a RETIRED occupant generation must be
// undeliverable to the successor. Compare the queue-time generation to the LIVE one. NOTE-2: an
@@ -382,30 +508,30 @@ export class ClaudeCompactionEnforcer {
if (stageGen != null) {
const liveGen = this.resolveOccupantGeneration?.(input.sessionName) ?? null;
if (liveGen != null && liveGen !== stageGen) {
this.invalidateOccupant(input.sessionName); // drop the retired-generation ghost stage
return { triggered: false, reason: "stale_generation" };
if (!lift("stale_generation")) {
this.invalidateOccupant(input.sessionName); // drop the retired-generation ghost stage
return { triggered: false, reason: "stale_generation" };
}
}
}
const pendingStage = this.pendingPostCompactRestore.get(input.sessionName);
if (pendingStage === "turn_boundary") {
const boundary = await this.sessionTransport.send(
input.sessionName,
const boundary = await sendAttempt(
buildPostCompactTurnBoundaryPrompt(),
{ waitForIdleMs: this.postCompactSendWaitMs },
);
if (!boundary.ok) {
// Busy/never-idle → no delivery, no advance; the SAME stage retries next tick.
return { triggered: false, reason: "send_failed" };
return boundary.outcome;
}
this.pendingPostCompactRestore.set(input.sessionName, "restore_prompt");
return { triggered: true };
return boundary.outcome;
}
if (pendingStage === "restore_prompt") {
// OPR.0.4.1.09: resolve the extra FOR THIS SEAT (per-seat preferred; the legacy
// global is refused if it declares a different seat) - never inject wrong-seat state.
const extra = resolvePostCompactExtra(input.sessionName, this.openrigHome, policy.messageFilePath);
const restore = await this.sessionTransport.send(
input.sessionName,
const restore = await sendAttempt(
buildPostCompactRestorePrompt({
sessionName: input.sessionName,
openrigHome: this.openrigHome,
@@ -421,23 +547,22 @@ export class ClaudeCompactionEnforcer {
// Restore is exact-once + operator-authorized: if the seat is still busy
// (mid-compaction/boundary), do NOT advance to restore-sent on an
// undelivered send — retry the SAME stage next tick.
return { triggered: false, reason: "send_failed" };
return restore.outcome;
}
this.pendingPostCompactRestore.set(input.sessionName, "compliance_prompt");
// OPR.0.4.3.14 — surface manual-trigger progress (no-op for auto seats).
this.advanceManualStage(input.sessionName, "compact-sent", "restore-sent");
return { triggered: true };
return restore.outcome;
}
if (pendingStage === "compliance_prompt") {
const compliance = await this.sessionTransport.send(
input.sessionName,
const compliance = await sendAttempt(
buildPostCompactCompliancePrompt(policy.postRestoreAuditInstruction),
{ waitForIdleMs: this.postCompactSendWaitMs },
);
if (!compliance.ok) {
// Audit cannot overtake restore: only advances once the restore turn is
// idle and this send delivers; a busy tick retries the SAME stage.
return { triggered: false, reason: "send_failed" };
return compliance.outcome;
}
this.pendingPostCompactRestore.delete(input.sessionName);
this.pendingStageGeneration.delete(input.sessionName); // GHOST-STAGE (b): stage completed → drop its gen
@@ -448,7 +573,7 @@ export class ClaudeCompactionEnforcer {
this.triggeredAboveThreshold.delete(input.sessionName);
// OPR.0.4.3.14 — terminal manual-trigger stage (no-op for auto seats).
this.advanceManualStage(input.sessionName, "restore-sent", "audit-sent");
return { triggered: true };
return compliance.outcome;
}
this.triggeredAboveThreshold.delete(input.sessionName);
this.pendingPreCompactPrep.delete(input.sessionName);
@@ -465,16 +590,19 @@ export class ClaudeCompactionEnforcer {
// constant policy), while a manual trigger's restore/audit half can finish
// via this single shared path even when auto-compaction is disabled.
if (!policy.enabled) {
return { triggered: false, reason: "disabled" };
if (!lift("disabled")) return { triggered: false, reason: "disabled" };
}
const now = Date.now();
const postRestoreCooldownUntil = this.postCompactRestoreCooldownUntil.get(input.sessionName);
if (postRestoreCooldownUntil !== undefined) {
if (now < postRestoreCooldownUntil) {
return { triggered: false, reason: "post_restore_cooldown" };
if (!lift("post_restore_cooldown")) {
return { triggered: false, reason: "post_restore_cooldown" };
}
} else {
this.postCompactRestoreCooldownUntil.delete(input.sessionName);
}
this.postCompactRestoreCooldownUntil.delete(input.sessionName);
}
const last = this.lastAutoCompactAt.get(input.sessionName);
@@ -487,8 +615,7 @@ export class ClaudeCompactionEnforcer {
const preCompactStage = this.pendingPreCompactPrep.get(input.sessionName);
if (preCompactStage === undefined) {
const prep = await this.sessionTransport.send(
input.sessionName,
const prep = await sendAttempt(
buildPreCompactPrepPrompt({
usedPercentage: input.usedPercentage,
thresholdPercent: policy.thresholdPercent,
@@ -496,18 +623,17 @@ export class ClaudeCompactionEnforcer {
}),
);
if (!prep.ok) {
return { triggered: false, reason: "send_failed" };
return prep.outcome;
}
this.pendingPreCompactPrep.set(input.sessionName, "prep_prompt_sent");
return { triggered: true };
return prep.outcome;
}
const result = await this.sessionTransport.send(
input.sessionName,
const attempt = await sendAttempt(
buildCompactCommand(policy.compactInstruction),
);
if (!result.ok) {
return { triggered: false, reason: "send_failed" };
if (!attempt.ok) {
return attempt.outcome;
}
this.lastAutoCompactAt.set(input.sessionName, now);
this.triggeredAboveThreshold.add(input.sessionName);
@@ -515,7 +641,7 @@ export class ClaudeCompactionEnforcer {
this.pendingPostCompactRestore.set(input.sessionName, "turn_boundary");
// GHOST-STAGE (b): capture the occupant generation at queue time (or null when unknown).
this.pendingStageGeneration.set(input.sessionName, this.resolveOccupantGeneration?.(input.sessionName) ?? null);
return { triggered: true };
return attempt.outcome;
}
/**
@@ -0,0 +1,359 @@
import type Database from "better-sqlite3";
import { randomUUID } from "node:crypto";
export const CLAUDE_COMPACTION_ENFORCER_KIND = "claude_compaction" as const;
export const AUTHORIZABLE_COMPACTION_REASONS = [
"disabled",
"post_restore_cooldown",
"stale_generation",
] as const;
export type AuthorizableCompactionReason = typeof AUTHORIZABLE_COMPACTION_REASONS[number];
export type EnforcerDecisionDirection = "hold" | "authorize";
export interface EnforcerDecision {
decisionId: string;
enforcerKind: string;
sessionName: string;
generationUuid: string;
direction: EnforcerDecisionDirection;
automaticReason: string | null;
reason: string;
actorSession: string;
identityProvenance: string;
createdAt: string;
expiresAt: string | null;
active: boolean;
releaseKind: string | null;
releasedAt: string | null;
releasedBySession: string | null;
releaseIdentityProvenance: string | null;
releaseReason: string | null;
consumedAt: string | null;
consumedByEnforcerKind: string | null;
liftedReason: string | null;
attemptOutcome: string | null;
attemptFailureReason: string | null;
lastObservedAt: string | null;
lastObservedOutcome: string | null;
}
export interface CreateEnforcerDecisionInput {
enforcerKind: string;
sessionName: string;
generationUuid: string;
direction: EnforcerDecisionDirection;
automaticReason?: string | null;
reason: string;
actorSession: string;
identityProvenance: string;
}
interface DecisionRow {
decision_id: string;
enforcer_kind: string;
session_name: string;
generation_uuid: string;
direction: EnforcerDecisionDirection;
automatic_reason: string | null;
reason: string;
actor_session: string;
identity_provenance: string;
created_at: string;
expires_at: string | null;
active: number;
release_kind: string | null;
released_at: string | null;
released_by_session: string | null;
release_identity_provenance: string | null;
release_reason: string | null;
consumed_at: string | null;
consumed_by_enforcer_kind: string | null;
lifted_reason: string | null;
attempt_outcome: string | null;
attempt_failure_reason: string | null;
last_observed_at: string | null;
last_observed_outcome: string | null;
}
const AUTHORIZABLE = new Set<string>(AUTHORIZABLE_COMPACTION_REASONS);
function required(value: unknown, field: string): string {
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`${field} is required`);
}
return value.trim();
}
function mapDecision(row: DecisionRow): EnforcerDecision {
return {
decisionId: row.decision_id,
enforcerKind: row.enforcer_kind,
sessionName: row.session_name,
generationUuid: row.generation_uuid,
direction: row.direction,
automaticReason: row.automatic_reason,
reason: row.reason,
actorSession: row.actor_session,
identityProvenance: row.identity_provenance,
createdAt: row.created_at,
expiresAt: row.expires_at,
active: row.active === 1,
releaseKind: row.release_kind,
releasedAt: row.released_at,
releasedBySession: row.released_by_session,
releaseIdentityProvenance: row.release_identity_provenance,
releaseReason: row.release_reason,
consumedAt: row.consumed_at,
consumedByEnforcerKind: row.consumed_by_enforcer_kind,
liftedReason: row.lifted_reason,
attemptOutcome: row.attempt_outcome,
attemptFailureReason: row.attempt_failure_reason,
lastObservedAt: row.last_observed_at,
lastObservedOutcome: row.last_observed_outcome,
};
}
export class EnforcerDecisionStore {
constructor(
private readonly db: Database.Database,
private readonly opts: { now: () => Date; authorizeTtlMinutes: () => number },
) {}
private nowIso(): string {
return this.opts.now().toISOString();
}
private expireAuthorizations(now = this.nowIso()): void {
this.db.prepare(`
UPDATE enforcer_decisions
SET active = 0,
release_kind = 'expired',
released_at = ?
WHERE active = 1
AND direction = 'authorize'
AND expires_at IS NOT NULL
AND expires_at <= ?
`).run(now, now);
}
private getById(decisionId: string): EnforcerDecision | null {
const row = this.db.prepare(
"SELECT * FROM enforcer_decisions WHERE decision_id = ?",
).get(decisionId) as DecisionRow | undefined;
return row ? mapDecision(row) : null;
}
create(input: CreateEnforcerDecisionInput): EnforcerDecision {
const enforcerKind = required(input.enforcerKind, "enforcer kind");
const sessionName = required(input.sessionName, "session name");
const generationUuid = required(input.generationUuid, "generation UUID");
const reason = required(input.reason, "reason");
const actorSession = required(input.actorSession, "actor session");
const identityProvenance = required(input.identityProvenance, "identity provenance");
if (input.direction !== "hold" && input.direction !== "authorize") {
throw new Error("direction must be hold or authorize");
}
let automaticReason: string | null = null;
if (input.direction === "authorize") {
automaticReason = required(input.automaticReason, "automatic reason");
if (!AUTHORIZABLE.has(automaticReason)) {
throw new Error(`automatic reason '${automaticReason}' is not authorizable`);
}
}
const now = this.nowIso();
this.expireAuthorizations(now);
const decisionId = randomUUID();
const ttlMinutes = this.opts.authorizeTtlMinutes();
const expiresAt = input.direction === "authorize"
? new Date(this.opts.now().getTime() + ttlMinutes * 60_000).toISOString()
: null;
const transaction = this.db.transaction(() => {
const active = this.db.prepare(`
SELECT direction FROM enforcer_decisions
WHERE enforcer_kind = ? AND session_name = ? AND generation_uuid = ? AND active = 1
`).get(enforcerKind, sessionName, generationUuid) as { direction: EnforcerDecisionDirection } | undefined;
if (input.direction === "authorize" && active?.direction === "hold") {
throw new Error("cannot authorize while an active hold exists");
}
if (active?.direction === input.direction) {
throw new Error(`an active ${input.direction} decision already exists`);
}
if (input.direction === "hold" && active?.direction === "authorize") {
this.db.prepare(`
UPDATE enforcer_decisions
SET active = 0,
release_kind = 'revoked_by_hold',
released_at = ?,
released_by_session = ?,
release_identity_provenance = ?,
release_reason = ?
WHERE enforcer_kind = ? AND session_name = ? AND generation_uuid = ? AND active = 1
`).run(
now,
actorSession,
identityProvenance,
reason,
enforcerKind,
sessionName,
generationUuid,
);
}
this.db.prepare(`
INSERT INTO enforcer_decisions (
decision_id, enforcer_kind, session_name, generation_uuid, direction,
automatic_reason, reason, actor_session, identity_provenance,
created_at, expires_at, active
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
`).run(
decisionId,
enforcerKind,
sessionName,
generationUuid,
input.direction,
automaticReason,
reason,
actorSession,
identityProvenance,
now,
expiresAt,
);
});
transaction.immediate();
return this.getById(decisionId)!;
}
list(input: { sessionName?: string } = {}): EnforcerDecision[] {
this.expireAuthorizations();
const rows = input.sessionName
? this.db.prepare(`
SELECT * FROM enforcer_decisions WHERE session_name = ?
ORDER BY created_at DESC, rowid DESC
`).all(input.sessionName) as DecisionRow[]
: this.db.prepare(`
SELECT * FROM enforcer_decisions ORDER BY created_at DESC, rowid DESC
`).all() as DecisionRow[];
return rows.map(mapDecision);
}
clear(input: {
decisionId: string;
actorSession: string;
identityProvenance: string;
reason: string;
}): EnforcerDecision {
const decisionId = required(input.decisionId, "decision ID");
const actorSession = required(input.actorSession, "actor session");
const identityProvenance = required(input.identityProvenance, "identity provenance");
const reason = required(input.reason, "reason");
const result = this.db.prepare(`
UPDATE enforcer_decisions
SET active = 0,
release_kind = 'cleared',
released_at = ?,
released_by_session = ?,
release_identity_provenance = ?,
release_reason = ?
WHERE decision_id = ? AND active = 1
`).run(this.nowIso(), actorSession, identityProvenance, reason, decisionId);
if (result.changes !== 1) throw new Error("active decision not found");
return this.getById(decisionId)!;
}
findActiveHold(input: {
enforcerKind: string;
sessionName: string;
liveGenerationUuid: string | null;
}): EnforcerDecision | null {
this.expireAuthorizations();
const row = input.liveGenerationUuid === null
? this.db.prepare(`
SELECT * FROM enforcer_decisions
WHERE enforcer_kind = ? AND session_name = ? AND direction = 'hold' AND active = 1
ORDER BY created_at DESC, rowid DESC LIMIT 1
`).get(input.enforcerKind, input.sessionName) as DecisionRow | undefined
: this.db.prepare(`
SELECT * FROM enforcer_decisions
WHERE enforcer_kind = ? AND session_name = ? AND generation_uuid = ?
AND direction = 'hold' AND active = 1
LIMIT 1
`).get(input.enforcerKind, input.sessionName, input.liveGenerationUuid) as DecisionRow | undefined;
return row ? mapDecision(row) : null;
}
findMatchingAuthorization(input: {
enforcerKind: string;
sessionName: string;
liveGenerationUuid: string | null;
automaticReason: string;
}): EnforcerDecision | null {
this.expireAuthorizations();
if (input.liveGenerationUuid === null) return null;
const row = this.db.prepare(`
SELECT * FROM enforcer_decisions
WHERE enforcer_kind = ? AND session_name = ? AND generation_uuid = ?
AND direction = 'authorize' AND automatic_reason = ? AND active = 1
LIMIT 1
`).get(
input.enforcerKind,
input.sessionName,
input.liveGenerationUuid,
input.automaticReason,
) as DecisionRow | undefined;
return row ? mapDecision(row) : null;
}
observeHold(input: { decisionId: string; outcome: "human_hold" }): void {
this.db.prepare(`
UPDATE enforcer_decisions
SET last_observed_at = ?, last_observed_outcome = ?
WHERE decision_id = ? AND direction = 'hold' AND active = 1
`).run(this.nowIso(), input.outcome, input.decisionId);
}
consumeAuthorizationForAttempt(input: {
decisionId: string;
enforcerKind: string;
liftedReason: string;
}): boolean {
const now = this.nowIso();
this.expireAuthorizations(now);
const result = this.db.prepare(`
UPDATE enforcer_decisions
SET active = 0,
consumed_at = ?,
consumed_by_enforcer_kind = ?,
lifted_reason = ?
WHERE decision_id = ?
AND direction = 'authorize'
AND automatic_reason = ?
AND active = 1
AND expires_at > ?
`).run(
now,
input.enforcerKind,
input.liftedReason,
input.decisionId,
input.liftedReason,
now,
);
return result.changes === 1;
}
recordAuthorizationAttempt(input: {
decisionId: string;
outcome: "succeeded" | "failed";
failureReason?: string;
}): void {
this.db.prepare(`
UPDATE enforcer_decisions
SET attempt_outcome = ?, attempt_failure_reason = ?
WHERE decision_id = ? AND consumed_at IS NOT NULL
`).run(input.outcome, input.failureReason ?? null, input.decisionId);
}
}
@@ -116,6 +116,7 @@ export const SETTINGS_VALID_KEYS = [
"policies.claude_compaction.message_inline",
"policies.claude_compaction.message_file_path",
"policies.claude_compaction.post_restore_audit_instruction",
"policies.claude_compaction.authorize_ttl_minutes",
"policies.idle_gate_qitem.scan_interval_seconds",
"policies.idle_gate_qitem.active_wake_interval_seconds",
"snapshots.periodic.enabled",
@@ -192,6 +193,7 @@ const ENV_MAP: Record<SettingsValidKey, { primary: string; legacy?: string }> =
"policies.claude_compaction.message_inline": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_INLINE" },
"policies.claude_compaction.message_file_path": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_FILE_PATH" },
"policies.claude_compaction.post_restore_audit_instruction": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_POST_RESTORE_AUDIT_INSTRUCTION" },
"policies.claude_compaction.authorize_ttl_minutes": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_AUTHORIZE_TTL_MINUTES" },
"policies.idle_gate_qitem.scan_interval_seconds": { primary: "OPENRIG_POLICIES_IDLE_GATE_QITEM_SCAN_INTERVAL_SECONDS" },
"policies.idle_gate_qitem.active_wake_interval_seconds": { primary: "OPENRIG_POLICIES_IDLE_GATE_QITEM_ACTIVE_WAKE_INTERVAL_SECONDS" },
"snapshots.periodic.enabled": { primary: "OPENRIG_SNAPSHOTS_PERIODIC_ENABLED" },
@@ -249,6 +251,7 @@ const KEY_TO_PATH: Record<SettingsValidKey, string[]> = {
"policies.claude_compaction.message_inline": ["policies", "claudeCompaction", "messageInline"],
"policies.claude_compaction.message_file_path": ["policies", "claudeCompaction", "messageFilePath"],
"policies.claude_compaction.post_restore_audit_instruction": ["policies", "claudeCompaction", "postRestoreAuditInstruction"],
"policies.claude_compaction.authorize_ttl_minutes": ["policies", "claudeCompaction", "authorizeTtlMinutes"],
"policies.idle_gate_qitem.scan_interval_seconds": ["policies", "idleGateQitem", "scanIntervalSeconds"],
"policies.idle_gate_qitem.active_wake_interval_seconds": ["policies", "idleGateQitem", "activeWakeIntervalSeconds"],
"snapshots.periodic.enabled": ["snapshots", "periodic", "enabled"],
@@ -477,6 +480,7 @@ function getDefaultValue(key: SettingsValidKey, workspaceRoot: string): string |
case "policies.claude_compaction.message_inline": return DEFAULT_CLAUDE_COMPACTION_RESTORE_INSTRUCTION;
case "policies.claude_compaction.message_file_path": return defaultClaudeCompactionExtraInstructionFilePath();
case "policies.claude_compaction.post_restore_audit_instruction": return DEFAULT_CLAUDE_COMPACTION_POST_RESTORE_AUDIT_INSTRUCTION;
case "policies.claude_compaction.authorize_ttl_minutes": return 15;
case "policies.idle_gate_qitem.scan_interval_seconds": return 60;
case "policies.idle_gate_qitem.active_wake_interval_seconds": return 900;
case "snapshots.periodic.enabled": return true;
@@ -552,6 +556,19 @@ const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, (raw: string, coerced: s
);
}
},
"policies.claude_compaction.authorize_ttl_minutes": (raw, coerced) => {
const trimmed = (raw ?? "").trim();
if (!/^-?\d+$/.test(trimmed) || typeof coerced !== "number" || !Number.isInteger(coerced)) {
throw new Error(
`Invalid value for policies.claude_compaction.authorize_ttl_minutes: expected an integer in [1, 60], got "${raw}"`,
);
}
if (coerced < 1 || coerced > 60) {
throw new Error(
`Invalid value for policies.claude_compaction.authorize_ttl_minutes: must be in [1, 60], got ${coerced}`,
);
}
},
"snapshots.periodic.interval_seconds": (raw, coerced) => {
if (!/^-?\d+$/.test((raw ?? "").trim())) {
throw new Error(
@@ -643,6 +660,7 @@ export interface ClaudeCompactionPolicy {
messageInline: string;
messageFilePath: string;
postRestoreAuditInstruction: string;
authorizeTtlMinutes: number;
}
export interface ResolvedConfig {
@@ -763,6 +781,7 @@ export class SettingsStore {
messageInline: this.resolveOne("policies.claude_compaction.message_inline", fc, wr).value as string,
messageFilePath: this.resolveOne("policies.claude_compaction.message_file_path", fc, wr).value as string,
postRestoreAuditInstruction: this.resolveOne("policies.claude_compaction.post_restore_audit_instruction", fc, wr).value as string,
authorizeTtlMinutes: this.resolveOne("policies.claude_compaction.authorize_ttl_minutes", fc, wr).value as number,
};
}
+99
View File
@@ -3,7 +3,14 @@ import type Database from "better-sqlite3";
import type { ClaudeCompactionEnforcer } from "../domain/claude-compaction-enforcer.js";
import type { ContextUsageStore } from "../domain/context-usage-store.js";
import type { SessionTransport } from "../domain/session-transport.js";
import type { SessionRegistry } from "../domain/session-registry.js";
import {
AUTHORIZABLE_COMPACTION_REASONS,
CLAUDE_COMPACTION_ENFORCER_KIND,
type EnforcerDecisionStore,
} from "../domain/enforcer-decision-store.js";
import { authBearerTokenMiddleware } from "../middleware/auth-bearer-token.js";
import { requireSenderIdentity } from "./require-sender-identity.js";
/**
* OPR.0.4.3.14 — manual configurable compaction trigger route.
@@ -21,6 +28,98 @@ export function compactionRoutes(opts?: { bearerToken?: string | null }): Hono {
const router = new Hono();
router.use("*", authBearerTokenMiddleware({ expectedToken: opts?.bearerToken ?? null }));
router.post("/control", async (c) => {
const identity = requireSenderIdentity(c, { verb: "compaction control create" });
if (!identity.ok) return identity.response;
const store = c.get("enforcerDecisionStore" as never) as EnforcerDecisionStore | undefined;
const sessionRegistry = c.get("sessionRegistry" as never) as SessionRegistry | undefined;
if (!store || !sessionRegistry) {
return c.json({ ok: false, error: "compaction_control_unavailable" }, 503);
}
type ControlBody = {
session?: string;
direction?: string;
automaticReason?: string;
reason?: string;
};
const body = await c.req.json<ControlBody>().catch((): ControlBody => ({}));
const sessionName = body.session?.trim();
if (!sessionName) return c.json({ ok: false, error: "session_required" }, 400);
const reason = body.reason?.trim();
if (!reason) return c.json({ ok: false, error: "reason_required" }, 400);
if (body.direction !== "hold" && body.direction !== "authorize") {
return c.json({ ok: false, error: "direction_invalid" }, 400);
}
let automaticReason: string | null = null;
if (body.direction === "authorize") {
automaticReason = body.automaticReason?.trim() ?? "";
if (!automaticReason) {
return c.json({ ok: false, error: "automatic_reason_required" }, 400);
}
if (!(AUTHORIZABLE_COMPACTION_REASONS as readonly string[]).includes(automaticReason)) {
return c.json({ ok: false, error: "automatic_reason_not_authorizable" }, 400);
}
}
const generationUuid = sessionRegistry.currentOccupantGenerationForSession(sessionName);
if (!generationUuid) {
return c.json({ ok: false, error: "generation_unavailable" }, 409);
}
try {
const decision = store.create({
enforcerKind: CLAUDE_COMPACTION_ENFORCER_KIND,
sessionName,
generationUuid,
direction: body.direction,
automaticReason,
reason,
actorSession: identity.session,
identityProvenance: "transport:v1",
});
return c.json({ ok: true, decision }, 201);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const status = /active hold|already exists/i.test(message) ? 409 : 400;
return c.json({ ok: false, error: "decision_rejected", message }, status as 400 | 409);
}
});
router.get("/control", (c) => {
const store = c.get("enforcerDecisionStore" as never) as EnforcerDecisionStore | undefined;
if (!store) return c.json({ ok: false, error: "compaction_control_unavailable" }, 503);
const sessionName = c.req.query("session")?.trim();
return c.json({
ok: true,
decisions: store.list(sessionName ? { sessionName } : {}),
});
});
router.post("/control/:decisionId/clear", async (c) => {
const identity = requireSenderIdentity(c, { verb: "compaction control clear" });
if (!identity.ok) return identity.response;
const store = c.get("enforcerDecisionStore" as never) as EnforcerDecisionStore | undefined;
if (!store) return c.json({ ok: false, error: "compaction_control_unavailable" }, 503);
type ClearBody = { reason?: string };
const body = await c.req.json<ClearBody>().catch((): ClearBody => ({}));
const reason = body.reason?.trim();
if (!reason) return c.json({ ok: false, error: "reason_required" }, 400);
try {
const decision = store.clear({
decisionId: c.req.param("decisionId"),
actorSession: identity.session,
identityProvenance: "transport:v1",
reason,
});
return c.json({ ok: true, decision });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return c.json({ ok: false, error: "decision_not_active", message }, 404);
}
});
router.post("/trigger", async (c) => {
const enforcer = c.get("compactionEnforcer" as never) as ClaudeCompactionEnforcer | undefined;
const transport = c.get("sessionTransport" as never) as SessionTransport | undefined;
+2
View File
@@ -292,6 +292,7 @@ export interface AppDeps {
* auto share one back-half state machine — no second restore path).
*/
compactionEnforcer?: ClaudeCompactionEnforcer;
enforcerDecisionStore?: import("./domain/enforcer-decision-store.js").EnforcerDecisionStore;
/**
* GHOST-STAGE (e/Class-B) — the canonical OccupantInvalidator, constructed once in startup and
* injected so SeatHandoverService.commit()'s re-key call actually FIRES (dev-driver's fold added
@@ -530,6 +531,7 @@ export function createApp(deps: AppDeps): Hono {
c.set("contextUsageStore" as never, deps.contextUsageStore);
c.set("contextMonitor" as never, deps.contextMonitor);
c.set("compactionEnforcer" as never, deps.compactionEnforcer);
c.set("enforcerDecisionStore" as never, deps.enforcerDecisionStore);
c.set("occupantInvalidator" as never, deps.occupantInvalidator);
c.set("nodeCmuxService" as never, deps.nodeCmuxService);
c.set("agentActivityStore" as never, deps.agentActivityStore);
+12 -2
View File
@@ -1757,14 +1757,24 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
// the type narrow for downstream consumers).
const { ContextMonitor } = await import("./domain/context-monitor.js");
const { ClaudeCompactionEnforcer } = await import("./domain/claude-compaction-enforcer.js");
const { EnforcerDecisionStore } = await import("./domain/enforcer-decision-store.js");
const compactionSettingsStore = new ContextPackSettingsStore();
const enforcerDecisionStore = new EnforcerDecisionStore(db, {
now: () => new Date(),
authorizeTtlMinutes: () => compactionSettingsStore.resolveClaudeCompactionPolicy().authorizeTtlMinutes,
});
deps.enforcerDecisionStore = enforcerDecisionStore;
const compactionEnforcer = deps.sessionTransport
? new ClaudeCompactionEnforcer(
new ContextPackSettingsStore(),
compactionSettingsStore,
deps.sessionTransport,
// GHOST-STAGE (b): resolve the LIVE occupant generation (atom-B tenure ledger) for a session
// so a stage minted by a retired generation is refused for the successor. null = UNKNOWN → the
// gate is inert (never compares a stale generation as if live).
{ resolveOccupantGeneration: (sessionName) => sessionRegistry.currentOccupantGenerationForSession(sessionName) },
{
resolveOccupantGeneration: (sessionName) => sessionRegistry.currentOccupantGenerationForSession(sessionName),
decisionStore: enforcerDecisionStore,
},
)
: undefined;
// 51-08 A1 — the over-time series rides the SAME 30s tick (PM decision 1):
@@ -49,6 +49,7 @@ function clearEnv(): () => void {
"OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_INLINE",
"OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_FILE_PATH",
"OPENRIG_POLICIES_CLAUDE_COMPACTION_POST_RESTORE_AUDIT_INSTRUCTION",
"OPENRIG_POLICIES_CLAUDE_COMPACTION_AUTHORIZE_TTL_MINUTES",
"OPENRIG_POLICIES_IDLE_GATE_QITEM_SCAN_INTERVAL_SECONDS",
"OPENRIG_POLICIES_IDLE_GATE_QITEM_ACTIVE_WAKE_INTERVAL_SECONDS",
"RIGGED_PORT", "RIGGED_HOST", "RIGGED_DB",
@@ -125,6 +126,7 @@ describe("SettingsStore (User Settings v0)", () => {
"policies.claude_compaction.message_inline",
"policies.claude_compaction.message_file_path",
"policies.claude_compaction.post_restore_audit_instruction",
"policies.claude_compaction.authorize_ttl_minutes",
// OPR.0.5.1 51-06 W2c — tunable daemon auto-registration cadence.
"policies.idle_gate_qitem.scan_interval_seconds",
"policies.idle_gate_qitem.active_wake_interval_seconds",
@@ -339,6 +341,7 @@ describe("SettingsStore (User Settings v0)", () => {
expect(policy.messageFilePath).toMatch(/^\//);
expect(policy.messageFilePath.endsWith(DEFAULT_EXTRA_INSTRUCTION_FILE_SUFFIX)).toBe(true);
expect(policy.postRestoreAuditInstruction).toContain(DEFAULT_AUDIT_INSTRUCTION_FRAGMENT);
expect(policy.authorizeTtlMinutes).toBe(15);
});
it("HG-10: resolveClaudeCompactionPolicy picks up direct config.json edits without daemon restart (single resolve call rereads file)", () => {
@@ -357,6 +360,7 @@ describe("SettingsStore (User Settings v0)", () => {
messageInline: "carry-forward note",
messageFilePath: "",
postRestoreAuditInstruction: "verify the reads",
authorizeTtlMinutes: 30,
},
},
}),
@@ -370,6 +374,7 @@ describe("SettingsStore (User Settings v0)", () => {
expect(updated.messageInline).toBe("carry-forward note");
expect(updated.messageFilePath.endsWith(DEFAULT_EXTRA_INSTRUCTION_FILE_SUFFIX)).toBe(true);
expect(updated.postRestoreAuditInstruction).toBe("verify the reads");
expect(updated.authorizeTtlMinutes).toBe(30);
});
it("Slice 27: ensureDefaultClaudeCompactionFiles creates the user-owned extra instruction placeholder without overwriting edits", () => {
@@ -396,6 +401,7 @@ describe("SettingsStore (User Settings v0)", () => {
store.set("policies.claude_compaction.message_inline", "rehydrate the agent");
store.set("policies.claude_compaction.message_file_path", "/tmp/msg.txt");
store.set("policies.claude_compaction.post_restore_audit_instruction", "audit reads");
store.set("policies.claude_compaction.authorize_ttl_minutes", "30");
const raw = JSON.parse(require("node:fs").readFileSync(configPath, "utf-8"));
expect(raw.policies.claudeCompaction.enabled).toBe(true);
@@ -405,6 +411,7 @@ describe("SettingsStore (User Settings v0)", () => {
expect(raw.policies.claudeCompaction.messageInline).toBe("rehydrate the agent");
expect(raw.policies.claudeCompaction.messageFilePath).toBe("/tmp/msg.txt");
expect(raw.policies.claudeCompaction.postRestoreAuditInstruction).toBe("audit reads");
expect(raw.policies.claudeCompaction.authorizeTtlMinutes).toBe(30);
expect(store.resolveOne("policies.claude_compaction.enabled").value).toBe(true);
expect(store.resolveOne("policies.claude_compaction.threshold_percent").value).toBe(60);
@@ -413,6 +420,7 @@ describe("SettingsStore (User Settings v0)", () => {
expect(store.resolveOne("policies.claude_compaction.message_inline").value).toBe("rehydrate the agent");
expect(store.resolveOne("policies.claude_compaction.message_file_path").value).toBe("/tmp/msg.txt");
expect(store.resolveOne("policies.claude_compaction.post_restore_audit_instruction").value).toBe("audit reads");
expect(store.resolveOne("policies.claude_compaction.authorize_ttl_minutes").value).toBe(30);
});
it("HG-1: invalid threshold rejected by coerceValue (non-numeric raises)", () => {
@@ -420,6 +428,70 @@ describe("SettingsStore (User Settings v0)", () => {
expect(() => store.set("policies.claude_compaction.threshold_percent", "not-a-number")).toThrow(/expected a number/);
});
describe("W4 authorize TTL config contract", () => {
it("accepts the exact inclusive 1..60 range through the write path", () => {
const store = new SettingsStore(configPath);
for (const value of ["1", "15", "60"]) {
store.set("policies.claude_compaction.authorize_ttl_minutes", value);
expect(store.resolveOne("policies.claude_compaction.authorize_ttl_minutes")).toMatchObject({
value: Number(value),
source: "file",
defaultValue: 15,
});
}
for (const value of ["0", "61", "15.5", "bad"]) {
expect(() => store.set("policies.claude_compaction.authorize_ttl_minutes", value)).toThrow(/integer|\[1, 60\]|number/i);
}
});
it("uses a valid env override", () => {
process.env["OPENRIG_POLICIES_CLAUDE_COMPACTION_AUTHORIZE_TTL_MINUTES"] = "30";
expect(new SettingsStore(configPath).resolveOne(
"policies.claude_compaction.authorize_ttl_minutes",
)).toMatchObject({ value: 30, source: "env", defaultValue: 15 });
});
it.each(["0", "61", "15.5", "bad"])(
"invalid env %s falls back to 15 and reports loudly",
(raw) => {
process.env["OPENRIG_POLICIES_CLAUDE_COMPACTION_AUTHORIZE_TTL_MINUTES"] = raw;
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
try {
const resolved = new SettingsStore(configPath).resolveOne(
"policies.claude_compaction.authorize_ttl_minutes",
);
expect(resolved).toMatchObject({ value: 15, source: "default", defaultValue: 15 });
expect(stderr.mock.calls.some((call) => String(call[0]).includes(
"env override for policies.claude_compaction.authorize_ttl_minutes rejected",
))).toBe(true);
} finally {
stderr.mockRestore();
}
},
);
it.each([0, 61, 15.5, "bad"])(
"invalid file value %s falls back to 15 and reports loudly",
(raw) => {
writeFileSync(configPath, JSON.stringify({
policies: { claudeCompaction: { authorizeTtlMinutes: raw } },
}));
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
try {
const resolved = new SettingsStore(configPath).resolveOne(
"policies.claude_compaction.authorize_ttl_minutes",
);
expect(resolved).toMatchObject({ value: 15, source: "default", defaultValue: 15 });
expect(stderr.mock.calls.some((call) => String(call[0]).includes(
"file value for policies.claude_compaction.authorize_ttl_minutes rejected",
))).toBe(true);
} finally {
stderr.mockRestore();
}
},
);
});
// Slice 27 BLOCKING-FIX-2 — env + file source resolution must also
// reject invalid threshold values. Without this, an env override
// (OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT=80abc) or a
@@ -0,0 +1,136 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type Database from "better-sqlite3";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { ContextUsageStore } from "../src/domain/context-usage-store.js";
import { ContextMonitor } from "../src/domain/context-monitor.js";
import { ClaudeCompactionEnforcer } from "../src/domain/claude-compaction-enforcer.js";
import type { SessionTransport } from "../src/domain/session-transport.js";
const STORE_MODULE = ["../src/domain", "enforcer-decision-store.js"].join("/");
const SESSION = "claude-seat@rig";
type DecisionStore = {
create(input: Record<string, unknown>): Record<string, unknown>;
list(input?: Record<string, unknown>): Array<Record<string, unknown>>;
};
type DecisionStoreConstructor = new (
db: Database.Database,
opts: { now: () => Date; authorizeTtlMinutes: () => number },
) => DecisionStore;
async function loadDecisionStore(): Promise<DecisionStoreConstructor> {
let mod: { EnforcerDecisionStore?: DecisionStoreConstructor } | null = null;
try {
mod = await import(/* @vite-ignore */ STORE_MODULE);
} catch {
// Assertion below keeps the RED feature-shaped instead of surfacing a loader error.
}
expect(mod, "durable enforcer decision store module must exist").not.toBeNull();
expect(mod?.EnforcerDecisionStore).toBeTypeOf("function");
return mod!.EnforcerDecisionStore!;
}
describe("W4 ContextMonitor enforcement observation", () => {
let db: Database.Database;
let stateDir: string;
let contextStore: ContextUsageStore;
let monitor: ContextMonitor | undefined;
let generationUuid: string;
beforeEach(() => {
db = createDb();
migrate(db, ALL_MIGRATIONS);
stateDir = join(tmpdir(), `w4-context-monitor-${process.pid}-${Math.random().toString(36).slice(2)}`);
mkdirSync(join(stateDir, "context"), { recursive: true });
contextStore = new ContextUsageStore(db, { stateDir, codexHomeDir: stateDir });
const rigs = new RigRepository(db);
const sessions = new SessionRegistry(db);
const rig = rigs.createRig("rig");
const node = rigs.addNode(rig.id, "claude.seat", { runtime: "claude-code" });
const session = sessions.registerSession(node.id, SESSION);
generationUuid = sessions.currentOccupantGenerationForSession(SESSION)!;
db.prepare("UPDATE sessions SET status = 'running' WHERE id = ?").run(session.id);
writeFileSync(
join(stateDir, "context", `${SESSION}.json`),
JSON.stringify({
session_name: SESSION,
sampled_at: "2026-08-08T18:00:30.000Z",
context_window: {
context_window_size: 200_000,
used_percentage: 90,
remaining_percentage: 10,
total_input_tokens: 170_000,
total_output_tokens: 10_000,
},
}),
);
});
afterEach(() => {
monitor?.stop();
db.close();
rmSync(stateDir, { recursive: true, force: true });
});
it("a real poll honors a hold with unknown live generation and exposes its observation", async () => {
const EnforcerDecisionStore = await loadDecisionStore();
const decisions = new EnforcerDecisionStore(db, {
now: () => new Date("2026-08-08T18:00:30.000Z"),
authorizeTtlMinutes: () => 15,
});
const hold = decisions.create({
enforcerKind: "claude_compaction",
sessionName: SESSION,
generationUuid,
direction: "hold",
reason: "finish the current atomic action",
actorSession: "orch-lead@rig",
identityProvenance: "transport:v1",
});
const send = vi.fn(async () => ({ ok: true as const }));
const enforcer = new ClaudeCompactionEnforcer(
{
resolveClaudeCompactionPolicy: () => ({
enabled: true,
thresholdPercent: 80,
preCompactInstruction: "prepare",
compactInstruction: "",
messageInline: "",
messageFilePath: "",
postRestoreAuditInstruction: "audit",
authorizeTtlMinutes: 15,
}),
} as never,
{ send } as unknown as SessionTransport,
{
resolveOccupantGeneration: () => null,
decisionStore: decisions,
} as never,
);
monitor = new ContextMonitor(
db,
contextStore,
{ ensureContextCollector: vi.fn() },
enforcer,
);
await monitor.pollOnce();
expect(send).not.toHaveBeenCalled();
expect(decisions.list({ sessionName: SESSION })).toContainEqual(expect.objectContaining({
decisionId: hold["decisionId"],
lastObservedAt: "2026-08-08T18:00:30.000Z",
lastObservedOutcome: "human_hold",
}));
});
});
@@ -0,0 +1,202 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
import type Database from "better-sqlite3";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { compactionRoutes } from "../src/routes/compaction.js";
const IDENTITY = "orch-lead@rig";
const SEAT = "claude-seat@rig";
describe("W4 compaction-control routes", () => {
let db: Database.Database;
let sessionRegistry: SessionRegistry;
let generationUuid: string;
let decisions: {
create: ReturnType<typeof vi.fn>;
list: ReturnType<typeof vi.fn>;
clear: ReturnType<typeof vi.fn>;
};
let app: Hono;
beforeEach(() => {
db = createDb();
migrate(db, ALL_MIGRATIONS);
const rigs = new RigRepository(db);
sessionRegistry = new SessionRegistry(db);
const rig = rigs.createRig("rig");
const node = rigs.addNode(rig.id, "claude.seat", { runtime: "claude-code" });
sessionRegistry.registerSession(node.id, SEAT);
generationUuid = sessionRegistry.currentOccupantGenerationForSession(SEAT)!;
decisions = {
create: vi.fn((input) => ({ decisionId: "decision-1", active: true, ...input })),
list: vi.fn(() => [{
decisionId: "hold-1",
sessionName: SEAT,
direction: "hold",
active: true,
lastObservedAt: "2026-08-08T18:00:30.000Z",
lastObservedOutcome: "human_hold",
}]),
clear: vi.fn((input) => ({ decisionId: input.decisionId, active: false, releaseKind: "cleared" })),
};
app = new Hono();
app.use("*", async (c, next) => {
c.set("db" as never, db);
c.set("sessionRegistry" as never, sessionRegistry);
c.set("enforcerDecisionStore" as never, decisions);
await next();
});
app.route("/api/compaction", compactionRoutes());
});
afterEach(() => db.close());
const request = (path: string, body?: Record<string, unknown>, identity = IDENTITY) =>
app.request(path, {
method: body === undefined ? "GET" : "POST",
headers: {
...(body === undefined ? {} : { "Content-Type": "application/json" }),
...(identity ? { "X-OpenRig-Session": identity } : {}),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
it("refuses an unattributable hold before writing any record", async () => {
const response = await request("/api/compaction/control", {
session: SEAT,
direction: "hold",
reason: "mid-fold",
}, "");
expect(response.status).toBe(401);
expect(await response.json()).toMatchObject({ error: "unattributable_sender" });
expect(decisions.create).not.toHaveBeenCalled();
});
it("refuses blank reasons and blanket authorizations loudly", async () => {
const blank = await request("/api/compaction/control", {
session: SEAT,
direction: "hold",
reason: " ",
});
expect(blank.status).toBe(400);
expect(await blank.json()).toMatchObject({ error: "reason_required" });
const blanket = await request("/api/compaction/control", {
session: SEAT,
direction: "authorize",
reason: "ignore refusals",
});
expect(blanket.status).toBe(400);
expect(await blanket.json()).toMatchObject({ error: "automatic_reason_required" });
expect(decisions.create).not.toHaveBeenCalled();
});
it.each([
"runtime_filter",
"no_usage_data",
"invalid_policy",
"below_threshold",
"already_triggered_above_threshold",
"dedup_window",
"send_failed",
])("refuses excluded authorization reason %s", async (automaticReason) => {
const response = await request("/api/compaction/control", {
session: SEAT,
direction: "authorize",
automaticReason,
reason: "not in locked set",
});
expect(response.status).toBe(400);
expect(await response.json()).toMatchObject({ error: "automatic_reason_not_authorizable" });
expect(decisions.create).not.toHaveBeenCalled();
});
it("derives actor/provenance and generation when creating a hold", async () => {
const response = await request("/api/compaction/control", {
session: SEAT,
direction: "hold",
reason: "finish current atomic action",
});
expect(response.status).toBe(201);
expect(decisions.create).toHaveBeenCalledWith({
enforcerKind: "claude_compaction",
sessionName: SEAT,
generationUuid,
direction: "hold",
automaticReason: null,
reason: "finish current atomic action",
actorSession: IDENTITY,
identityProvenance: "transport:v1",
});
});
it.each(["disabled", "post_restore_cooldown", "stale_generation"])(
"creates one reason-matched authorization for %s without accepting caller expiry",
async (automaticReason) => {
const response = await request("/api/compaction/control", {
session: SEAT,
direction: "authorize",
automaticReason,
reason: `allow ${automaticReason} once`,
expiresAt: "2099-01-01T00:00:00.000Z",
});
expect(response.status).toBe(201);
expect(decisions.create).toHaveBeenCalledWith({
enforcerKind: "claude_compaction",
sessionName: SEAT,
generationUuid,
direction: "authorize",
automaticReason,
reason: `allow ${automaticReason} once`,
actorSession: IDENTITY,
identityProvenance: "transport:v1",
});
expect(decisions.create.mock.calls[0]?.[0]).not.toHaveProperty("expiresAt");
},
);
it("refuses decision creation when live generation cannot be resolved", async () => {
const response = await request("/api/compaction/control", {
session: "missing-seat@rig",
direction: "hold",
reason: "cannot scope without a generation",
});
expect(response.status).toBe(409);
expect(await response.json()).toMatchObject({ error: "generation_unavailable" });
expect(decisions.create).not.toHaveBeenCalled();
});
it("lists durable observation fields without requiring a write identity", async () => {
const response = await request(`/api/compaction/control?session=${encodeURIComponent(SEAT)}`);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
ok: true,
decisions: [{
decisionId: "hold-1",
sessionName: SEAT,
direction: "hold",
active: true,
lastObservedAt: "2026-08-08T18:00:30.000Z",
lastObservedOutcome: "human_hold",
}],
});
expect(decisions.list).toHaveBeenCalledWith({ sessionName: SEAT });
});
it("clear derives its human actor and records a required release reason", async () => {
const response = await request("/api/compaction/control/hold-1/clear", {
reason: "atomic action completed",
});
expect(response.status).toBe(200);
expect(decisions.clear).toHaveBeenCalledWith({
decisionId: "hold-1",
actorSession: IDENTITY,
identityProvenance: "transport:v1",
reason: "atomic action completed",
});
});
});
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const here = dirname(fileURLToPath(import.meta.url));
describe("W4 production wiring", () => {
it("constructs one decision store and shares it with the enforcer and routes", () => {
const startup = readFileSync(join(here, "../src/startup.ts"), "utf8");
const server = readFileSync(join(here, "../src/server.ts"), "utf8");
expect(startup).toContain('import("./domain/enforcer-decision-store.js")');
expect(startup.match(/new EnforcerDecisionStore\(/g)).toHaveLength(1);
expect(startup).toMatch(/decisionStore:\s*enforcerDecisionStore/);
expect(startup).toMatch(/deps\.enforcerDecisionStore\s*=\s*enforcerDecisionStore/);
expect(server).toMatch(/enforcerDecisionStore\?:\s*import\("\.\/domain\/enforcer-decision-store\.js"\)\.EnforcerDecisionStore/);
expect(server).toContain('c.set("enforcerDecisionStore" as never, deps.enforcerDecisionStore)');
expect(server).toContain("compactionRoutes");
});
});
@@ -0,0 +1,633 @@
import { describe, expect, it, vi } from "vitest";
import type Database from "better-sqlite3";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import {
ClaudeCompactionEnforcer,
type EnforcerInput,
} from "../src/domain/claude-compaction-enforcer.js";
import type { SessionTransport } from "../src/domain/session-transport.js";
const STORE_MODULE = ["../src/domain", "enforcer-decision-store.js"].join("/");
const KIND = "claude_compaction";
const SEAT = "claude-seat@rig";
type DecisionStoreConstructor = new (
db: Database.Database,
opts: { now: () => Date; authorizeTtlMinutes: () => number },
) => {
create(input: Record<string, unknown>): Record<string, unknown>;
list(input?: Record<string, unknown>): Array<Record<string, unknown>>;
clear(input: Record<string, unknown>): Record<string, unknown>;
findActiveHold(input: Record<string, unknown>): Record<string, unknown> | null;
consumeAuthorizationForAttempt(input: Record<string, unknown>): boolean;
recordAuthorizationAttempt(input: Record<string, unknown>): void;
};
async function loadDecisionStore(): Promise<DecisionStoreConstructor> {
let mod: { EnforcerDecisionStore?: DecisionStoreConstructor } | null = null;
try {
mod = await import(/* @vite-ignore */ STORE_MODULE);
} catch {
// Assertion below keeps the RED feature-shaped instead of surfacing a loader error.
}
expect(mod, "durable enforcer decision store module must exist").not.toBeNull();
expect(mod?.EnforcerDecisionStore).toBeTypeOf("function");
return mod!.EnforcerDecisionStore!;
}
function makeDb(): Database.Database {
const db = createDb();
migrate(db, ALL_MIGRATIONS);
return db;
}
function humanInput(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
enforcerKind: KIND,
sessionName: SEAT,
generationUuid: "gen-1",
direction: "hold",
reason: "seat is inside a load-bearing atomic step",
actorSession: "orch-lead@rig",
identityProvenance: "transport:v1",
...overrides,
};
}
describe("W4 durable enforcer decision store", () => {
it("registers migration 068 and creates the durable decision table", () => {
expect(ALL_MIGRATIONS.map((migration) => migration.name)).toContain(
"068_enforcer_decisions.sql",
);
const db = makeDb();
try {
const row = db.prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'enforcer_decisions'",
).get() as { name: string } | undefined;
expect(row?.name).toBe("enforcer_decisions");
} finally {
db.close();
}
});
it("persists an attributable generation-scoped hold with enforcer kind", async () => {
const EnforcerDecisionStore = await loadDecisionStore();
const db = makeDb();
try {
const store = new EnforcerDecisionStore(db, {
now: () => new Date("2026-08-08T18:00:00.000Z"),
authorizeTtlMinutes: () => 15,
});
const created = store.create(humanInput());
expect(created).toMatchObject({
enforcerKind: KIND,
sessionName: SEAT,
generationUuid: "gen-1",
direction: "hold",
actorSession: "orch-lead@rig",
identityProvenance: "transport:v1",
reason: "seat is inside a load-bearing atomic step",
active: true,
});
} finally {
db.close();
}
});
it("makes authorize expiring, reason-matched, and limited to the locked lift set", async () => {
const EnforcerDecisionStore = await loadDecisionStore();
const db = makeDb();
try {
const store = new EnforcerDecisionStore(db, {
now: () => new Date("2026-08-08T18:00:00.000Z"),
authorizeTtlMinutes: () => 15,
});
for (const [index, automaticReason] of [
"disabled",
"post_restore_cooldown",
"stale_generation",
].entries()) {
const created = store.create(humanInput({
generationUuid: `gen-${index + 1}`,
direction: "authorize",
automaticReason,
reason: `lift ${automaticReason} once`,
}));
expect(created).toMatchObject({
direction: "authorize",
automaticReason,
expiresAt: "2026-08-08T18:15:00.000Z",
active: true,
});
}
for (const automaticReason of [
"runtime_filter",
"no_usage_data",
"invalid_policy",
"below_threshold",
"already_triggered_above_threshold",
"dedup_window",
"send_failed",
"human_hold",
]) {
expect(() => store.create(humanInput({
generationUuid: `excluded-${automaticReason}`,
direction: "authorize",
automaticReason,
reason: "must be refused",
}))).toThrow(/automatic reason|not authorizable|liftable/i);
}
} finally {
db.close();
}
});
it("enforces one active decision and resolves conflicts toward hold", async () => {
const EnforcerDecisionStore = await loadDecisionStore();
const db = makeDb();
try {
const store = new EnforcerDecisionStore(db, {
now: () => new Date("2026-08-08T18:00:00.000Z"),
authorizeTtlMinutes: () => 15,
});
const authorization = store.create(humanInput({
direction: "authorize",
automaticReason: "disabled",
reason: "one disabled-policy attempt",
}));
const hold = store.create(humanInput({ reason: "new work began; stop before acting" }));
expect(hold).toMatchObject({ direction: "hold", active: true });
const rows = store.list({ sessionName: SEAT });
expect(rows.find((row) => row["decisionId"] === authorization["decisionId"])).toMatchObject({
active: false,
releaseKind: "revoked_by_hold",
releasedBySession: "orch-lead@rig",
releaseIdentityProvenance: "transport:v1",
});
expect(() => store.create(humanInput({
direction: "authorize",
automaticReason: "disabled",
reason: "must not outrank hold",
}))).toThrow(/active hold/i);
} finally {
db.close();
}
});
it("consumes one authorization atomically for one machine send attempt", async () => {
const EnforcerDecisionStore = await loadDecisionStore();
const db = makeDb();
try {
const store = new EnforcerDecisionStore(db, {
now: () => new Date("2026-08-08T18:00:00.000Z"),
authorizeTtlMinutes: () => 15,
});
const created = store.create(humanInput({
direction: "authorize",
automaticReason: "disabled",
reason: "allow one disabled-policy attempt",
}));
const consumeInput = {
decisionId: created["decisionId"],
enforcerKind: KIND,
liftedReason: "disabled",
};
expect(store.consumeAuthorizationForAttempt(consumeInput)).toBe(true);
expect(store.consumeAuthorizationForAttempt(consumeInput)).toBe(false);
store.recordAuthorizationAttempt({
decisionId: created["decisionId"],
outcome: "failed",
failureReason: "send_failed",
});
expect(store.list({ sessionName: SEAT })[0]).toMatchObject({
consumedByEnforcerKind: KIND,
liftedReason: "disabled",
attemptOutcome: "failed",
attemptFailureReason: "send_failed",
});
expect(store.list({ sessionName: SEAT })[0]).not.toHaveProperty("consumedByActorSession");
expect(store.list({ sessionName: SEAT })[0]).not.toHaveProperty("consumeIdentityProvenance");
} finally {
db.close();
}
});
it("records explicit human clear and lazy expiry without erasing history", async () => {
const EnforcerDecisionStore = await loadDecisionStore();
const db = makeDb();
let now = new Date("2026-08-08T18:00:00.000Z");
try {
const store = new EnforcerDecisionStore(db, {
now: () => now,
authorizeTtlMinutes: () => 15,
});
const hold = store.create(humanInput());
store.clear({
decisionId: hold["decisionId"],
actorSession: "orch-lead@rig",
identityProvenance: "transport:v1",
reason: "atomic step completed",
});
expect(store.list({ sessionName: SEAT })[0]).toMatchObject({
active: false,
releaseKind: "cleared",
releasedBySession: "orch-lead@rig",
releaseIdentityProvenance: "transport:v1",
releaseReason: "atomic step completed",
});
store.create(humanInput({
generationUuid: "gen-2",
direction: "authorize",
automaticReason: "disabled",
reason: "short-lived one-shot",
}));
now = new Date("2026-08-08T18:16:00.000Z");
const expired = store.list({ sessionName: SEAT }).find((row) => row["generationUuid"] === "gen-2");
expect(expired).toMatchObject({ active: false, releaseKind: "expired" });
} finally {
db.close();
}
});
it("refuses anonymous, reasonless, and blanket decisions loudly", async () => {
const EnforcerDecisionStore = await loadDecisionStore();
const db = makeDb();
try {
const store = new EnforcerDecisionStore(db, {
now: () => new Date("2026-08-08T18:00:00.000Z"),
authorizeTtlMinutes: () => 15,
});
expect(() => store.create(humanInput({ actorSession: "" }))).toThrow(/actor|required|attribut/i);
expect(() => store.create(humanInput({ reason: " " }))).toThrow(/reason|required/i);
expect(() => store.create(humanInput({
generationUuid: "gen-blanket",
direction: "authorize",
automaticReason: undefined,
reason: "ignore refusals",
}))).toThrow(/automatic reason|required|blanket/i);
} finally {
db.close();
}
});
});
const BASE_POLICY = {
enabled: true,
thresholdPercent: 80,
preCompactInstruction: "prepare",
compactInstruction: "",
messageInline: "",
messageFilePath: "",
postRestoreAuditInstruction: "audit",
authorizeTtlMinutes: 15,
};
function settings(policy: Record<string, unknown> = BASE_POLICY) {
return { resolveClaudeCompactionPolicy: () => policy } as never;
}
function transport() {
const send = vi.fn(async () => ({ ok: true as const }));
return { send, value: { send } as unknown as SessionTransport };
}
function decisionHarness(opts: {
hold?: (generation: string | null) => Record<string, unknown> | null;
authorizationReason?: string;
} = {}) {
const findActiveHold = vi.fn((input: { liveGenerationUuid: string | null }) =>
opts.hold?.(input.liveGenerationUuid) ?? null);
const findMatchingAuthorization = vi.fn((input: { automaticReason: string }) =>
input.automaticReason === opts.authorizationReason
? { decisionId: "authorize-1", automaticReason: input.automaticReason }
: null);
return {
findActiveHold,
findMatchingAuthorization,
observeHold: vi.fn(),
consumeAuthorizationForAttempt: vi.fn(() => true),
recordAuthorizationAttempt: vi.fn(),
};
}
const highInput = (usedPercentage = 90): EnforcerInput => ({
sessionName: SEAT,
runtime: "claude-code",
usedPercentage,
transcriptPath: "/tmp/claude.jsonl",
});
describe("W4 ClaudeCompactionEnforcer decision consumption", () => {
it("a hold prevents firing and reports human_hold durably", async () => {
const tx = transport();
const decisions = decisionHarness({ hold: () => ({ decisionId: "hold-1" }) });
const enforcer = new ClaudeCompactionEnforcer(settings(), tx.value, {
resolveOccupantGeneration: () => "gen-1",
decisionStore: decisions,
} as never);
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({
triggered: false,
reason: "human_hold",
decisionId: "hold-1",
});
expect(tx.send).not.toHaveBeenCalled();
expect(decisions.observeHold).toHaveBeenCalledWith(expect.objectContaining({
decisionId: "hold-1",
outcome: "human_hold",
}));
});
it("a null live generation fails closed on a hold, then known N+1 makes the N hold inert", async () => {
const tx = transport();
let liveGeneration: string | null = null;
const decisions = decisionHarness({
hold: (generation) => generation === null ? { decisionId: "hold-N" } : null,
});
const enforcer = new ClaudeCompactionEnforcer(settings(), tx.value, {
resolveOccupantGeneration: () => liveGeneration,
decisionStore: decisions,
} as never);
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({
triggered: false,
reason: "human_hold",
decisionId: "hold-N",
});
liveGeneration = "gen-N+1";
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({ triggered: true });
expect(tx.send).toHaveBeenCalledTimes(1);
});
it("matches a persisted generation-N hold and makes it inert for generation N+1", async () => {
const EnforcerDecisionStore = await loadDecisionStore();
const db = makeDb();
try {
const store = new EnforcerDecisionStore(db, {
now: () => new Date("2026-08-08T18:00:00.000Z"),
authorizeTtlMinutes: () => 15,
});
const hold = store.create(humanInput({ generationUuid: "gen-N" }));
const tx = transport();
let liveGeneration = "gen-N";
const enforcer = new ClaudeCompactionEnforcer(settings(), tx.value, {
resolveOccupantGeneration: () => liveGeneration,
decisionStore: store,
} as never);
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({
triggered: false,
reason: "human_hold",
decisionId: hold["decisionId"],
});
expect(tx.send).not.toHaveBeenCalled();
liveGeneration = "gen-N+1";
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({ triggered: true });
expect(tx.send).toHaveBeenCalledTimes(1);
} finally {
db.close();
}
});
it("consumes a persisted authorization only for matching known generation N and only once", async () => {
const EnforcerDecisionStore = await loadDecisionStore();
const db = makeDb();
try {
const store = new EnforcerDecisionStore(db, {
now: () => new Date("2026-08-08T18:00:00.000Z"),
authorizeTtlMinutes: () => 15,
});
const authorization = store.create(humanInput({
generationUuid: "gen-N",
direction: "authorize",
automaticReason: "disabled",
reason: "lift disabled once for generation N",
}));
const row = () => store.list({ sessionName: SEAT }).find(
(candidate) => candidate["decisionId"] === authorization["decisionId"],
);
const tx = transport();
let liveGeneration: string | null = null;
const enforcer = new ClaudeCompactionEnforcer(
settings({ ...BASE_POLICY, enabled: false }),
tx.value,
{
resolveOccupantGeneration: () => liveGeneration,
decisionStore: store,
} as never,
);
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({
triggered: false,
reason: "disabled",
});
expect(row()).toMatchObject({ active: true });
expect(row()?.["consumedAt"] ?? null).toBeNull();
expect(tx.send).not.toHaveBeenCalled();
liveGeneration = "gen-N+1";
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({
triggered: false,
reason: "disabled",
});
expect(row()).toMatchObject({ active: true });
expect(row()?.["consumedAt"] ?? null).toBeNull();
expect(tx.send).not.toHaveBeenCalled();
liveGeneration = "gen-N";
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({
triggered: true,
decisionId: authorization["decisionId"],
liftedReason: "disabled",
});
expect(row()).toMatchObject({
active: false,
consumedByEnforcerKind: KIND,
liftedReason: "disabled",
attemptOutcome: "succeeded",
});
expect(tx.send).toHaveBeenCalledTimes(1);
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({
triggered: false,
reason: "disabled",
});
expect(tx.send).toHaveBeenCalledTimes(1);
} finally {
db.close();
}
});
it("lifts disabled once and reports decisionId + liftedReason on the triggered outcome", async () => {
const tx = transport();
const decisions = decisionHarness({ authorizationReason: "disabled" });
const enforcer = new ClaudeCompactionEnforcer(
settings({ ...BASE_POLICY, enabled: false }),
tx.value,
{
resolveOccupantGeneration: () => "gen-1",
decisionStore: decisions,
} as never,
);
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({
triggered: true,
decisionId: "authorize-1",
liftedReason: "disabled",
});
expect(tx.send).toHaveBeenCalledTimes(1);
expect(decisions.consumeAuthorizationForAttempt).toHaveBeenCalledTimes(1);
expect(decisions.recordAuthorizationAttempt).toHaveBeenCalledWith(expect.objectContaining({
decisionId: "authorize-1",
outcome: "succeeded",
}));
});
it("does not consume authorization when its reason is not the refusal that releases a send", async () => {
const tx = transport();
const decisions = decisionHarness({ authorizationReason: "disabled" });
const enforcer = new ClaudeCompactionEnforcer(settings(), tx.value, {
resolveOccupantGeneration: () => "gen-1",
decisionStore: decisions,
} as never);
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({ triggered: true });
expect(tx.send).toHaveBeenCalledTimes(1);
expect(decisions.consumeAuthorizationForAttempt).not.toHaveBeenCalled();
});
it("lifts post_restore_cooldown for one send attempt", async () => {
const tx = transport();
const decisions = decisionHarness({ authorizationReason: "post_restore_cooldown" });
let now = 1_700_000_000_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
try {
const enforcer = new ClaudeCompactionEnforcer(settings(), tx.value, {
dedupWindowMs: 0,
postCompactRestoreCooldownMs: 60_000,
openrigHome: "/tmp/openrig-w4",
resolveOccupantGeneration: () => "gen-1",
decisionStore: decisions,
} as never);
await enforcer.maybeAutoCompact(highInput());
now += 1_000;
await enforcer.maybeAutoCompact(highInput());
for (let i = 0; i < 3; i += 1) {
now += 1_000;
await enforcer.maybeAutoCompact(highInput(20));
}
now += 1_000;
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({
triggered: true,
decisionId: "authorize-1",
liftedReason: "post_restore_cooldown",
});
expect(decisions.consumeAuthorizationForAttempt).toHaveBeenCalledTimes(1);
} finally {
vi.restoreAllMocks();
}
});
it("checks stale_generation authorization before deleting the queued stage", async () => {
const tx = transport();
const decisions = decisionHarness({ authorizationReason: "stale_generation" });
let generation = "gen-N";
const enforcer = new ClaudeCompactionEnforcer(settings(), tx.value, {
dedupWindowMs: 0,
postCompactRestoreCooldownMs: 0,
openrigHome: "/tmp/openrig-w4",
resolveOccupantGeneration: () => generation,
decisionStore: decisions,
} as never);
await enforcer.maybeAutoCompact(highInput());
await enforcer.maybeAutoCompact(highInput());
generation = "gen-N+1";
await expect(enforcer.maybeAutoCompact(highInput(20))).resolves.toEqual({
triggered: true,
decisionId: "authorize-1",
liftedReason: "stale_generation",
});
expect(tx.send.mock.calls.at(-1)?.[1]).toContain("post-compaction turn boundary");
expect(decisions.consumeAuthorizationForAttempt).toHaveBeenCalledTimes(1);
});
it("consumes authorization on a failed attempt and records the actual failure", async () => {
const send = vi.fn(async () => ({ ok: false as const, reason: "send_failed" }));
const decisions = decisionHarness({ authorizationReason: "disabled" });
const enforcer = new ClaudeCompactionEnforcer(
settings({ ...BASE_POLICY, enabled: false }),
{ send } as unknown as SessionTransport,
{
resolveOccupantGeneration: () => "gen-1",
decisionStore: decisions,
} as never,
);
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({
triggered: false,
reason: "send_failed",
});
expect(decisions.consumeAuthorizationForAttempt).toHaveBeenCalledTimes(1);
expect(decisions.recordAuthorizationAttempt).toHaveBeenCalledWith(expect.objectContaining({
decisionId: "authorize-1",
outcome: "failed",
failureReason: "send_failed",
}));
});
it("records a thrown authorized send as failed and keeps the authorization one-shot", async () => {
const EnforcerDecisionStore = await loadDecisionStore();
const db = makeDb();
try {
const store = new EnforcerDecisionStore(db, {
now: () => new Date("2026-08-08T18:00:00.000Z"),
authorizeTtlMinutes: () => 15,
});
const authorization = store.create(humanInput({
generationUuid: "gen-N",
direction: "authorize",
automaticReason: "disabled",
reason: "allow one attempted send",
}));
const send = vi.fn(async () => {
throw new Error("transport exploded");
});
const enforcer = new ClaudeCompactionEnforcer(
settings({ ...BASE_POLICY, enabled: false }),
{ send } as unknown as SessionTransport,
{
resolveOccupantGeneration: () => "gen-N",
decisionStore: store,
} as never,
);
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({
triggered: false,
reason: "send_failed",
});
expect(store.list({ sessionName: SEAT })).toContainEqual(expect.objectContaining({
decisionId: authorization["decisionId"],
active: false,
consumedByEnforcerKind: KIND,
liftedReason: "disabled",
attemptOutcome: "failed",
attemptFailureReason: "transport exploded",
}));
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({
triggered: false,
reason: "disabled",
});
expect(send).toHaveBeenCalledTimes(1);
} finally {
db.close();
}
});
});