diff --git a/loopx/control_plane/work_items/execution_preparation.ts b/loopx/control_plane/work_items/execution_preparation.ts index 25fd0996fc..8105338135 100644 --- a/loopx/control_plane/work_items/execution_preparation.ts +++ b/loopx/control_plane/work_items/execution_preparation.ts @@ -1,3 +1,4 @@ +import {EXECUTION_HANDOFF_MODES} from "../coordination/handoff_mode_vocabulary.ts"; import type {JsonObject} from "../effect_program.ts"; import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; import { @@ -13,7 +14,6 @@ export const EXECUTION_PREPARATION_FACTS_SCHEMA_VERSION = export const EXECUTION_PREPARATION_SCHEMA_VERSION = "execution_preparation_v0"; -const HANDOFF_MODES = ["soft_claim", "hard_lease"] as const; const LEASE_STATUSES = ["active", "released", "unknown"] as const; function leaseFact(value: unknown, label: string): JsonObject { @@ -55,7 +55,7 @@ export function projectExecutionPreparation(value: unknown): JsonObject { } const goalId = requireNonEmptyString(request.goal_id, "goal_id"); const agentId = requireNonEmptyString(request.agent_id, "agent_id"); - const handoffMode = requireStringLiteral(request.handoff_mode, HANDOFF_MODES, "handoff_mode"); + const handoffMode = requireStringLiteral(request.handoff_mode, EXECUTION_HANDOFF_MODES, "handoff_mode"); const authorityAvailable = requireBoolean(request.authority_available, "authority_available"); const selected = requireJsonObject(request.selected_todo, "selected_todo"); const todoId = requireNonEmptyString(selected.todo_id, "selected_todo.todo_id"); diff --git a/tests/control_plane_ts/execution_preparation.test.ts b/tests/control_plane_ts/execution_preparation.test.ts index 9e1c3c7491..f3812e62b9 100644 --- a/tests/control_plane_ts/execution_preparation.test.ts +++ b/tests/control_plane_ts/execution_preparation.test.ts @@ -1,6 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { + EXECUTION_HANDOFF_MODES, + HANDOFF_MODES, +} from "../../loopx/control_plane/coordination/handoff_mode_vocabulary.ts"; import {EffectRuntimeRequestError} from "../../loopx/control_plane/effect_runtime_errors.ts"; import {projectExecutionPreparation} from "../../loopx/control_plane/work_items/execution_preparation.ts"; @@ -85,3 +89,21 @@ test("malformed facts are rejected", () => { /active requires status=active/, ); }); + +test("the execution handoff vocabulary stays narrower than the historical one", () => { + // `legacy` still decodes stored state and receipts, but it is not an accepted + // execution-preparation input. Borrowing the shared `HANDOFF_MODES` list here + // instead of the execution subset would admit it silently, so both the + // membership and the rejection are pinned against the vocabulary owner. + for (const mode of EXECUTION_HANDOFF_MODES) { + assert.doesNotThrow(() => projectExecutionPreparation(facts({handoff_mode: mode}))); + } + const historical: readonly string[] = HANDOFF_MODES; + const execution: readonly string[] = EXECUTION_HANDOFF_MODES; + assert.ok(historical.includes("legacy"), "legacy stays decodable for stored receipts"); + assert.ok(!execution.includes("legacy"), "legacy is not a new execution target"); + assert.throws( + () => projectExecutionPreparation(facts({handoff_mode: "legacy"})), + EffectRuntimeRequestError, + ); +});