fix: enforce compaction final-boundary identity

This commit is contained in:
v-openrig-build committed 2026-08-09 09:55:27 +00:00
1 parent 07879b10b4
commit 89fd15bf59
9 files changed
+471 -28

No files matched your search

+1 -1
View File
@@ -73,7 +73,7 @@ Keys:
policies.claude_compaction.*
enabled, threshold_percent, compact_instruction,
message_inline, message_file_path,
authorize_ttl_minutes
policies.claude_compaction.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
@@ -710,10 +710,13 @@ export class ClaudeCompactionEnforcer {
*/
async triggerManualCompact(
input: EnforcerInput,
opts: { operatorInitiated?: boolean } = {},
opts: {
operatorInitiated?: boolean;
resolveCurrentSessionName?: (sessionName: string) => string | null;
} = {},
): Promise<ManualCompactionOutcome> {
const liveGenerationUuid = this.resolveOccupantGeneration?.(input.sessionName) ?? null;
const activeHold = this.findAndObserveHold(input.sessionName, liveGenerationUuid);
const startingGenerationUuid = this.resolveOccupantGeneration?.(input.sessionName) ?? null;
const activeHold = this.findAndObserveHold(input.sessionName, startingGenerationUuid);
if (activeHold) {
return {
triggered: false,
@@ -786,12 +789,63 @@ export class ClaudeCompactionEnforcer {
// Phase 2 — WAIT for the prep turn to complete (seat idle), THEN send
// /compact. `waitForIdleMs` makes the transport block on explicit idle
// evidence before pasting /compact, guaranteeing prep-before-compact.
const preCompactCheck = {
reason: null as string | null,
decisionId: undefined as string | undefined,
};
const compact = await this.sessionTransport.send(
input.sessionName,
buildCompactCommand(policy.compactInstruction),
{ waitForIdleMs: this.manualPrepWaitMs },
{
waitForIdleMs: this.manualPrepWaitMs,
beforeSend: () => {
const currentSessionName = opts.resolveCurrentSessionName
? opts.resolveCurrentSessionName(input.sessionName)
: input.sessionName;
if (!currentSessionName) {
preCompactCheck.reason = "session_not_current";
return {
reason: preCompactCheck.reason,
error: `Session '${input.sessionName}' is no longer current.`,
};
}
const currentGenerationUuid = this.resolveOccupantGeneration?.(currentSessionName) ?? null;
const currentHold = this.findAndObserveHold(currentSessionName, currentGenerationUuid);
if (currentHold) {
preCompactCheck.reason = "human_hold";
preCompactCheck.decisionId = currentHold.decisionId;
return {
reason: preCompactCheck.reason,
error: `Session '${currentSessionName}' has an active human compaction hold.`,
};
}
if (currentSessionName !== input.sessionName) {
preCompactCheck.reason = "session_not_current";
return {
reason: preCompactCheck.reason,
error: `Session '${input.sessionName}' is no longer current; current session is '${currentSessionName}'.`,
};
}
if (currentGenerationUuid !== startingGenerationUuid) {
preCompactCheck.reason = "occupant_generation_changed";
return {
reason: preCompactCheck.reason,
error: `Session '${input.sessionName}' changed occupant generation during manual compaction preparation.`,
};
}
return null;
},
},
);
if (!compact.ok) {
if (preCompactCheck.reason) {
return this.recordManualFailure(
input.sessionName,
preCompactCheck.reason,
preCompactCheck.decisionId,
);
}
return this.recordManualFailure(input.sessionName, compact.reason ?? "send_failed");
}
@@ -849,9 +903,14 @@ export class ClaudeCompactionEnforcer {
this.manualCompactionState.set(sessionName, { stage, reason, updatedAt: Date.now(), operatorInitiated });
}
private recordManualFailure(sessionName: string, reason: string): ManualCompactionOutcome {
private recordManualFailure(sessionName: string, reason: string, decisionId?: string): ManualCompactionOutcome {
this.setManualStage(sessionName, "skipped-or-failed", reason);
return { triggered: false, stage: "skipped-or-failed", reason };
return {
triggered: false,
stage: "skipped-or-failed",
reason,
...(decisionId ? { decisionId } : {}),
};
}
private findAndObserveHold(
@@ -370,6 +370,11 @@ export interface SendOpts {
verify?: boolean;
force?: boolean;
waitForIdleMs?: number;
/** Final fail-loud check after any idle wait and immediately before delivery. */
beforeSend?: () =>
| { reason: string; error?: string }
| null
| Promise<{ reason: string; error?: string } | null>;
// OPR.0.4.1.10 — interactive-prompt / permission guard.
// `dangerouslyInteract` is the ONLY override of the prompt/permission guard (force does NOT bypass
// it). It requires `reason` and writes an auditable `transport.prompt_override` record before the
@@ -946,6 +951,31 @@ export class SessionTransport {
text = appendDeliveredSegment(text, this.now().getTime() - Date.parse(opts.stampISO));
}
if (opts?.beforeSend) {
try {
const refusal = await opts.beforeSend();
if (refusal) {
return {
ok: false,
sessionName,
reason: refusal.reason,
error: refusal.error,
sent: false,
...waitEvidence,
};
}
} catch (error) {
return {
ok: false,
sessionName,
reason: "before_send_check_failed",
error: error instanceof Error ? error.message : String(error),
sent: false,
...waitEvidence,
};
}
}
// 3. Send text (paste)
const textResult = await this.runStage(
"session_transport.send_text",
+40 -17
View File
@@ -63,25 +63,12 @@ export function compactionRoutes(opts?: { bearerToken?: string | null }): Hono {
}
}
const currentSession = sessionRegistry.db.prepare(`
WITH target AS (
SELECT node_id
FROM sessions
WHERE session_name = ?
ORDER BY id DESC
LIMIT 1
)
SELECT session_name
FROM sessions
WHERE node_id = (SELECT node_id FROM target)
ORDER BY id DESC
LIMIT 1
`).get(sessionName) as { session_name: string } | undefined;
if (currentSession && currentSession.session_name !== sessionName) {
const currentSession = currentSessionName(sessionRegistry.db, sessionName);
if (currentSession && currentSession !== sessionName) {
return c.json({
ok: false,
error: "session_not_current",
currentSession: currentSession.session_name,
currentSession,
}, 409);
}
@@ -169,6 +156,15 @@ export function compactionRoutes(opts?: { bearerToken?: string | null }): Hono {
return c.json({ ok: false, error: resolved.error }, status);
}
const currentSession = currentSessionName(db, sessionName);
if (currentSession && currentSession !== sessionName) {
return c.json({
ok: false,
error: "session_not_current",
currentSession,
}, 409);
}
// Resolve the DB node id + runtime for the latest session row.
const row = db.prepare(`
SELECT n.id AS node_id, n.runtime AS runtime
@@ -201,7 +197,10 @@ export function compactionRoutes(opts?: { bearerToken?: string | null }): Hono {
// This is the OPERATOR's manual-trigger verb (bearer-auth'd); the resulting sequence is
// drain-exempt while auto-compaction is disabled. GHOST-STAGE fix (a) actor-gate: automation
// paths that do NOT set this are NOT exempt, so they cannot launder a drain past the gate.
{ operatorInitiated: true },
{
operatorInitiated: true,
resolveCurrentSessionName: (candidate) => currentSessionName(db, candidate),
},
);
if (outcome.triggered) {
@@ -222,6 +221,8 @@ export function compactionRoutes(opts?: { bearerToken?: string | null }): Hono {
send_failed: 502,
submit_failed: 502,
human_hold: 409,
session_not_current: 409,
occupant_generation_changed: 409,
};
const status = (statusMap[outcome.reason] ?? 409) as 400 | 404 | 409 | 422 | 502 | 503;
return c.json({
@@ -249,6 +250,24 @@ export function compactionRoutes(opts?: { bearerToken?: string | null }): Hono {
return router;
}
function currentSessionName(db: Database.Database, sessionName: string): string | null {
const row = db.prepare(`
WITH target AS (
SELECT node_id
FROM sessions
WHERE session_name = ?
ORDER BY id DESC
LIMIT 1
)
SELECT session_name
FROM sessions
WHERE node_id = (SELECT node_id FROM target)
ORDER BY id DESC
LIMIT 1
`).get(sessionName) as { session_name: string } | undefined;
return row?.session_name ?? null;
}
function manualReasonMessage(sessionName: string, reason: string): string {
switch (reason) {
case "runtime_filter":
@@ -265,6 +284,10 @@ function manualReasonMessage(sessionName: string, reason: string): string {
return `Prep was sent to '${sessionName}' but it did not go idle in time, so /compact was NOT sent. Retry once the prep turn completes.`;
case "human_hold":
return `Refused: '${sessionName}' has an active human compaction hold.`;
case "session_not_current":
return `Refused: '${sessionName}' is no longer the current session for its node.`;
case "occupant_generation_changed":
return `Refused: '${sessionName}' changed occupant generation during compaction preparation.`;
default:
return `Manual compaction for '${sessionName}' did not complete (${reason}).`;
}
@@ -911,7 +911,10 @@ describe("ClaudeCompactionEnforcer", () => {
expect(send.mock.calls[0]![2]).toBeUndefined();
// Phase 2 — /compact WITH the trust-bridge AND wait-for-idle (two-phase).
expect(send.mock.calls[1]![1]).toContain("/compact In the continuity summary, preserve this trust-channel note");
expect(send.mock.calls[1]![2]).toEqual({ waitForIdleMs: expect.any(Number) });
expect(send.mock.calls[1]![2]).toEqual({
waitForIdleMs: expect.any(Number),
beforeSend: expect.any(Function),
});
expect(send).toHaveBeenCalledTimes(2);
// The SAME maybeAutoCompact back-half (single restore path) drains it.
@@ -495,6 +495,47 @@ describe("SessionTransport", () => {
expect(callOrder).toEqual(["capture", "capture", "sendText", "sendKeys"]);
});
it("send with wait-for-idle runs its final check after idle and can refuse before delivery", async () => {
seedCanonicalRig();
const callOrder: string[] = [];
let captureCount = 0;
const sendTextSpy = vi.fn(async () => {
callOrder.push("sendText");
return { ok: true as const };
});
const tmux = mockTmux({
capturePaneContent: async () => {
callOrder.push("capture");
captureCount++;
return captureCount === 1
? "Working on task...\n⠋ Processing files\nesc to interrupt"
: "Ready\n❯ ";
},
sendText: sendTextSpy,
});
const transport = createTransport(tmux, {
sleep: async () => undefined,
waitForIdlePollMs: 1,
});
const result = await transport.send("dev-impl@my-rig", "hello", {
waitForIdleMs: 50,
beforeSend: async () => {
callOrder.push("beforeSend");
return { reason: "human_hold", error: "Compaction hold became active." };
},
});
expect(result).toMatchObject({
ok: false,
reason: "human_hold",
sent: false,
attempts: 2,
});
expect(callOrder).toEqual(["capture", "capture", "beforeSend"]);
expect(sendTextSpy).not.toHaveBeenCalled();
});
it("send with wait-for-idle waits through current Claude thinking evidence and sends after idle", async () => {
seedCanonicalRig();
const callOrder: string[] = [];
@@ -28,6 +28,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",
];
const saved: Record<string, string | undefined> = {};
for (const k of keys) {
@@ -78,7 +79,7 @@ describe("config routes (User Settings v0)", () => {
// + 2 V1 pre-release Item 1 (transcripts.lines / transcripts.poll_interval_seconds)
// + 1 plugin-primitive Phase 3a slice 3.5 (runtime.codex.hooks_enabled)
// + 1 V0.3.1 slice 05 (workspace.operator_seat_name)
// + 7 slice 27 (policies.claude_compaction.*)
// + 8 slice 27 + W4 (policies.claude_compaction.*)
// + 3 OPR.0.3.4.9 (snapshots.periodic.*)
// + 1 OPR.0.4.0.1 (ui.terminal.max_live_terminals)
// + 2 OPR.0.4.6.MH1 (host.selected / host.name)
@@ -88,14 +89,18 @@ describe("config routes (User Settings v0)", () => {
// watchdog_keep_per_job / batch_size — the CLI-settable queue-retention knobs;
// + retention.usage_samples_days, 51-08 A2)
// + 2 OPR.0.5.1 W2c (policies.idle_gate_qitem.scan_interval_seconds /
// active_wake_interval_seconds) → 52 total.
expect(Object.keys(body.settings).length).toBe(52);
// active_wake_interval_seconds) → 53 total.
expect(Object.keys(body.settings).length).toBe(53);
expect(body.settings["daemon.port"]?.source).toBe("default");
expect(body.settings["ui.preview.refresh_interval_seconds"]?.value).toBe(3);
expect(body.settings["ui.preview.max_pins"]?.value).toBe(4);
expect(body.settings["ui.preview.default_lines"]?.value).toBe(50);
expect(body.settings["recovery.auto_drive_provider_prompts"]?.value).toBe(false);
expect(body.settings["recovery.provider_auth_env_allowlist"]?.value).toBe("");
expect(body.settings["policies.claude_compaction.authorize_ttl_minutes"]).toMatchObject({
value: 15,
source: "default",
});
expect(body.settings["host.selected"]).toMatchObject({ value: "local", source: "default" });
expect(body.settings["host.name"]).toMatchObject({ value: "localhost", source: "default" });
expect(String(body.settings["workspace.dogfood_evidence_root"]?.value)).toMatch(/dogfood-evidence$/);
@@ -80,6 +80,74 @@ describe("W4 compaction-control routes", () => {
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
function makeBoundaryTransport(afterPrep: () => void) {
const delivered: string[] = [];
const send = vi.fn(async (
_sessionName: string,
message: string,
opts?: {
beforeSend?: () =>
| { reason: string; error?: string }
| null
| Promise<{ reason: string; error?: string } | null>;
},
) => {
if (send.mock.calls.length === 1) {
delivered.push(message);
afterPrep();
return { ok: true as const };
}
const refusal = await opts?.beforeSend?.();
if (refusal) return { ok: false as const, sessionName: SEAT, sent: false, ...refusal };
delivered.push(message);
return { ok: true as const };
});
return {
delivered,
send,
transport: {
send,
resolveSessions: vi.fn(async () => ({ ok: true as const, sessions: [SEAT] })),
},
};
}
function installManualHarness(store: EnforcerDecisionStore, transport: Record<string, unknown>): void {
const enforcer = new ClaudeCompactionEnforcer(
{
resolveClaudeCompactionPolicy: () => ({
enabled: true,
thresholdPercent: 80,
preCompactInstruction: "prepare",
compactInstruction: "",
messageInline: "",
messageFilePath: "",
postRestoreAuditInstruction: "audit",
authorizeTtlMinutes: 15,
}),
} as never,
transport as never,
{
resolveOccupantGeneration: (sessionName: string) =>
sessionRegistry.currentOccupantGenerationForSession(sessionName),
decisionStore: store,
},
);
manualDeps = {
enforcer,
transport,
usageStore: {
getForNode: () => ({
availability: "known",
usedPercentage: 90,
transcriptPath: "/tmp/claude.jsonl",
sessionId: "claude-session",
}),
},
};
decisions = store as never;
}
it("refuses an unattributable hold before writing any record", async () => {
const response = await request("/api/compaction/control", {
session: SEAT,
@@ -285,6 +353,168 @@ describe("W4 compaction-control routes", () => {
}));
});
it("the public manual trigger rejects a historical alias before it can miss the current alias hold", async () => {
const currentSeat = "claude-seat-next@rig";
sessionRegistry.registerSession(nodeId, currentSeat);
const currentGeneration = sessionRegistry.currentOccupantGenerationForSession(currentSeat)!;
const store = new EnforcerDecisionStore(db, {
now: () => new Date("2026-08-08T18:00:00.000Z"),
authorizeTtlMinutes: () => 15,
});
const hold = store.create({
enforcerKind: "claude_compaction",
sessionName: currentSeat,
generationUuid: currentGeneration,
direction: "hold",
automaticReason: null,
reason: "finish the current atomic action",
actorSession: IDENTITY,
identityProvenance: "transport:v1",
});
const send = vi.fn(async () => ({ ok: true as const }));
const transport = {
send,
resolveSessions: vi.fn(async () => ({ ok: true as const, sessions: [SEAT] })),
};
const enforcer = new ClaudeCompactionEnforcer(
{
resolveClaudeCompactionPolicy: () => ({
enabled: true,
thresholdPercent: 80,
preCompactInstruction: "prepare",
compactInstruction: "",
messageInline: "",
messageFilePath: "",
postRestoreAuditInstruction: "audit",
authorizeTtlMinutes: 15,
}),
} as never,
transport as never,
{
resolveOccupantGeneration: (sessionName: string) =>
sessionRegistry.currentOccupantGenerationForSession(sessionName),
decisionStore: store,
},
);
manualDeps = {
enforcer,
transport,
usageStore: {
getForNode: () => ({
availability: "known",
usedPercentage: 90,
transcriptPath: "/tmp/claude.jsonl",
sessionId: "claude-session",
}),
},
};
decisions = store as never;
const stale = await request("/api/compaction/trigger", { session: SEAT });
expect(stale.status).toBe(409);
expect(await stale.json()).toMatchObject({
ok: false,
error: "session_not_current",
currentSession: currentSeat,
});
expect(send).not.toHaveBeenCalled();
expect(store.list({ sessionName: currentSeat })).toContainEqual(expect.objectContaining({
decisionId: hold.decisionId,
active: true,
lastObservedAt: null,
}));
const current = await request("/api/compaction/trigger", { session: currentSeat });
expect(current.status).toBe(409);
expect(await current.json()).toMatchObject({
ok: false,
reason: "human_hold",
decisionId: hold.decisionId,
});
expect(send).not.toHaveBeenCalled();
expect(store.list({ sessionName: currentSeat })).toContainEqual(expect.objectContaining({
decisionId: hold.decisionId,
lastObservedOutcome: "human_hold",
}));
});
it("the public manual trigger observes a new-current-alias hold activated during preparation", async () => {
const currentSeat = "claude-seat-next@rig";
const store = new EnforcerDecisionStore(db, {
now: () => new Date("2026-08-08T18:00:00.000Z"),
authorizeTtlMinutes: () => 15,
});
let hold: ReturnType<EnforcerDecisionStore["create"]> | null = null;
const tx = makeBoundaryTransport(() => {
sessionRegistry.registerSession(nodeId, currentSeat, "handover");
hold = store.create({
enforcerKind: "claude_compaction",
sessionName: currentSeat,
generationUuid: sessionRegistry.currentOccupantGenerationForSession(currentSeat)!,
direction: "hold",
automaticReason: null,
reason: "hold the new occupant",
actorSession: IDENTITY,
identityProvenance: "transport:v1",
});
});
installManualHarness(store, tx.transport);
const response = await request("/api/compaction/trigger", { session: SEAT });
expect(response.status).toBe(409);
expect(await response.json()).toMatchObject({
ok: false,
reason: "human_hold",
decisionId: hold?.decisionId,
});
expect(tx.delivered).toHaveLength(1);
expect(tx.delivered[0]).toContain("automatic compaction preparation");
expect(store.list({ sessionName: currentSeat })).toContainEqual(expect.objectContaining({
decisionId: hold?.decisionId,
lastObservedOutcome: "human_hold",
lastObservedAt: "2026-08-08T18:00:00.000Z",
}));
});
it("the public manual trigger refuses an alias turnover without a hold at the final boundary", async () => {
const currentSeat = "claude-seat-next@rig";
const store = new EnforcerDecisionStore(db, {
now: () => new Date("2026-08-08T18:00:00.000Z"),
authorizeTtlMinutes: () => 15,
});
const tx = makeBoundaryTransport(() => {
sessionRegistry.registerSession(nodeId, currentSeat, "handover");
});
installManualHarness(store, tx.transport);
const response = await request("/api/compaction/trigger", { session: SEAT });
expect(response.status).toBe(409);
expect(await response.json()).toMatchObject({
ok: false,
reason: "session_not_current",
});
expect(tx.delivered).toHaveLength(1);
});
it("the public manual trigger refuses same-alias occupant-tenure drift at the final boundary", async () => {
const store = new EnforcerDecisionStore(db, {
now: () => new Date("2026-08-08T18:00:00.000Z"),
authorizeTtlMinutes: () => 15,
});
const tx = makeBoundaryTransport(() => {
sessionRegistry.mintOccupantTenure(nodeId, "handover");
});
installManualHarness(store, tx.transport);
const response = await request("/api/compaction/trigger", { session: SEAT });
expect(response.status).toBe(409);
expect(await response.json()).toMatchObject({
ok: false,
reason: "occupant_generation_changed",
});
expect(tx.delivered).toHaveLength(1);
});
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);
@@ -394,6 +394,58 @@ describe("W4 ClaudeCompactionEnforcer decision consumption", () => {
}
});
it("rechecks the real hold store after manual preparation and before the final action", 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,
});
let hold: Record<string, unknown> | null = null;
const delivered: string[] = [];
const send = vi.fn(async (
_sessionName: string,
message: string,
opts?: { beforeSend?: () => Promise<{ reason: string; error?: string } | null> },
) => {
if (send.mock.calls.length === 1) {
hold = store.create(humanInput({
generationUuid: "gen-N",
reason: "hold activated during manual preparation",
}));
}
const refusal = await opts?.beforeSend?.();
if (refusal) return { ok: false as const, sessionName: SEAT, sent: false, ...refusal };
delivered.push(message);
return { ok: true as const };
});
const enforcer = new ClaudeCompactionEnforcer(settings(), { send } as never, {
resolveOccupantGeneration: () => "gen-N",
decisionStore: store,
} as never);
await expect(enforcer.triggerManualCompact(highInput(), { operatorInitiated: true })).resolves.toEqual({
triggered: false,
stage: "skipped-or-failed",
reason: "human_hold",
decisionId: expect.any(String),
});
expect(send).toHaveBeenCalledTimes(2);
expect(delivered).toHaveLength(1);
expect(delivered[0]).toContain("automatic compaction preparation");
expect(hold).not.toBeNull();
expect(store.list({ sessionName: SEAT })).toContainEqual(expect.objectContaining({
decisionId: hold?.["decisionId"],
active: true,
lastObservedOutcome: "human_hold",
lastObservedAt: "2026-08-08T18:00:00.000Z",
}));
} 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();