= { type: "manual", source: "agent-page" };
- if (isBuilderMode(agent.name)) {
+ if (isBuilderAgent(agent.name, agent.triggers)) {
const objective = window.prompt(`Run ${agent.name} — what should it do?`);
if (objective === null) return;
const message = objective.trim();
@@ -132,6 +133,8 @@ export function AgentDetail(props: Props) {
const health = status ? agentHealth(status) : null;
const next = fmtIn(status?.nextRunAt ?? null);
+ const builderRequiresPlan =
+ builderPlanPolicy === "required" && isBuilderAgent(agent.name, agent.triggers);
return (
@@ -163,11 +166,19 @@ export function AgentDetail(props: Props) {
size="sm"
variant="primary"
tone="agent"
- disabled={busy !== null || !agent.enabled}
+ disabled={busy !== null || !agent.enabled || builderRequiresPlan}
+ title={
+ builderRequiresPlan
+ ? "Approve a current Architect plan from the story or GitHub issue"
+ : undefined
+ }
onClick={() => void runNow()}
>
{busy === "run" ? "starting…" : "run now"}
+ {builderRequiresPlan ? (
+ plan approval required
+ ) : null}
(
existing ? { cron: existing.cron } : null,
@@ -536,12 +558,22 @@ function TriggersSection({ projectId, agent, status, catalog, act, busy, note }:
size="sm"
variant="outline"
className="ml-auto"
- disabled={busy !== null}
+ disabled={busy !== null || builderRequiresPlan}
+ title={
+ builderRequiresPlan
+ ? "Scheduled Builder runs cannot satisfy a per-plan human approval"
+ : undefined
+ }
onClick={() => setEditing(true)}
>
{existing ? "edit" : "add schedule"}
) : null}
+ {builderRequiresPlan ? (
+
+ disabled by required plan gate
+
+ ) : null}
{editing ? (
diff --git a/apps/web/components/inbox/proposal-card.tsx b/apps/web/components/inbox/proposal-card.tsx
index 16ffbeed..44c17dac 100644
--- a/apps/web/components/inbox/proposal-card.tsx
+++ b/apps/web/components/inbox/proposal-card.tsx
@@ -65,6 +65,15 @@ export function ProposalCard({ proposal, focused }: { proposal: Proposal; focuse
headings, lists, and code spans) — render it, don't dump the source. */}
+ {proposal.executionError ? (
+
+ {proposal.actionType === "plan_acceptance"
+ ? "Builder dispatch blocked"
+ : "Execution failed"}
+ : {proposal.executionError}
+
+ ) : null}
+
payload
diff --git a/apps/web/components/issues/issue-row.tsx b/apps/web/components/issues/issue-row.tsx
index c96b2375..a585cb11 100644
--- a/apps/web/components/issues/issue-row.tsx
+++ b/apps/web/components/issues/issue-row.tsx
@@ -21,10 +21,12 @@ export function IssueRow({
projectId,
story,
canTrigger,
+ builderPlanRequired,
}: {
projectId: string;
story: PipelineStory;
canTrigger: boolean;
+ builderPlanRequired: boolean;
}) {
const router = useRouter();
const [busy, setBusy] = useState(null);
@@ -90,6 +92,13 @@ export function IssueRow({
);
}
if (story.stageState === "ready_to_build" && canTrigger && story.storyType === "issue") {
+ if (builderPlanRequired) {
+ return (
+
+ Review Gate 1
+
+ );
+ }
return (
+ Review Gate 1
+
+ );
+ }
return (
& {
* The project's acceptance gates — what the control-plane lane runs after the agent
* finishes and what kickstart detected. One command per line.
*/
-export function GatesEditor({ projectId, settings }: { projectId: string; settings: Settings }) {
+export function GatesEditor({
+ projectId,
+ settings,
+ builderPlanPolicy,
+}: {
+ projectId: string;
+ settings: Settings;
+ builderPlanPolicy: "optional" | "required";
+}) {
const router = useRouter();
const [branch, setBranch] = useState(settings.default_branch ?? "main");
const [provision, setProvision] = useState(settings.provision_cmd ?? "");
const [checks, setChecks] = useState((settings.check_cmds ?? []).join("\n"));
+ const [planPolicy, setPlanPolicy] = useState(builderPlanPolicy);
const [busy, setBusy] = useState(false);
const [note, setNote] = useState(null);
@@ -39,9 +48,17 @@ export function GatesEditor({ projectId, settings }: { projectId: string; settin
const res = await fetch(`/api/v1/projects/${projectId}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
- body: JSON.stringify({ settings: nextSettings }),
+ body: JSON.stringify({ settings: nextSettings, builderPlanPolicy: planPolicy }),
});
- if (!res.ok) throw new Error(`save failed (${res.status})`);
+ if (!res.ok) {
+ const body = (await res.json().catch(() => null)) as {
+ error?: { code?: string; message?: string };
+ } | null;
+ throw new Error(
+ [body?.error?.code, body?.error?.message].filter(Boolean).join(": ") ||
+ `save failed (${res.status})`,
+ );
+ }
setNote("saved");
router.refresh();
} catch (err) {
@@ -75,6 +92,18 @@ export function GatesEditor({ projectId, settings }: { projectId: string; settin
placeholder={"pnpm lint\npnpm test"}
/>
+
+ setPlanPolicy(event.target.value as "optional" | "required")}
+ >
+ optional
+ required
+
+
{busy ? "saving…" : "save gates"}
diff --git a/apps/web/components/story/timeline.tsx b/apps/web/components/story/timeline.tsx
index 2ea6757a..5e713b0d 100644
--- a/apps/web/components/story/timeline.tsx
+++ b/apps/web/components/story/timeline.tsx
@@ -303,7 +303,11 @@ function humanizeAction(actionType: string | undefined): string {
function decisionOf(proposal: Proposal): string {
if (proposal.state === "rejected") return "rejected";
- if (proposal.state === "execution_failed") return "approved · execution failed";
+ if (proposal.state === "execution_failed") {
+ return proposal.executionError
+ ? `approved · blocked (${proposal.executionError})`
+ : "approved · execution failed";
+ }
return proposal.state === "approved" ? "approved" : proposal.state;
}
diff --git a/apps/web/components/story/trigger-buttons.tsx b/apps/web/components/story/trigger-buttons.tsx
index 92f1cb98..ecc03e28 100644
--- a/apps/web/components/story/trigger-buttons.tsx
+++ b/apps/web/components/story/trigger-buttons.tsx
@@ -9,10 +9,12 @@ export function StoryTriggerButtons({
projectId,
issueNumber,
repoId,
+ builderPlanRequired,
}: {
projectId: string;
issueNumber: number;
repoId: string;
+ builderPlanRequired: boolean;
}) {
const router = useRouter();
const [busy, setBusy] = useState(null);
@@ -61,11 +63,15 @@ export function StoryTriggerButtons({
void trigger("builder")}
>
{busy === "builder" ? "queuing…" : "builder"}
+ {builderPlanRequired ? (
+ approve the plan below
+ ) : null}
{error ? {error} : null}
);
diff --git a/packages/cli/templates/receipts/collect.mjs b/packages/cli/templates/receipts/collect.mjs
index 93379e6a..70a84a78 100644
--- a/packages/cli/templates/receipts/collect.mjs
+++ b/packages/cli/templates/receipts/collect.mjs
@@ -28,7 +28,8 @@ export function collectReceipt(env = process.env, now = new Date()) {
const engine = parseEngineEvidence(env.FACILITY_RECEIPT_ENGINE_JSONL);
const checkEvidence = parseChecks(env.FACILITY_RECEIPT_CHECKS_FILE);
const target = githubTarget(env.GITHUB_EVENT_PATH);
- const git = gitActivity(env.FACILITY_RECEIPT_BASE_SHA, env.GITHUB_WORKSPACE);
+ const baseSha = gitCommitSha(env.FACILITY_RECEIPT_BASE_SHA);
+ const git = gitActivity(baseSha, env.GITHUB_WORKSPACE);
const actor = env.GITHUB_ACTOR;
const receipt = {
schema: "facility.run.v1",
@@ -59,6 +60,7 @@ export function collectReceipt(env = process.env, now = new Date()) {
repo: env.GITHUB_REPOSITORY?.split("/")[1],
issue: target.issue,
pr: target.pr,
+ ...(baseSha ? { base_sha: baseSha } : {}),
...(actor ? { actor_sha256: sha256(actor) } : {}),
},
timing: {
@@ -250,6 +252,12 @@ function normalizeResult(value) {
return "failed";
}
+function gitCommitSha(value) {
+ return typeof value === "string" && /^[0-9a-f]{40}$/i.test(value)
+ ? value.toLowerCase()
+ : undefined;
+}
+
function requiredChoice(value, choices, name) {
if (!value || !choices.has(value))
throw new Error(`FACILITY_RECEIPT_${name.toUpperCase()} is invalid`);
diff --git a/packages/cli/test/receipts.test.mjs b/packages/cli/test/receipts.test.mjs
index e3e7305a..f6383617 100644
--- a/packages/cli/test/receipts.test.mjs
+++ b/packages/cli/test/receipts.test.mjs
@@ -29,6 +29,7 @@ test("collects a privacy-preserving, tamper-evident agent receipt", async () =>
FACILITY_RECEIPT_RESULT: "success",
FACILITY_RECEIPT_STARTED_AT: "2026-07-19T00:00:00.000Z",
FACILITY_RECEIPT_ENGINE_JSONL: enginePath,
+ FACILITY_RECEIPT_BASE_SHA: "a".repeat(40),
FACILITY_RECEIPT_OUTPUT: outputPath,
GITHUB_EVENT_PATH: eventPath,
GITHUB_REPOSITORY: "theam/mirror",
@@ -40,6 +41,7 @@ test("collects a privacy-preserving, tamper-evident agent receipt", async () =>
const receipt = collectReceipt(env, new Date("2026-07-19T00:01:00.000Z"));
assert.equal(receipt.schema, "facility.run.v1");
assert.equal(receipt.github.pr, 42);
+ assert.equal(receipt.github.base_sha, "a".repeat(40));
assert.equal(receipt.usage.input_tokens, 10);
assert.equal(receipt.activity.turns, 1);
assert.equal(receipt.activity.shell_commands, 1);
@@ -85,5 +87,6 @@ test("reports the full check count when receipt details are bounded", async () =
assert.equal(receipt.checks_truncated, true);
assert.equal(receipt.checks[0].name, "check-1");
assert.equal(receipt.checks.at(-1).name, "check-200");
+ assert.equal(receipt.github.base_sha, undefined);
assert.equal(verifyReceipt(receipt), true);
});
diff --git a/packages/core/src/receipts.ts b/packages/core/src/receipts.ts
index b080fb38..3d087fe3 100644
--- a/packages/core/src/receipts.ts
+++ b/packages/core/src/receipts.ts
@@ -51,6 +51,10 @@ export const FacilityReceiptSchema = z.object({
repo: z.string().optional(),
issue: z.number().int().optional(),
pr: z.number().int().optional(),
+ base_sha: z
+ .string()
+ .regex(/^[0-9a-f]{40}$/i)
+ .optional(),
actor_sha256: z.string().optional(),
})
.optional(),
@@ -155,6 +159,10 @@ const LegacyAgentReceiptSchema = z.object({
repo: z.string().optional(),
issue: z.number().int().optional(),
pr: z.number().int().optional(),
+ base_sha: z
+ .string()
+ .regex(/^[0-9a-f]{40}$/i)
+ .optional(),
actor: z.string().optional(),
actor_sha256: z.string().optional(),
})
@@ -206,6 +214,7 @@ export function parseLegacyAgentReceipt(json: unknown): FacilityReceipt {
repo: receipt.github.repo,
issue: receipt.github.issue,
pr: receipt.github.pr,
+ base_sha: receipt.github.base_sha,
actor_sha256:
receipt.github.actor_sha256 ??
(receipt.github.actor ? hashActor(receipt.github.actor) : undefined),
diff --git a/packages/core/test/core.test.ts b/packages/core/test/core.test.ts
index 27d227d2..88f001bc 100644
--- a/packages/core/test/core.test.ts
+++ b/packages/core/test/core.test.ts
@@ -404,13 +404,14 @@ describe("receipts", () => {
result: "succeeded",
usage: { input_tokens: 100, output_tokens: 50, cost_usd: 1.235, cost_source: "provider" },
activity: { turns: 2, shell_commands: 1, file_changes: 3, mcp_tool_calls: 0, errors: 0 },
- github: { owner: "example", repo: "product", actor: "octo" },
+ github: { owner: "example", repo: "product", base_sha: "b".repeat(40), actor: "octo" },
timing: { started_at: "2026-01-01T00:00:00Z", duration_ms: 1000 },
checks: [{ name: "pnpm test", status: "passed", source: "platform", exit_code: 0 }],
});
expect(receipt.schema).toBe("facility.run.v1");
expect(receipt.usage.cost_cents).toBe(124);
expect(receipt.github?.actor_sha256).toMatch(/^[0-9a-f]{64}$/);
+ expect(receipt.github?.base_sha).toBe("b".repeat(40));
expect(receipt.checks).toEqual([
{ name: "pnpm test", status: "passed", source: "platform", exit_code: 0 },
]);
@@ -444,4 +445,27 @@ describe("receipts", () => {
}),
).toBe(false);
});
+
+ it("keeps the optional base commit inside receipt integrity", () => {
+ const baseSha = "c".repeat(40);
+ const receipt = parseLegacyAgentReceipt({
+ schema: "example.agent_sdlc.run.v1",
+ provider: "codex_cli",
+ mode: "builder",
+ result: "succeeded",
+ usage: { input_tokens: 1, output_tokens: 1, cost_source: "provider" },
+ activity: {},
+ github: { base_sha: baseSha },
+ timing: { started_at: "2026-08-16T00:00:00.000Z" },
+ });
+ const sealed = sealFacilityReceipt(receipt, null);
+
+ expect(verifyFacilityReceipt(sealed)).toBe(true);
+ expect(
+ verifyFacilityReceipt({
+ ...sealed,
+ github: { ...sealed.github, base_sha: "d".repeat(40) },
+ }),
+ ).toBe(false);
+ });
});
diff --git a/packages/db/migrations/0042_run_base_sha_provenance.sql b/packages/db/migrations/0042_run_base_sha_provenance.sql
new file mode 100644
index 00000000..4bb5db54
--- /dev/null
+++ b/packages/db/migrations/0042_run_base_sha_provenance.sql
@@ -0,0 +1,5 @@
+ALTER TABLE runs
+ ADD COLUMN workspace_base_sha text;
+
+ALTER TABLE run_deliveries
+ ADD COLUMN base_sha text;
diff --git a/packages/db/migrations/0043_builder_plan_policy.sql b/packages/db/migrations/0043_builder_plan_policy.sql
new file mode 100644
index 00000000..61c1d294
--- /dev/null
+++ b/packages/db/migrations/0043_builder_plan_policy.sql
@@ -0,0 +1,8 @@
+-- 0042 is reserved by the workspace/base provenance work in #166. Keeping
+-- this policy migration at 0043 lets the two contributions compose cleanly.
+ALTER TABLE projects
+ ADD COLUMN builder_plan_policy text NOT NULL DEFAULT 'optional';
+
+ALTER TABLE projects
+ ADD CONSTRAINT projects_builder_plan_policy_check
+ CHECK (builder_plan_policy IN ('optional', 'required'));
diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts
index eba9b04f..4b8d04b8 100644
--- a/packages/db/src/schema.ts
+++ b/packages/db/src/schema.ts
@@ -106,6 +106,7 @@ export const projects = pgTable(
slug: text("slug").notNull(),
description: text("description"),
systemVersion: text("system_version").notNull().default("v1"),
+ builderPlanPolicy: text("builder_plan_policy").notNull().default("optional"),
settings: jsonb("settings").notNull().default(sql`'{}'::jsonb`),
status: text("status").notNull().default("active"),
...timestamps,
@@ -113,6 +114,10 @@ export const projects = pgTable(
(table) => [
unique("projects_org_slug_uidx").on(table.orgId, table.slug),
index("projects_org_idx").on(table.orgId),
+ check(
+ "projects_builder_plan_policy_check",
+ sql`${table.builderPlanPolicy} IN ('optional', 'required')`,
+ ),
],
);
@@ -492,6 +497,7 @@ export const runs = pgTable(
ciRepairKey: text("ci_repair_key"),
transcriptUri: text("transcript_uri"),
sessionStateUri: text("session_state_uri"),
+ workspaceBaseSha: text("workspace_base_sha"),
error: text("error"),
queuedAt: timestamp("queued_at", { withTimezone: true }).defaultNow().notNull(),
startedAt: timestamp("started_at", { withTimezone: true }),
@@ -550,6 +556,7 @@ export const runDeliveries = pgTable(
repoName: text("repo_name").notNull(),
headBranch: text("head_branch").notNull(),
expectedHeadSha: text("expected_head_sha").notNull(),
+ baseSha: text("base_sha"),
baseBranch: text("base_branch").notNull(),
title: text("title").notNull(),
body: text("body").notNull(),
diff --git a/packages/db/test/db.test.ts b/packages/db/test/db.test.ts
index f0aabec6..c58b9c7a 100644
--- a/packages/db/test/db.test.ts
+++ b/packages/db/test/db.test.ts
@@ -743,6 +743,9 @@ describe("db", async () => {
OR (table_name = 'spend_counters' AND column_name = 'spent_cents')
OR (table_name = 'analytics_daily' AND column_name IN ('cost_cents', 'outcomes_assessed', 'outcomes_accepted'))
OR (table_name = 'provider_credentials' AND column_name = 'auth_mode')
+ OR (table_name = 'projects' AND column_name = 'builder_plan_policy')
+ OR (table_name = 'runs' AND column_name = 'workspace_base_sha')
+ OR (table_name = 'run_deliveries' AND column_name = 'base_sha')
`,
)) as Iterable<{ table_name: string; column_name: string; data_type: string }>;
const columnTypes = new Map(
@@ -757,6 +760,9 @@ describe("db", async () => {
expect(columnTypes.get("analytics_daily.outcomes_assessed")).toBe("integer");
expect(columnTypes.get("analytics_daily.outcomes_accepted")).toBe("integer");
expect(columnTypes.get("provider_credentials.auth_mode")).toBe("text");
+ expect(columnTypes.get("projects.builder_plan_policy")).toBe("text");
+ expect(columnTypes.get("runs.workspace_base_sha")).toBe("text");
+ expect(columnTypes.get("run_deliveries.base_sha")).toBe("text");
const indexes = (await db.execute(
sql`
SELECT indexname
@@ -864,6 +870,17 @@ describe("db", async () => {
expect(Array.from(ciEventChecks).map((row) => row.conname)).toEqual([
"gh_ci_events_state_check",
]);
+ const projectChecks = (await db.execute(
+ sql`
+ SELECT conname
+ FROM pg_constraint
+ WHERE conrelid = 'projects'::regclass
+ AND conname = 'projects_builder_plan_policy_check'
+ `,
+ )) as Iterable<{ conname: string }>;
+ expect(Array.from(projectChecks).map((row) => row.conname)).toEqual([
+ "projects_builder_plan_policy_check",
+ ]);
const applied = (await db.execute(
sql`
SELECT name
@@ -874,7 +891,10 @@ describe("db", async () => {
// A developer database can include later migrations from another worktree;
// assert this checkout's latest migration was applied without assuming it
// is the newest row in that shared database.
- expect(Array.from(applied).map((row) => row.name)).toContain("0041_github_ci_story_events.sql");
+ expect(Array.from(applied).map((row) => row.name)).toContain("0043_builder_plan_policy.sql");
+ expect(Array.from(applied).map((row) => row.name)).toContain(
+ "0042_run_base_sha_provenance.sql",
+ );
const providerCredentialChecks = (await db.execute(
sql`
SELECT conname
diff --git a/packages/run-objective/src/index.d.ts b/packages/run-objective/src/index.d.ts
index 9d5be84a..64fe7d04 100644
--- a/packages/run-objective/src/index.d.ts
+++ b/packages/run-objective/src/index.d.ts
@@ -1,2 +1,4 @@
export declare function isBuilderMode(mode: string): boolean;
+export declare function agentDefTriggersBuilder(value: unknown): boolean;
+export declare function isBuilderAgent(name: string, triggers: unknown): boolean;
export declare function runObjectiveText(value: unknown): string | null;
diff --git a/packages/run-objective/src/index.js b/packages/run-objective/src/index.js
index 6553f29f..6fbde10c 100644
--- a/packages/run-objective/src/index.js
+++ b/packages/run-objective/src/index.js
@@ -1,6 +1,22 @@
/** @param {string} mode */
export function isBuilderMode(mode) {
- return mode === "builder" || mode.endsWith("-builder");
+ const canonical = mode.replaceAll("_", "-");
+ return canonical === "builder" || canonical.endsWith("-builder");
+}
+
+/** @param {unknown} value */
+export function agentDefTriggersBuilder(value) {
+ if (!Array.isArray(value)) return false;
+ return value.some((trigger) => {
+ const entry = objectValue(trigger);
+ const command = stringValue(entry.command) ?? stringValue(entry.handle);
+ return command ? isBuilderMode(command.replace(/^\//, "")) : false;
+ });
+}
+
+/** @param {string} name @param {unknown} triggers */
+export function isBuilderAgent(name, triggers) {
+ return isBuilderMode(name) || agentDefTriggersBuilder(triggers);
}
/**
@@ -36,3 +52,8 @@ export function runObjectiveText(value) {
function objectValue(value) {
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
}
+
+/** @param {unknown} value @returns {string | null} */
+function stringValue(value) {
+ return typeof value === "string" && value.length > 0 ? value : null;
+}
diff --git a/packages/run-objective/test/index.test.ts b/packages/run-objective/test/index.test.ts
index 682bc7f4..2d475e2d 100644
--- a/packages/run-objective/test/index.test.ts
+++ b/packages/run-objective/test/index.test.ts
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
-import { isBuilderMode, runObjectiveText } from "../src/index.js";
+import {
+ agentDefTriggersBuilder,
+ isBuilderAgent,
+ isBuilderMode,
+ runObjectiveText,
+} from "../src/index.js";
describe("run objective policy", () => {
it("recognizes every governed builder objective source", () => {
@@ -34,6 +39,14 @@ describe("run objective policy", () => {
it("recognizes canonical and prefixed builder modes", () => {
expect(isBuilderMode("builder")).toBe(true);
expect(isBuilderMode("codex-builder")).toBe(true);
+ expect(isBuilderMode("codex_builder")).toBe(true);
expect(isBuilderMode("architect")).toBe(false);
});
+
+ it("recognizes renamed Builder definitions from command triggers", () => {
+ expect(agentDefTriggersBuilder([{ type: "command", command: "builder" }])).toBe(true);
+ expect(agentDefTriggersBuilder([{ type: "command", handle: "/codex_builder" }])).toBe(true);
+ expect(isBuilderAgent("implementation-agent", [{ handle: "/codex-builder" }])).toBe(true);
+ expect(isBuilderAgent("architect", [{ handle: "/architect" }])).toBe(false);
+ });
});
diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json
index 882c05d2..036685f0 100644
--- a/packages/sdk/openapi.json
+++ b/packages/sdk/openapi.json
@@ -3603,6 +3603,13 @@
"systemVersion": {
"type": "string"
},
+ "builderPlanPolicy": {
+ "type": "string",
+ "enum": [
+ "optional",
+ "required"
+ ]
+ },
"settings": {
"type": "object",
"additionalProperties": {}
@@ -3626,6 +3633,7 @@
"slug",
"description",
"systemVersion",
+ "builderPlanPolicy",
"settings",
"status",
"createdAt",
@@ -3750,6 +3758,13 @@
"description": {
"type": "string"
},
+ "builderPlanPolicy": {
+ "type": "string",
+ "enum": [
+ "optional",
+ "required"
+ ]
+ },
"settings": {
"type": "object",
"additionalProperties": {}
@@ -3790,6 +3805,13 @@
"systemVersion": {
"type": "string"
},
+ "builderPlanPolicy": {
+ "type": "string",
+ "enum": [
+ "optional",
+ "required"
+ ]
+ },
"settings": {
"type": "object",
"additionalProperties": {}
@@ -3813,6 +3835,7 @@
"slug",
"description",
"systemVersion",
+ "builderPlanPolicy",
"settings",
"status",
"createdAt",
@@ -3972,6 +3995,13 @@
"systemVersion": {
"type": "string"
},
+ "builderPlanPolicy": {
+ "type": "string",
+ "enum": [
+ "optional",
+ "required"
+ ]
+ },
"settings": {
"type": "object",
"additionalProperties": {}
@@ -3995,6 +4025,7 @@
"slug",
"description",
"systemVersion",
+ "builderPlanPolicy",
"settings",
"status",
"createdAt",
@@ -4118,6 +4149,13 @@
"status": {
"type": "string"
},
+ "builderPlanPolicy": {
+ "type": "string",
+ "enum": [
+ "optional",
+ "required"
+ ]
+ },
"settings": {
"type": "object",
"additionalProperties": {}
@@ -4164,6 +4202,13 @@
"systemVersion": {
"type": "string"
},
+ "builderPlanPolicy": {
+ "type": "string",
+ "enum": [
+ "optional",
+ "required"
+ ]
+ },
"settings": {
"type": "object",
"additionalProperties": {}
@@ -4187,6 +4232,7 @@
"slug",
"description",
"systemVersion",
+ "builderPlanPolicy",
"settings",
"status",
"createdAt",
@@ -6639,6 +6685,10 @@
"nullable": true,
"type": "string"
},
+ "workspaceBaseSha": {
+ "nullable": true,
+ "type": "string"
+ },
"error": {
"nullable": true,
"type": "string"
@@ -6685,6 +6735,7 @@
"engineSessionId",
"transcriptUri",
"sessionStateUri",
+ "workspaceBaseSha",
"error",
"queuedAt",
"startedAt",
@@ -6968,6 +7019,10 @@
"nullable": true,
"type": "string"
},
+ "workspaceBaseSha": {
+ "nullable": true,
+ "type": "string"
+ },
"error": {
"nullable": true,
"type": "string"
@@ -7014,6 +7069,7 @@
"engineSessionId",
"transcriptUri",
"sessionStateUri",
+ "workspaceBaseSha",
"error",
"queuedAt",
"startedAt",
@@ -7463,6 +7519,10 @@
"nullable": true,
"type": "string"
},
+ "workspaceBaseSha": {
+ "nullable": true,
+ "type": "string"
+ },
"error": {
"nullable": true,
"type": "string"
@@ -7509,6 +7569,7 @@
"engineSessionId",
"transcriptUri",
"sessionStateUri",
+ "workspaceBaseSha",
"error",
"queuedAt",
"startedAt",
@@ -7923,6 +7984,10 @@
"nullable": true,
"type": "string"
},
+ "workspaceBaseSha": {
+ "nullable": true,
+ "type": "string"
+ },
"error": {
"nullable": true,
"type": "string"
@@ -7989,6 +8054,7 @@
"engineSessionId",
"transcriptUri",
"sessionStateUri",
+ "workspaceBaseSha",
"error",
"queuedAt",
"startedAt",
@@ -8234,6 +8300,10 @@
"nullable": true,
"type": "string"
},
+ "workspaceBaseSha": {
+ "nullable": true,
+ "type": "string"
+ },
"error": {
"nullable": true,
"type": "string"
@@ -8280,6 +8350,7 @@
"engineSessionId",
"transcriptUri",
"sessionStateUri",
+ "workspaceBaseSha",
"error",
"queuedAt",
"startedAt",
@@ -8534,6 +8605,10 @@
"nullable": true,
"type": "string"
},
+ "workspaceBaseSha": {
+ "nullable": true,
+ "type": "string"
+ },
"error": {
"nullable": true,
"type": "string"
@@ -8580,6 +8655,7 @@
"engineSessionId",
"transcriptUri",
"sessionStateUri",
+ "workspaceBaseSha",
"error",
"queuedAt",
"startedAt",
@@ -9809,6 +9885,10 @@
"nullable": true,
"type": "string"
},
+ "workspaceBaseSha": {
+ "nullable": true,
+ "type": "string"
+ },
"error": {
"nullable": true,
"type": "string"
@@ -9855,6 +9935,7 @@
"engineSessionId",
"transcriptUri",
"sessionStateUri",
+ "workspaceBaseSha",
"error",
"queuedAt",
"startedAt",
@@ -13420,6 +13501,10 @@
"nullable": true,
"type": "string"
},
+ "workspaceBaseSha": {
+ "nullable": true,
+ "type": "string"
+ },
"error": {
"nullable": true,
"type": "string"
@@ -13466,6 +13551,7 @@
"engineSessionId",
"transcriptUri",
"sessionStateUri",
+ "workspaceBaseSha",
"error",
"queuedAt",
"startedAt",
@@ -18258,6 +18344,10 @@
"state": {
"type": "string"
},
+ "executionError": {
+ "nullable": true,
+ "type": "string"
+ },
"decidedBy": {
"nullable": true,
"type": "string"
@@ -18334,6 +18424,10 @@
"state": {
"type": "string"
},
+ "executionError": {
+ "nullable": true,
+ "type": "string"
+ },
"decidedBy": {
"nullable": true,
"type": "string"
@@ -18607,6 +18701,10 @@
"state": {
"type": "string"
},
+ "executionError": {
+ "nullable": true,
+ "type": "string"
+ },
"decidedBy": {
"nullable": true,
"type": "string"
@@ -18870,6 +18968,10 @@
"state": {
"type": "string"
},
+ "executionError": {
+ "nullable": true,
+ "type": "string"
+ },
"decidedBy": {
"nullable": true,
"type": "string"
@@ -19101,6 +19203,10 @@
"state": {
"type": "string"
},
+ "executionError": {
+ "nullable": true,
+ "type": "string"
+ },
"decidedBy": {
"nullable": true,
"type": "string"
@@ -19340,6 +19446,10 @@
"state": {
"type": "string"
},
+ "executionError": {
+ "nullable": true,
+ "type": "string"
+ },
"decidedBy": {
"nullable": true,
"type": "string"
@@ -19541,6 +19651,10 @@
"state": {
"type": "string"
},
+ "executionError": {
+ "nullable": true,
+ "type": "string"
+ },
"decidedBy": {
"nullable": true,
"type": "string"
@@ -27394,6 +27508,10 @@
"state": {
"type": "string"
},
+ "executionError": {
+ "nullable": true,
+ "type": "string"
+ },
"decidedBy": {
"nullable": true,
"type": "string"
diff --git a/packages/sdk/src/schema.d.ts b/packages/sdk/src/schema.d.ts
index b4aa3246..f5edd1fd 100644
--- a/packages/sdk/src/schema.d.ts
+++ b/packages/sdk/src/schema.d.ts
@@ -4374,6 +4374,8 @@ export interface operations {
slug: string;
description: string | null;
systemVersion: string;
+ /** @enum {string} */
+ builderPlanPolicy: "optional" | "required";
settings: {
[key: string]: unknown;
};
@@ -4475,6 +4477,8 @@ export interface operations {
name: string;
slug: string;
description?: string;
+ /** @enum {string} */
+ builderPlanPolicy?: "optional" | "required";
settings?: {
[key: string]: unknown;
};
@@ -4495,6 +4499,8 @@ export interface operations {
slug: string;
description: string | null;
systemVersion: string;
+ /** @enum {string} */
+ builderPlanPolicy: "optional" | "required";
settings: {
[key: string]: unknown;
};
@@ -4604,6 +4610,8 @@ export interface operations {
slug: string;
description: string | null;
systemVersion: string;
+ /** @enum {string} */
+ builderPlanPolicy: "optional" | "required";
settings: {
[key: string]: unknown;
};
@@ -4800,6 +4808,8 @@ export interface operations {
name?: string;
description?: string;
status?: string;
+ /** @enum {string} */
+ builderPlanPolicy?: "optional" | "required";
settings?: {
[key: string]: unknown;
};
@@ -4820,6 +4830,8 @@ export interface operations {
slug: string;
description: string | null;
systemVersion: string;
+ /** @enum {string} */
+ builderPlanPolicy: "optional" | "required";
settings: {
[key: string]: unknown;
};
@@ -6130,6 +6142,7 @@ export interface operations {
engineSessionId: string | null;
transcriptUri: string | null;
sessionStateUri: string | null;
+ workspaceBaseSha: string | null;
error: string | null;
/** Format: date-time */
queuedAt: string;
@@ -6295,6 +6308,7 @@ export interface operations {
engineSessionId: string | null;
transcriptUri: string | null;
sessionStateUri: string | null;
+ workspaceBaseSha: string | null;
error: string | null;
/** Format: date-time */
queuedAt: string;
@@ -6559,6 +6573,7 @@ export interface operations {
engineSessionId: string | null;
transcriptUri: string | null;
sessionStateUri: string | null;
+ workspaceBaseSha: string | null;
error: string | null;
/** Format: date-time */
queuedAt: string;
@@ -6809,6 +6824,7 @@ export interface operations {
engineSessionId: string | null;
transcriptUri: string | null;
sessionStateUri: string | null;
+ workspaceBaseSha: string | null;
error: string | null;
/** Format: date-time */
queuedAt: string;
@@ -6962,6 +6978,7 @@ export interface operations {
engineSessionId: string | null;
transcriptUri: string | null;
sessionStateUri: string | null;
+ workspaceBaseSha: string | null;
error: string | null;
/** Format: date-time */
queuedAt: string;
@@ -7113,6 +7130,7 @@ export interface operations {
engineSessionId: string | null;
transcriptUri: string | null;
sessionStateUri: string | null;
+ workspaceBaseSha: string | null;
error: string | null;
/** Format: date-time */
queuedAt: string;
@@ -7891,6 +7909,7 @@ export interface operations {
engineSessionId: string | null;
transcriptUri: string | null;
sessionStateUri: string | null;
+ workspaceBaseSha: string | null;
error: string | null;
/** Format: date-time */
queuedAt: string;
@@ -9663,6 +9682,7 @@ export interface operations {
engineSessionId: string | null;
transcriptUri: string | null;
sessionStateUri: string | null;
+ workspaceBaseSha: string | null;
error: string | null;
/** Format: date-time */
queuedAt: string;
@@ -12532,6 +12552,7 @@ export interface operations {
};
contextMd: string;
state: string;
+ executionError?: string | null;
decidedBy: string | null;
/** Format: date-time */
decidedAt: string | null;
@@ -12554,6 +12575,7 @@ export interface operations {
};
contextMd: string;
state: string;
+ executionError?: string | null;
decidedBy: string | null;
/** Format: date-time */
decidedAt: string | null;
@@ -12690,6 +12712,7 @@ export interface operations {
};
contextMd: string;
state: string;
+ executionError?: string | null;
decidedBy: string | null;
/** Format: date-time */
decidedAt: string | null;
@@ -12833,6 +12856,7 @@ export interface operations {
};
contextMd: string;
state: string;
+ executionError?: string | null;
decidedBy: string | null;
/** Format: date-time */
decidedAt: string | null;
@@ -12963,6 +12987,7 @@ export interface operations {
};
contextMd: string;
state: string;
+ executionError?: string | null;
decidedBy: string | null;
/** Format: date-time */
decidedAt: string | null;
@@ -13089,6 +13114,7 @@ export interface operations {
};
contextMd: string;
state: string;
+ executionError?: string | null;
decidedBy: string | null;
/** Format: date-time */
decidedAt: string | null;
@@ -13207,6 +13233,7 @@ export interface operations {
};
contextMd: string;
state: string;
+ executionError?: string | null;
decidedBy: string | null;
/** Format: date-time */
decidedAt: string | null;
@@ -17661,6 +17688,7 @@ export interface operations {
};
contextMd: string;
state: string;
+ executionError?: string | null;
decidedBy: string | null;
/** Format: date-time */
decidedAt: string | null;
diff --git a/runner/src/index.ts b/runner/src/index.ts
index 1dd4617c..7bfc5a9e 100644
--- a/runner/src/index.ts
+++ b/runner/src/index.ts
@@ -137,6 +137,7 @@ async function main() {
await prepareRunnerRuntime();
});
restoredSessionState = await restoreSessionState(activeBundle);
+ await recordWorkspaceProvenance(activeBundle);
steerStop = startSteeringPoll();
const packageInstallCmd = activeBundle.packageInstallCmd;
if (packageInstallCmd) {
@@ -545,6 +546,22 @@ export async function prepareWorkspace(
}
}
+export async function preparedWorkspaceBaseSha(bundle: RunBundle, root = workRoot) {
+ if (!bundle.repo.cloneUrl) return null;
+ const baseSha = (await gitOutput(cwdFor(bundle, root), ["rev-parse", "HEAD"])).trim();
+ if (!/^[0-9a-f]{40}$/i.test(baseSha)) throw new Error("workspace_base_sha_invalid");
+ return baseSha.toLowerCase();
+}
+
+export async function recordWorkspaceProvenance(bundle: RunBundle, root = workRoot) {
+ const baseSha = await preparedWorkspaceBaseSha(bundle, root);
+ if (!baseSha) return null;
+ return api<{ baseSha: string }>(`/internal/runs/${currentRunId()}/workspace`, {
+ method: "POST",
+ body: JSON.stringify({ baseSha }),
+ });
+}
+
async function pathExists(path: string) {
try {
await lstat(path);
@@ -1922,6 +1939,7 @@ export async function shipGitChanges(
return {
branch: published.branch,
headSha: published.headSha,
+ baseSha,
changed: true,
...(pullRequest
? {
diff --git a/runner/test/github-delivery.integration.test.ts b/runner/test/github-delivery.integration.test.ts
index 9793641f..79653556 100644
--- a/runner/test/github-delivery.integration.test.ts
+++ b/runner/test/github-delivery.integration.test.ts
@@ -440,6 +440,7 @@ describe.sequential("signed GitHub delivery integration", () => {
expect(result).toEqual({
branch: "feature/task",
headSha: "signed_sha",
+ baseSha: fixture.baseSha,
changed: true,
pullRequestTitle: "fix!: deliver signed task",
pullRequestBody: "## Summary\n\n- Deliver the signed task.",
@@ -579,7 +580,12 @@ describe.sequential("signed GitHub delivery integration", () => {
githubFetch: github.githubFetch,
});
- expect(result).toEqual({ branch: "feature/task", headSha: "signed_sha", changed: true });
+ expect(result).toEqual({
+ branch: "feature/task",
+ headSha: "signed_sha",
+ baseSha: fixture.baseSha,
+ changed: true,
+ });
expect(facilityRequests(facility.requests, "/push-token")).toHaveLength(1);
expect(github.originalUrls).toEqual(["https://api.github.com/graphql"]);
expect(github.committed()).toBe(true);
@@ -606,7 +612,12 @@ describe.sequential("signed GitHub delivery integration", () => {
githubFetch: github.githubFetch,
});
- expect(result).toEqual({ branch: "feature/task", headSha: "signed_sha", changed: true });
+ expect(result).toEqual({
+ branch: "feature/task",
+ headSha: "signed_sha",
+ baseSha: fixture.baseSha,
+ changed: true,
+ });
expect(facilityRequests(facility.requests, "/push-token")).toHaveLength(1);
expect(github.originalUrls).toEqual(["https://api.github.com/graphql"]);
expect(github.committed()).toBe(true);
diff --git a/runner/test/workspace.test.ts b/runner/test/workspace.test.ts
index 97268bfd..2ae6d9d0 100644
--- a/runner/test/workspace.test.ts
+++ b/runner/test/workspace.test.ts
@@ -33,6 +33,7 @@ import {
gitOutput,
handleControlMessage,
parseGitNameStatus,
+ preparedWorkspaceBaseSha,
prepareWorkspace,
privateRegistryInstallCommand,
privateRegistryNpmrc,
@@ -1403,9 +1404,11 @@ describe("Claude resume controls", () => {
it("replays a workspace checkpoint when the admitted base changed", async () => {
const root = await mkdtemp(join(tmpdir(), "facility-runner-resume-stale-"));
const source = join(root, "source");
- const target = join(root, "target");
+ const workspace = join(root, "workspace");
+ const target = join(workspace, "repo");
const checkpoint = join(root, "checkpoint");
await mkdir(source);
+ await mkdir(workspace);
execFileSync("git", ["init", "--initial-branch=main"], { cwd: source });
execFileSync("git", ["config", "user.name", "Facility Test"], { cwd: source });
execFileSync("git", ["config", "user.email", "facility@example.test"], { cwd: source });
@@ -1413,6 +1416,10 @@ describe("Claude resume controls", () => {
execFileSync("git", ["add", "task.txt"], { cwd: source });
execFileSync("git", ["commit", "-m", "chore: initialize stale fixture"], { cwd: source });
execFileSync("git", ["update-ref", "refs/remotes/origin/main", "HEAD"], { cwd: source });
+ const originalBaseSha = execFileSync("git", ["rev-parse", "HEAD"], {
+ cwd: source,
+ encoding: "utf8",
+ }).trim();
await writeFile(join(source, "task.txt"), "resumable work\n");
await createWorkspaceCheckpoint(source, checkpoint, "main");
@@ -1423,10 +1430,32 @@ describe("Claude resume controls", () => {
execFileSync("git", ["add", "new-base.txt"], { cwd: target });
execFileSync("git", ["commit", "-m", "chore: advance admitted base"], { cwd: target });
execFileSync("git", ["update-ref", "refs/remotes/origin/main", "HEAD"], { cwd: target });
+ const currentBaseSha = execFileSync("git", ["rev-parse", "HEAD"], {
+ cwd: target,
+ encoding: "utf8",
+ }).trim();
await expect(restoreWorkspaceCheckpoint(target, checkpoint, "main")).resolves.toBe(true);
- await expect(readFile(join(target, "task.txt"), "utf8")).resolves.toBe("resumable work\n");
- await expect(readFile(join(target, "new-base.txt"), "utf8")).resolves.toBe("new base\n");
+ await expect(
+ preparedWorkspaceBaseSha(
+ bundle({
+ repo: {
+ cloneUrl: "https://github.com/acme/widget.git",
+ branch: "main",
+ expectedHeadSha: null,
+ installationTokenRef: null,
+ },
+ }),
+ workspace,
+ ),
+ ).resolves.toBe(currentBaseSha);
+ expect(currentBaseSha).not.toBe(originalBaseSha);
+ await expect(
+ readFile(join(target, "task.txt"), "utf8").then((body) => body.replace(/\r\n/g, "\n")),
+ ).resolves.toBe("resumable work\n");
+ await expect(
+ readFile(join(target, "new-base.txt"), "utf8").then((body) => body.replace(/\r\n/g, "\n")),
+ ).resolves.toBe("new base\n");
expect(
execFileSync("git", ["branch", "--show-current"], { cwd: target, encoding: "utf8" }).trim(),
).toBe("main");
diff --git a/services/api/src/builder-plan-freshness.ts b/services/api/src/builder-plan-freshness.ts
new file mode 100644
index 00000000..bd3dba14
--- /dev/null
+++ b/services/api/src/builder-plan-freshness.ts
@@ -0,0 +1,156 @@
+import { type FacilityDb, proposals, repos } from "@facility/db";
+import { and, eq } from "drizzle-orm";
+import { ApiError } from "./errors.js";
+import {
+ createGithubClientFactory,
+ type FacilityGithubClient,
+ type GithubClientFactory,
+} from "./github/client.js";
+import { githubIssueRevisionContext, githubIssueRevisionSha256 } from "./github/issue-revision.js";
+import { createGithubClientForRepo } from "./github/kickstart.js";
+import type { AppConfig } from "./types.js";
+
+export type BuilderPlanFreshnessEvidence = {
+ baseSha: string;
+ issueRevisionSha256: string;
+ checkedAt: string;
+};
+
+type FreshnessClient = Pick<
+ FacilityGithubClient,
+ "getDefaultBranchSha" | "getIssue" | "listIssueComments"
+>;
+
+export type BuilderPlanFreshnessOptions = {
+ config?: AppConfig;
+ githubFactory?: GithubClientFactory;
+ githubClient?: {
+ owner: string;
+ repo: string;
+ client: FreshnessClient;
+ };
+};
+
+const ISSUE_CONTEXT_MAX_CHARS = 512 * 1024;
+
+export async function resolveBuilderPlanFreshnessForRun(
+ db: FacilityDb,
+ run: { orgId: string; projectId: string; trigger: unknown },
+ options: BuilderPlanFreshnessOptions = {},
+): Promise {
+ const trigger = objectValue(run.trigger);
+ const proposalId = stringValue(trigger.proposalId);
+ const architectRunId = stringValue(trigger.architectRunId);
+ if (trigger.source !== "plan_acceptance" || !proposalId || !architectRunId) {
+ throw freshnessUnavailable("plan_acceptance_context_missing");
+ }
+ const proposal = (
+ await db
+ .select()
+ .from(proposals)
+ .where(
+ and(
+ eq(proposals.orgId, run.orgId),
+ eq(proposals.projectId, run.projectId),
+ eq(proposals.id, proposalId),
+ eq(proposals.runId, architectRunId),
+ ),
+ )
+ .limit(1)
+ )[0];
+ if (!proposal) throw freshnessUnavailable("proposal_not_found");
+ return resolveBuilderPlanFreshnessForProposal(db, proposal, options);
+}
+
+export async function resolveBuilderPlanFreshnessForProposal(
+ db: FacilityDb,
+ proposal: Pick,
+ options: BuilderPlanFreshnessOptions = {},
+): Promise {
+ if (!proposal.projectId) throw freshnessUnavailable("proposal_project_missing");
+ const payload = objectValue(proposal.payload);
+ const repoId = stringValue(payload.repoId);
+ const issueNumber = positiveInteger(payload.issueNumber);
+ if (!repoId || !issueNumber) throw freshnessUnavailable("proposal_issue_context_missing");
+ const repo = (
+ await db
+ .select()
+ .from(repos)
+ .where(
+ and(
+ eq(repos.orgId, proposal.orgId),
+ eq(repos.projectId, proposal.projectId),
+ eq(repos.id, repoId),
+ ),
+ )
+ .limit(1)
+ )[0];
+ if (!repo) throw freshnessUnavailable("proposal_repo_missing");
+
+ let client: FreshnessClient;
+ try {
+ if (options.githubClient) {
+ if (options.githubClient.owner !== repo.owner || options.githubClient.repo !== repo.name) {
+ throw freshnessUnavailable("github_client_repo_mismatch");
+ }
+ client = options.githubClient.client;
+ } else {
+ const factory =
+ options.githubFactory ??
+ (options.config ? createGithubClientFactory(options.config) : null);
+ if (!factory) throw freshnessUnavailable("github_client_unavailable");
+ client = await createGithubClientForRepo(db, factory, repo);
+ }
+ const [baseSha, issue, comments] = await Promise.all([
+ client.getDefaultBranchSha(),
+ client.getIssue(issueNumber),
+ client.listIssueComments(issueNumber, ISSUE_CONTEXT_MAX_CHARS),
+ ]);
+ const normalizedBaseSha = gitSha(baseSha);
+ const issueRevisionSha256 = githubIssueRevisionSha256(
+ githubIssueRevisionContext(issue, comments),
+ );
+ if (!normalizedBaseSha || !issueRevisionSha256) {
+ throw freshnessUnavailable("github_freshness_invalid");
+ }
+ return {
+ baseSha: normalizedBaseSha,
+ issueRevisionSha256,
+ checkedAt: new Date().toISOString(),
+ };
+ } catch (error) {
+ if (error instanceof ApiError && error.code === "builder_plan_freshness_unavailable") {
+ throw error;
+ }
+ throw freshnessUnavailable(
+ error instanceof Error ? `github_freshness_error:${error.message}` : "github_freshness_error",
+ );
+ }
+}
+
+function freshnessUnavailable(reason: string) {
+ return new ApiError(
+ 409,
+ "builder_plan_freshness_unavailable",
+ "Facility could not verify the approved repository and issue revision",
+ { reason },
+ );
+}
+
+function gitSha(value: unknown) {
+ return typeof value === "string" && /^[a-f0-9]{40}$/i.test(value) ? value.toLowerCase() : null;
+}
+
+function stringValue(value: unknown) {
+ return typeof value === "string" && value.length > 0 ? value : null;
+}
+
+function positiveInteger(value: unknown) {
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
+}
+
+function objectValue(value: unknown): Record {
+ return value !== null && typeof value === "object" && !Array.isArray(value)
+ ? (value as Record)
+ : {};
+}
diff --git a/services/api/src/builder-plan-policy.ts b/services/api/src/builder-plan-policy.ts
new file mode 100644
index 00000000..e49325a2
--- /dev/null
+++ b/services/api/src/builder-plan-policy.ts
@@ -0,0 +1,692 @@
+import { createHash } from "node:crypto";
+import { FacilityReceiptSchema, verifyFacilityReceipt } from "@facility/core";
+import {
+ type AuditInsert,
+ actionTypes,
+ agentDefs,
+ type FacilityDb,
+ insertAuditEvent,
+ projects,
+ proposalEvents,
+ proposals,
+ repos,
+ runs,
+} from "@facility/db";
+import { agentDefTriggersBuilder, isBuilderMode } from "@facility/run-objective";
+import { and, eq, sql } from "drizzle-orm";
+import { ApiError } from "./errors.js";
+
+export type BuilderPlanPolicy = "optional" | "required";
+export type BuilderPlanDenialCode =
+ | "builder_plan_required"
+ | "builder_plan_context_invalid"
+ | "builder_plan_expired"
+ | "builder_plan_rejected"
+ | "builder_plan_already_consumed"
+ | "builder_plan_stale"
+ | "builder_plan_freshness_unavailable";
+
+const BUILDER_PLAN_DENIAL_CODES = new Set([
+ "builder_plan_required",
+ "builder_plan_context_invalid",
+ "builder_plan_expired",
+ "builder_plan_rejected",
+ "builder_plan_already_consumed",
+ "builder_plan_stale",
+ "builder_plan_freshness_unavailable",
+]);
+
+export type BuilderPlanDispatchInput = {
+ orgId: string;
+ projectId: string;
+ mode: string;
+ agentDefId?: string | null;
+ agentName?: string | null;
+ trigger: unknown;
+ gh?: unknown;
+ runId?: string | null;
+ actor?: AuditInsert["actor"];
+ source?: string;
+ /** Trusted, live evidence resolved by the canonical executor/worker, never request JSON. */
+ freshnessEvidence?: {
+ baseSha: string;
+ issueRevisionSha256: string;
+ checkedAt: string;
+ };
+};
+
+/**
+ * Immutable classification produced while the project/agent admission lock is
+ * held. Producers must persist `mode`; downstream policy must never have to
+ * infer a queued run's security role from a mutable agent definition.
+ */
+export type BuilderPlanAdmission = {
+ mode: string;
+ isBuilder: boolean;
+};
+
+type BuilderPlanDecisionInput = {
+ policy: BuilderPlanPolicy;
+ mode: string;
+ agentName?: string | null;
+ agentIsBuilder?: boolean;
+ trigger: unknown;
+ acceptanceValid: boolean;
+ denialCode?: BuilderPlanDenialCode;
+};
+
+export type BuilderPlanDecision =
+ | { allowed: true }
+ | { allowed: false; code: BuilderPlanDenialCode };
+
+type AcceptanceValidation =
+ | { valid: true }
+ | { valid: false; code: BuilderPlanDenialCode; reason: string };
+
+const FRESHNESS_EVIDENCE_MAX_AGE_MS = 5 * 60_000;
+const FRESHNESS_EVIDENCE_FUTURE_SKEW_MS = 30_000;
+
+/**
+ * Pure policy seam shared by API preflight and the worker's final dispatch guard.
+ * `acceptanceValid` is deliberately supplied by the caller so tests cannot make
+ * a syntactically plausible trigger stand in for durable proposal provenance.
+ */
+export function builderPlanDecision(input: BuilderPlanDecisionInput): BuilderPlanDecision {
+ if (
+ !(input.agentIsBuilder ?? builderIdentity(input.mode, input.agentName)) ||
+ input.policy === "optional"
+ ) {
+ return { allowed: true };
+ }
+ if (objectValue(input.trigger).source !== "plan_acceptance") {
+ return { allowed: false, code: "builder_plan_required" };
+ }
+ return input.acceptanceValid
+ ? { allowed: true }
+ : { allowed: false, code: input.denialCode ?? "builder_plan_context_invalid" };
+}
+
+export function builderIdentity(mode: string, agentName?: string | null) {
+ return isBuilderMode(mode) || (agentName ? isBuilderMode(agentName) : false);
+}
+
+export function isBuilderPlanDenialError(error: unknown): error is ApiError {
+ return error instanceof ApiError && builderPlanDenialCode(error.code) !== null;
+}
+
+export function builderPlanDenialCode(value: unknown): BuilderPlanDenialCode | null {
+ return typeof value === "string" && BUILDER_PLAN_DENIAL_CODES.has(value as BuilderPlanDenialCode)
+ ? (value as BuilderPlanDenialCode)
+ : null;
+}
+
+export async function builderPlanRequired(
+ db: FacilityDb,
+ orgId: string,
+ projectId: string,
+): Promise {
+ const project = (
+ await db
+ .select({ policy: projects.builderPlanPolicy })
+ .from(projects)
+ .where(and(eq(projects.orgId, orgId), eq(projects.id, projectId)))
+ .limit(1)
+ )[0];
+ if (!project) throw new ApiError(404, "project_not_found", "Project not found");
+ return project.policy === "required";
+}
+
+/**
+ * Serialize Builder admission with project policy activation. Every run
+ * producer performs its policy check and insert inside this transaction;
+ * PATCHing a project to `required` takes the same lock. This closes the window
+ * where a producer could observe `optional`, an administrator could enable the
+ * gate, and the producer could then insert an ungoverned row.
+ */
+export async function withBuilderPlanPreflight(
+ db: FacilityDb,
+ input: BuilderPlanDispatchInput,
+ create: (tx: FacilityDb, admission: BuilderPlanAdmission) => Promise,
+): Promise {
+ try {
+ return await db.transaction(async (transaction) => {
+ const tx = transaction as unknown as FacilityDb;
+ await lockBuilderPlanPolicy(tx, input.orgId, input.projectId);
+ const admission = await assertBuilderPlanDispatch(tx, input);
+ return create(tx, admission);
+ });
+ } catch (error) {
+ // The denial audit written by assertBuilderPlanDispatch participates in the
+ // admission transaction and is rolled back with the denied insert. Re-emit
+ // it after rollback so the stable decision remains durable.
+ const apiError = error instanceof ApiError ? error : null;
+ const code = apiError ? builderPlanDenialCode(apiError.code) : null;
+ if (code) {
+ const details = objectValue(apiError?.details);
+ await recordBuilderPlanDenial(
+ db,
+ input,
+ code,
+ stringValue(details.reason) ?? "transactional_preflight_denied",
+ );
+ }
+ throw error;
+ }
+}
+
+/** Must be called from an open transaction. */
+export async function lockBuilderPlanPolicy(
+ db: FacilityDb,
+ orgId: string,
+ projectId: string,
+): Promise {
+ await db.execute(
+ sql`select pg_advisory_xact_lock(hashtextextended(${`builder-plan:${orgId}:${projectId}`}, 0))`,
+ );
+}
+
+/**
+ * Fail closed immediately before a Builder row can be inserted or provisioned.
+ * A reserved trigger string is insufficient: required projects accept only a
+ * proposal Facility opened for a sealed Architect receipt and a distinct,
+ * durable approval event.
+ */
+export async function assertBuilderPlanDispatch(
+ db: FacilityDb,
+ input: BuilderPlanDispatchInput,
+): Promise {
+ const agent = await resolvedAgentIdentity(db, input);
+ const admission: BuilderPlanAdmission = {
+ mode: agent.isBuilder ? canonicalBuilderRunMode(input, agent) : input.mode,
+ isBuilder: agent.isBuilder,
+ };
+ const required = await builderPlanRequired(db, input.orgId, input.projectId);
+ if (required && input.agentDefId && !agent.found) {
+ await recordBuilderPlanDenial(
+ db,
+ input,
+ "builder_plan_context_invalid",
+ "agent_definition_scope_mismatch",
+ );
+ throw new ApiError(
+ 409,
+ "builder_plan_context_invalid",
+ "Builder plan dispatch could not verify the agent definition in this project",
+ { reason: "agent_definition_scope_mismatch" },
+ );
+ }
+ if (!agent.isBuilder) return admission;
+ if (!required) return admission;
+
+ const trigger = objectValue(input.trigger);
+ const validation =
+ trigger.source === "plan_acceptance"
+ ? await validatePlanAcceptance(db, input, trigger)
+ : ({
+ valid: false,
+ code: "builder_plan_required",
+ reason: "plan_acceptance_missing",
+ } as const);
+ const decision = builderPlanDecision({
+ policy: "required",
+ mode: input.mode,
+ agentName: agent.name,
+ agentIsBuilder: agent.isBuilder,
+ trigger,
+ acceptanceValid: validation.valid,
+ denialCode: validation.valid ? undefined : validation.code,
+ });
+ if (decision.allowed) return admission;
+ const denialReason = validation.valid ? "plan_acceptance_invalid" : validation.reason;
+
+ await recordBuilderPlanDenial(db, input, decision.code, denialReason);
+
+ if (decision.code === "builder_plan_required") {
+ throw new ApiError(
+ 409,
+ decision.code,
+ "This project requires an approved Architect plan before Builder can run",
+ { reason: denialReason },
+ );
+ }
+ throw new ApiError(
+ 409,
+ decision.code,
+ "Builder plan acceptance is missing valid Facility proposal provenance",
+ { reason: denialReason },
+ );
+}
+
+export async function recordBuilderPlanDenial(
+ db: FacilityDb,
+ input: BuilderPlanDispatchInput,
+ code: BuilderPlanDenialCode,
+ reason: string,
+): Promise {
+ const agentName = (await resolvedAgentIdentity(db, input)).name;
+ const trigger = objectValue(input.trigger);
+ const planProvenance = objectValue(trigger.planProvenance);
+ const expectedBaseSha = gitShaValue(planProvenance.workspaceBaseSha);
+ const expectedIssueRevisionSha256 = sha256Value(planProvenance.issueRevisionSha256);
+ const observedBaseSha = gitShaValue(input.freshnessEvidence?.baseSha);
+ const observedIssueRevisionSha256 = sha256Value(input.freshnessEvidence?.issueRevisionSha256);
+ await insertAuditEvent(db, {
+ orgId: input.orgId,
+ projectId: input.projectId,
+ actor: input.actor ?? { type: "system", id: "builder-plan-policy" },
+ action: "run.builder_plan_denied",
+ target: input.runId
+ ? { type: "run", id: input.runId }
+ : { type: "project", id: input.projectId },
+ payload: {
+ code,
+ reason,
+ source: input.source ?? "unknown",
+ mode: input.mode,
+ agentDefId: input.agentDefId ?? null,
+ agentName: agentName ?? null,
+ proposalId: stringValue(trigger.proposalId),
+ architectRunId: stringValue(trigger.architectRunId),
+ expectedPlanInputs: {
+ baseSha: expectedBaseSha,
+ issueRevisionSha256: expectedIssueRevisionSha256,
+ },
+ observedPlanInputs: {
+ baseSha: observedBaseSha,
+ issueRevisionSha256: observedIssueRevisionSha256,
+ checkedAt: stringValue(input.freshnessEvidence?.checkedAt),
+ },
+ },
+ });
+}
+
+async function resolvedAgentIdentity(db: FacilityDb, input: BuilderPlanDispatchInput) {
+ if (!input.agentDefId) {
+ const name = input.agentName ?? null;
+ return { name, triggers: null, isBuilder: builderIdentity(input.mode, name), found: true };
+ }
+ const agent = (
+ await db
+ .select({ name: agentDefs.name, triggers: agentDefs.triggers })
+ .from(agentDefs)
+ .where(
+ and(
+ eq(agentDefs.orgId, input.orgId),
+ eq(agentDefs.projectId, input.projectId),
+ eq(agentDefs.id, input.agentDefId),
+ ),
+ )
+ .limit(1)
+ )[0];
+ const name = agent?.name ?? input.agentName ?? null;
+ return {
+ name,
+ triggers: agent?.triggers ?? null,
+ found: Boolean(agent),
+ isBuilder:
+ builderIdentity(input.mode, name) ||
+ (agent ? agentDefTriggersBuilder(agent.triggers) : false),
+ };
+}
+
+function canonicalBuilderRunMode(
+ input: BuilderPlanDispatchInput,
+ agent: { name: string | null; triggers: unknown },
+): "builder" | "codex-builder" {
+ const candidates = [
+ input.mode,
+ input.agentName,
+ agent.name,
+ ...agentTriggerCommands(agent.triggers),
+ ];
+ return candidates.some((candidate) => canonicalAgentToken(candidate) === "codex-builder")
+ ? "codex-builder"
+ : "builder";
+}
+
+function agentTriggerCommands(value: unknown): unknown[] {
+ if (!Array.isArray(value)) return [];
+ return value.flatMap((trigger) => {
+ const entry = objectValue(trigger);
+ return [entry.command, entry.handle];
+ });
+}
+
+function canonicalAgentToken(value: unknown): string | null {
+ return typeof value === "string" ? value.replace(/^\//, "").replaceAll("_", "-") : null;
+}
+
+async function validatePlanAcceptance(
+ db: FacilityDb,
+ input: BuilderPlanDispatchInput,
+ trigger: Record,
+): Promise {
+ const proposalId = stringValue(trigger.proposalId);
+ const architectRunId = stringValue(trigger.architectRunId);
+ const approvedPlan = stringValue(trigger.approvedPlan);
+ if (!proposalId || !architectRunId || !approvedPlan) {
+ return invalid("builder_plan_context_invalid", "trigger_context_missing");
+ }
+
+ const proposal = (
+ await db
+ .select()
+ .from(proposals)
+ .where(
+ and(
+ eq(proposals.orgId, input.orgId),
+ eq(proposals.projectId, input.projectId),
+ eq(proposals.id, proposalId),
+ ),
+ )
+ .limit(1)
+ )[0];
+ if (!proposal) return invalid("builder_plan_context_invalid", "proposal_not_found");
+ if (proposal.state === "expired") {
+ return invalid("builder_plan_expired", "proposal_expired");
+ }
+ if (proposal.state === "rejected") {
+ return invalid("builder_plan_rejected", "proposal_rejected");
+ }
+ if (
+ (proposal.state === "open" && proposal.expiresAt.getTime() <= Date.now()) ||
+ (proposal.decidedAt && proposal.decidedAt.getTime() > proposal.expiresAt.getTime())
+ ) {
+ return invalid("builder_plan_expired", "proposal_expired");
+ }
+ const linkedRuns = await db
+ .select({ id: runs.id })
+ .from(runs)
+ .where(
+ and(
+ eq(runs.orgId, input.orgId),
+ eq(runs.projectId, input.projectId),
+ sql`${runs.trigger}->>'source' = 'plan_acceptance'`,
+ sql`${runs.trigger}->>'proposalId' = ${proposal.id}`,
+ ),
+ )
+ .limit(2);
+ if (linkedRuns.some((run) => run.id !== input.runId)) {
+ return invalid("builder_plan_already_consumed", "proposal_linked_to_another_run");
+ }
+ const architectLinkedRuns = await db
+ .select({ id: runs.id })
+ .from(runs)
+ .where(
+ and(
+ eq(runs.orgId, input.orgId),
+ eq(runs.projectId, input.projectId),
+ sql`${runs.trigger}->>'source' = 'plan_acceptance'`,
+ sql`${runs.trigger}->>'architectRunId' = ${architectRunId}`,
+ ),
+ )
+ .limit(2);
+ if (architectLinkedRuns.some((run) => run.id !== input.runId)) {
+ return invalid("builder_plan_already_consumed", "architect_plan_linked_to_another_run");
+ }
+ const dispatchingLinkedRun = Boolean(
+ input.runId &&
+ linkedRuns.some((run) => run.id === input.runId) &&
+ architectLinkedRuns.some((run) => run.id === input.runId),
+ );
+ if (proposal.state === "executed" && !dispatchingLinkedRun) {
+ return invalid("builder_plan_context_invalid", "executed_proposal_missing_linked_run");
+ }
+ if (
+ proposal.state !== "executing" &&
+ proposal.state !== "executed" &&
+ !(proposal.state === "execution_failed" && dispatchingLinkedRun)
+ ) {
+ return invalid("builder_plan_context_invalid", "proposal_not_executing");
+ }
+ if (!proposal.decidedBy || !proposal.decidedAt) {
+ return invalid("builder_plan_context_invalid", "approval_decision_missing");
+ }
+ if (proposal.runId !== architectRunId) {
+ return invalid("builder_plan_context_invalid", "architect_run_link_mismatch");
+ }
+ const actionType = (
+ await db
+ .select()
+ .from(actionTypes)
+ .where(and(eq(actionTypes.orgId, input.orgId), eq(actionTypes.id, proposal.actionTypeId)))
+ .limit(1)
+ )[0];
+ if (!actionType) return invalid("builder_plan_context_invalid", "action_type_missing");
+ if (actionType.name !== "plan_acceptance") {
+ return invalid("builder_plan_context_invalid", "action_type_invalid");
+ }
+ if (objectValue(actionType.executor).type !== "internal") {
+ return invalid("builder_plan_context_invalid", "action_executor_invalid");
+ }
+ if (proposal.contextMd.trim().length === 0 || proposal.contextMd !== approvedPlan) {
+ return invalid("builder_plan_context_invalid", "approved_plan_mismatch");
+ }
+ const planSha256 = createHash("sha256").update(proposal.contextMd).digest("hex");
+ const payload = objectValue(proposal.payload);
+ const approvalContext = objectValue(trigger.approval);
+ const recordedPlanSha256 = payload.planSha256;
+ if (
+ (recordedPlanSha256 !== undefined && sha256Value(recordedPlanSha256) !== planSha256) ||
+ sha256Value(trigger.planSha256) !== planSha256 ||
+ stringValue(approvalContext.principal) !== proposal.decidedBy ||
+ stringValue(approvalContext.at) !== proposal.decidedAt.toISOString()
+ ) {
+ return invalid("builder_plan_context_invalid", "plan_or_approval_identity_mismatch");
+ }
+
+ const architectRun = (
+ await db
+ .select()
+ .from(runs)
+ .where(
+ and(
+ eq(runs.orgId, input.orgId),
+ eq(runs.projectId, input.projectId),
+ eq(runs.id, architectRunId),
+ ),
+ )
+ .limit(1)
+ )[0];
+ if (!architectRun) {
+ return invalid("builder_plan_context_invalid", "architect_run_not_found");
+ }
+ if (architectRun.status !== "succeeded") {
+ return invalid("builder_plan_context_invalid", "architect_run_not_succeeded");
+ }
+ if (!(await architectRunIdentityValid(db, architectRun))) {
+ return invalid("builder_plan_context_invalid", "architect_identity_invalid");
+ }
+
+ const events = await db
+ .select()
+ .from(proposalEvents)
+ .where(and(eq(proposalEvents.orgId, input.orgId), eq(proposalEvents.proposalId, proposal.id)));
+ const opener = events.find((event) => event.seq === 1);
+ const openerActor = objectValue(opener?.actor);
+ if (
+ opener?.type !== "open" ||
+ openerActor.type !== "agent" ||
+ openerActor.id !== architectRun.id ||
+ objectValue(opener.data).source !== "architect_run"
+ ) {
+ return invalid("builder_plan_context_invalid", "proposal_origin_invalid");
+ }
+ const approval = events.find((event) => {
+ if (event.type !== "approved") return false;
+ const actor = objectValue(event.actor);
+ return actor.id === proposal.decidedBy && actor.type === "user";
+ });
+ if (!approval) return invalid("builder_plan_context_invalid", "approval_event_invalid");
+
+ const repoId = stringValue(payload.repoId);
+ const issueNumber = positiveInteger(payload.issueNumber);
+ const receiptSha256 = sha256Value(payload.receiptSha256);
+ const expectedBaseSha = gitShaValue(payload.workspaceBaseSha);
+ const expectedIssueRevision = sha256Value(payload.issueRevisionSha256);
+ const planProvenance = objectValue(trigger.planProvenance);
+ if (
+ stringValue(payload.architectRunId) !== architectRun.id ||
+ !repoId ||
+ !issueNumber ||
+ !receiptSha256
+ ) {
+ return invalid("builder_plan_context_invalid", "proposal_context_invalid");
+ }
+ if (
+ expectedBaseSha &&
+ expectedIssueRevision &&
+ (gitShaValue(planProvenance.workspaceBaseSha) !== expectedBaseSha ||
+ sha256Value(planProvenance.issueRevisionSha256) !== expectedIssueRevision)
+ ) {
+ return invalid("builder_plan_context_invalid", "plan_provenance_mismatch");
+ }
+ const repo = (
+ await db
+ .select()
+ .from(repos)
+ .where(
+ and(
+ eq(repos.orgId, input.orgId),
+ eq(repos.projectId, input.projectId),
+ eq(repos.id, repoId),
+ ),
+ )
+ .limit(1)
+ )[0];
+ if (!repo) return invalid("builder_plan_context_invalid", "proposal_repo_invalid");
+
+ const architectGh = objectValue(architectRun.gh);
+ const architectTrigger = objectValue(architectRun.trigger);
+ const triggerRepo = objectValue(architectTrigger.repo);
+ const triggerIssue = objectValue(architectTrigger.issue);
+ if (
+ architectGh.owner !== repo.owner ||
+ architectGh.repo !== repo.name ||
+ positiveInteger(architectGh.issueNumber) !== issueNumber ||
+ triggerRepo.id !== repo.id ||
+ triggerRepo.owner !== repo.owner ||
+ triggerRepo.name !== repo.name ||
+ positiveInteger(triggerIssue.number) !== issueNumber
+ ) {
+ return invalid("builder_plan_context_invalid", "architect_issue_context_invalid");
+ }
+
+ const builderGh = objectValue(input.gh);
+ if (
+ builderGh.owner !== repo.owner ||
+ builderGh.repo !== repo.name ||
+ positiveInteger(builderGh.issueNumber) !== issueNumber
+ ) {
+ return invalid("builder_plan_context_invalid", "builder_issue_context_invalid");
+ }
+
+ const receipt = FacilityReceiptSchema.safeParse(architectRun.receipt);
+ if (!receipt.success || !verifyFacilityReceipt(receipt.data)) {
+ return invalid("builder_plan_context_invalid", "architect_receipt_invalid");
+ }
+ const expectedReceiptMode = architectReceiptMode(architectRun.mode);
+ if (
+ !expectedReceiptMode ||
+ receipt.data.integrity?.payload_sha256 !== receiptSha256 ||
+ receipt.data.run_id !== architectRun.id ||
+ receipt.data.project_id !== input.projectId ||
+ receipt.data.mode !== expectedReceiptMode ||
+ receipt.data.result !== "succeeded" ||
+ receipt.data.github?.owner !== repo.owner ||
+ receipt.data.github?.repo !== repo.name ||
+ receipt.data.github?.issue !== issueNumber ||
+ (expectedBaseSha && receipt.data.github?.base_sha !== expectedBaseSha) ||
+ (expectedBaseSha && gitShaValue(architectRun.workspaceBaseSha) !== expectedBaseSha) ||
+ (architectRun.agentDefId && receipt.data.agent_id !== architectRun.agentDefId)
+ ) {
+ return invalid("builder_plan_context_invalid", "architect_receipt_context_invalid");
+ }
+
+ const currentBaseSha = gitShaValue(input.freshnessEvidence?.baseSha);
+ const currentIssueRevision = sha256Value(input.freshnessEvidence?.issueRevisionSha256);
+ const checkedAt = stringValue(input.freshnessEvidence?.checkedAt);
+ const checkedAtMs = checkedAt ? Date.parse(checkedAt) : Number.NaN;
+ const now = Date.now();
+ if (
+ !expectedBaseSha ||
+ !expectedIssueRevision ||
+ !currentBaseSha ||
+ !currentIssueRevision ||
+ !checkedAt ||
+ !Number.isFinite(checkedAtMs) ||
+ checkedAtMs < now - FRESHNESS_EVIDENCE_MAX_AGE_MS ||
+ checkedAtMs > now + FRESHNESS_EVIDENCE_FUTURE_SKEW_MS
+ ) {
+ return invalid("builder_plan_freshness_unavailable", "freshness_evidence_missing");
+ }
+ // Legacy Architect proposals did not persist a plan digest. They remain
+ // distinguishable as freshness-unavailable when they also predate the
+ // trusted base/issue envelope, but no otherwise-complete envelope may omit it.
+ if (recordedPlanSha256 === undefined) {
+ return invalid("builder_plan_context_invalid", "plan_hash_missing");
+ }
+ if (currentBaseSha !== expectedBaseSha || currentIssueRevision !== expectedIssueRevision) {
+ return invalid("builder_plan_stale", "base_or_issue_revision_changed");
+ }
+ return { valid: true };
+}
+
+function architectReceiptMode(mode: string): "architect" | null {
+ const canonical = mode.replaceAll("_", "-");
+ return canonical === "architect" || canonical.endsWith("-architect") ? "architect" : null;
+}
+
+export async function architectRunIdentityValid(
+ db: FacilityDb,
+ architectRun: typeof runs.$inferSelect,
+): Promise {
+ const mode = architectRun.mode.replaceAll("_", "-");
+ if (mode !== "architect" && !mode.endsWith("-architect")) return false;
+ if (!architectRun.agentDefId) return true;
+ const agent = (
+ await db
+ .select({ name: agentDefs.name })
+ .from(agentDefs)
+ .where(
+ and(
+ eq(agentDefs.orgId, architectRun.orgId),
+ eq(agentDefs.projectId, architectRun.projectId),
+ eq(agentDefs.id, architectRun.agentDefId),
+ ),
+ )
+ .limit(1)
+ )[0];
+ const name = agent?.name.replaceAll("_", "-");
+ return Boolean(name && (name === "architect" || name.endsWith("-architect")));
+}
+
+function invalid(code: BuilderPlanDenialCode, reason?: string): AcceptanceValidation {
+ return { valid: false, code, reason: reason ?? code };
+}
+
+function objectValue(value: unknown): Record {
+ return value !== null && typeof value === "object" && !Array.isArray(value)
+ ? (value as Record)
+ : {};
+}
+
+function stringValue(value: unknown): string | null {
+ return typeof value === "string" && value.length > 0 ? value : null;
+}
+
+function sha256Value(value: unknown): string | null {
+ const candidate = stringValue(value);
+ return candidate && /^[a-f0-9]{64}$/i.test(candidate) ? candidate.toLowerCase() : null;
+}
+
+function gitShaValue(value: unknown): string | null {
+ const candidate = stringValue(value);
+ return candidate && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(candidate)
+ ? candidate.toLowerCase()
+ : null;
+}
+
+function positiveInteger(value: unknown): number | null {
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
+}
diff --git a/services/api/src/executors.ts b/services/api/src/executors.ts
index 616b4343..3a9e8555 100644
--- a/services/api/src/executors.ts
+++ b/services/api/src/executors.ts
@@ -1,6 +1,6 @@
import { createHash } from "node:crypto";
import { can, newId } from "@facility/core";
-import type { createDb } from "@facility/db";
+import type { createDb, FacilityDb } from "@facility/db";
import {
actionTypes,
agentDefs,
@@ -35,6 +35,20 @@ import {
import { artifactIdFor, validate } from "@facility/harness";
import { and, desc, eq, gt, inArray, isNull, notInArray, or, sql } from "drizzle-orm";
import { assertBudgetAgentInProject, resolveBudgetScope } from "./budget-scope.js";
+import {
+ type BuilderPlanFreshnessOptions,
+ resolveBuilderPlanFreshnessForProposal,
+} from "./builder-plan-freshness.js";
+import {
+ architectRunIdentityValid,
+ builderPlanDenialCode,
+ builderPlanRequired,
+ lockBuilderPlanPolicy,
+ recordBuilderPlanDenial,
+ withBuilderPlanPreflight,
+} from "./builder-plan-policy.js";
+import { ApiError } from "./errors.js";
+import { findAgentDef, laneFor } from "./github/agent-routing.js";
import {
createGithubClientFactory,
FacilityGithubClient,
@@ -46,7 +60,6 @@ import {
kickstartRepo,
upgradeRepo,
} from "./github/kickstart.js";
-import { findAgentDef, laneFor } from "./github/router.js";
import { renderGithubRunProgress } from "./github/run-progress.js";
import { ensureTrackedIssue } from "./github/tracked-issues.js";
import {
@@ -85,6 +98,7 @@ type ExecuteApprovedProposalOptions = {
config?: AppConfig;
github?: GitHubIssueClient;
githubFactory?: GithubClientFactory;
+ githubClient?: BuilderPlanFreshnessOptions["githubClient"];
enqueue?: (queue: string, data: Record) => Promise;
};
@@ -95,30 +109,57 @@ export async function executeApprovedProposal(
options: ExecuteApprovedProposalOptions | GitHubIssueClient = {},
) {
const executionOptions = isGitHubIssueClient(options) ? { github: options } : options;
- if (candidate.state !== "approved" && candidate.state !== "execution_failed") return false;
+ if (
+ candidate.state !== "approved" &&
+ candidate.state !== "execution_failed" &&
+ candidate.state !== "executing"
+ ) {
+ return false;
+ }
const actionType = (
await db.select().from(actionTypes).where(eq(actionTypes.id, candidate.actionTypeId)).limit(1)
)[0];
if (!actionType) return false;
+ // Plan acceptance is the only executor whose side effects are deliberately
+ // idempotent at the database boundary: its Builder row is protected by the
+ // proposal and Architect-run unique indexes. Allow a later request to finish
+ // an `executing` proposal after a process dies between the claim and insert.
+ // Other action types retain the original single-owner behavior.
+ if (candidate.state === "executing" && actionType.name !== "plan_acceptance") return false;
// `executor.type = none` is a deliberate externally-consumed approval gate.
// Approval is terminal for Facility itself, so leave the proposal approved.
if (objectOrEmpty(actionType.executor).type === "none") return true;
// Claim before performing any database or external side effect. The compare-and-set
// makes concurrent /execute calls single-writer even when both loaded the same
// approved proposal. Failed executions remain explicitly retryable.
- const proposal = (
- await db
- .update(proposals)
- .set({ state: "executing", updatedAt: new Date() })
- .where(
- and(
- eq(proposals.orgId, candidate.orgId),
- eq(proposals.id, candidate.id),
- inArray(proposals.state, ["approved", "execution_failed"]),
- ),
- )
- .returning()
- )[0];
+ const proposal =
+ candidate.state === "executing"
+ ? (
+ await db
+ .select()
+ .from(proposals)
+ .where(
+ and(
+ eq(proposals.orgId, candidate.orgId),
+ eq(proposals.id, candidate.id),
+ eq(proposals.state, "executing"),
+ ),
+ )
+ .limit(1)
+ )[0]
+ : (
+ await db
+ .update(proposals)
+ .set({ state: "executing", updatedAt: new Date() })
+ .where(
+ and(
+ eq(proposals.orgId, candidate.orgId),
+ eq(proposals.id, candidate.id),
+ inArray(proposals.state, ["approved", "execution_failed"]),
+ ),
+ )
+ .returning()
+ )[0];
if (!proposal) return false;
let actionTypeName = "unknown";
try {
@@ -153,23 +194,56 @@ export async function executeApprovedProposal(
} else {
throw new Error(`unsupported_action_type:${actionType.name}`);
}
- await db
- .update(proposals)
- .set({ state: "executed", updatedAt: new Date() })
- .where(and(eq(proposals.orgId, proposal.orgId), eq(proposals.id, proposal.id)));
- await appendProposalEvent(db, proposal, "executed", actor, {
- actionType: actionType.name,
- ...(recurrence ? { recurrence } : {}),
+ await db.transaction(async (transaction) => {
+ const tx = transaction as unknown as Db;
+ const completed = (
+ await tx
+ .update(proposals)
+ .set({ state: "executed", updatedAt: new Date() })
+ .where(
+ and(
+ eq(proposals.orgId, proposal.orgId),
+ eq(proposals.id, proposal.id),
+ inArray(proposals.state, ["executing", "execution_failed"]),
+ ),
+ )
+ .returning()
+ )[0];
+ if (completed) {
+ await appendProposalEvent(tx, completed, "executed", actor, {
+ actionType: actionType.name,
+ ...(recurrence ? { recurrence } : {}),
+ });
+ }
});
return true;
} catch (error) {
- await db
- .update(proposals)
- .set({ state: "execution_failed", updatedAt: new Date() })
- .where(and(eq(proposals.orgId, proposal.orgId), eq(proposals.id, proposal.id)));
- await appendProposalEvent(db, proposal, "execution_failed", actor, {
- actionType: actionTypeName,
- error: error instanceof Error ? error.message : String(error),
+ await db.transaction(async (transaction) => {
+ const tx = transaction as unknown as Db;
+ const failed = (
+ await tx
+ .update(proposals)
+ .set({ state: "execution_failed", updatedAt: new Date() })
+ .where(
+ and(
+ eq(proposals.orgId, proposal.orgId),
+ eq(proposals.id, proposal.id),
+ eq(proposals.state, "executing"),
+ ),
+ )
+ .returning()
+ )[0];
+ if (failed) {
+ await appendProposalEvent(tx, failed, "execution_failed", actor, {
+ actionType: actionTypeName,
+ error:
+ error instanceof ApiError
+ ? error.code
+ : error instanceof Error
+ ? error.message
+ : String(error),
+ });
+ }
});
return true;
}
@@ -183,6 +257,7 @@ async function executePlanAcceptance(
) {
if (!proposal.projectId) throw new Error("plan_acceptance_missing_project");
if (!proposal.runId) throw new Error("plan_acceptance_missing_architect_run");
+ const proposalProjectId = proposal.projectId;
const architectRun = (
await db
@@ -198,20 +273,120 @@ async function executePlanAcceptance(
.limit(1)
)[0];
if (!architectRun) throw new Error("plan_acceptance_architect_run_not_found");
- if (!["architect", "codex-architect"].includes(architectRun.mode)) {
+ if (!(await architectRunIdentityValid(db, architectRun))) {
throw new Error("plan_acceptance_source_not_architect");
}
if (!["succeeded", "awaiting_human"].includes(architectRun.status)) {
throw new Error("plan_acceptance_architect_run_not_ready");
}
- await assertPlatformBuilderLane(db, proposal, architectRun);
+ const builderCommand = architectRun.engine === "codex" ? "codex-builder" : "builder";
+ await assertPlatformBuilderLane(db, proposal, architectRun, builderCommand);
+ const requiredPlan = await builderPlanRequired(db, proposal.orgId, proposal.projectId);
+ let freshnessEvidence:
+ | Awaited>
+ | undefined;
+ if (requiredPlan) {
+ try {
+ freshnessEvidence = await resolveBuilderPlanFreshnessForProposal(db, proposal, options);
+ } catch (error) {
+ const apiError = error instanceof ApiError ? error : null;
+ const code = apiError ? builderPlanDenialCode(apiError.code) : null;
+ if (code) {
+ const payload = objectOrEmpty(proposal.payload);
+ await recordBuilderPlanDenial(
+ db,
+ {
+ orgId: proposal.orgId,
+ projectId: proposal.projectId,
+ mode: builderCommand,
+ agentName: builderCommand,
+ trigger: {
+ source: "plan_acceptance",
+ proposalId: proposal.id,
+ architectRunId: architectRun.id,
+ planProvenance: {
+ workspaceBaseSha: payload.workspaceBaseSha,
+ issueRevisionSha256: payload.issueRevisionSha256,
+ },
+ },
+ gh: architectRun.gh,
+ actor: { type: auditActorType(actor.type), id: actor.id },
+ source: "plan_acceptance_freshness",
+ },
+ code,
+ stringField(objectOrEmpty(apiError?.details).reason) ?? "freshness_resolution_failed",
+ );
+ }
+ throw error;
+ }
+ }
+ const builderGh = { ...objectOrEmpty(architectRun.gh) };
+ delete builderGh.progressComment;
+ const planSha256 = createHash("sha256").update(proposal.contextMd).digest("hex");
+ const approval = {
+ principal: proposal.decidedBy,
+ at: proposal.decidedAt?.toISOString(),
+ };
+ const approvalEvent = (
+ await db
+ .select({ actor: proposalEvents.actor })
+ .from(proposalEvents)
+ .where(
+ and(
+ eq(proposalEvents.orgId, proposal.orgId),
+ eq(proposalEvents.proposalId, proposal.id),
+ eq(proposalEvents.type, "approved"),
+ ),
+ )
+ .orderBy(desc(proposalEvents.seq))
+ .limit(1)
+ )[0];
+ const approvalActor = objectOrEmpty(approvalEvent?.actor);
+ const approvalCreator = {
+ type: stringField(approvalActor.type) ?? actor.type,
+ id: proposal.decidedBy ?? actor.id,
+ proposalId: proposal.id,
+ };
// The proposal link, not the currently configured builder definition, is
// the durable dispatch identity. Reuse the original run even if an admin
// replaces or disables the builder before an execution retry.
const existingRun = await loadPlanBuilderRun(db, proposal);
if (existingRun) {
+ await withBuilderPlanPreflight(
+ db,
+ {
+ orgId: existingRun.orgId,
+ projectId: existingRun.projectId,
+ mode: existingRun.mode,
+ agentDefId: existingRun.agentDefId,
+ trigger: existingRun.trigger,
+ gh: existingRun.gh,
+ runId: existingRun.id,
+ actor: { type: auditActorType(actor.type), id: actor.id },
+ source: "plan_acceptance_retry",
+ freshnessEvidence,
+ },
+ async (tx) => {
+ await assertPlatformBuilderLane(tx, proposal, architectRun, builderCommand);
+ await tx
+ .insert(runEvents)
+ .values({
+ orgId: existingRun.orgId,
+ runId: existingRun.id,
+ seq: 1,
+ type: "queued",
+ data: {
+ queue: "runs.dispatch",
+ source: "plan_acceptance",
+ proposalId: proposal.id,
+ architectRunId: architectRun.id,
+ },
+ })
+ .onConflictDoNothing();
+ },
+ );
await options.enqueue?.("runs.dispatch", {
runId: existingRun.id,
orgId: proposal.orgId,
@@ -219,59 +394,122 @@ async function executePlanAcceptance(
return;
}
- const builderCommand = architectRun.engine === "codex" ? "codex-builder" : "builder";
const builder = await findAgentDef(db, proposal.orgId, proposal.projectId, builderCommand);
if (!builder) throw new Error("plan_acceptance_builder_not_configured");
- const builderGh = { ...objectOrEmpty(architectRun.gh) };
- delete builderGh.progressComment;
+
const architectTrigger = objectOrEmpty(architectRun.trigger);
+ const proposalPayload = objectOrEmpty(proposal.payload);
const architectCreator = objectOrEmpty(architectRun.createdBy);
const githubLogin =
stringField(architectTrigger.githubLogin) ??
(architectCreator.type === "github"
? (stringField(architectCreator.id) ?? stringField(architectCreator.login))
: null);
+ const builderTrigger = {
+ source: "plan_acceptance",
+ ...(githubLogin ? { githubLogin } : {}),
+ proposalId: proposal.id,
+ architectRunId: architectRun.id,
+ architectTrigger: architectRun.trigger,
+ approvedPlan: proposal.contextMd,
+ planSha256,
+ approval,
+ planProvenance: {
+ workspaceBaseSha: proposalPayload.workspaceBaseSha,
+ issueRevisionSha256: proposalPayload.issueRevisionSha256,
+ },
+ ...(freshnessEvidence ? { admissionFreshness: freshnessEvidence } : {}),
+ };
+ const builderPolicyInput = {
+ orgId: proposal.orgId,
+ projectId: proposal.projectId,
+ mode: "builder",
+ agentDefId: builder.id,
+ trigger: builderTrigger,
+ gh: builderGh,
+ actor: { type: auditActorType(actor.type), id: actor.id } as const,
+ source: "plan_acceptance_executor",
+ freshnessEvidence,
+ };
- const createdRun = (
- await db
- .insert(runs)
- .values({
- id: newId("run"),
- orgId: proposal.orgId,
- projectId: proposal.projectId,
- agentDefId: builder.id,
- mode: "builder",
- engine: builder.engine,
- trigger: {
- source: "plan_acceptance",
- ...(githubLogin ? { githubLogin } : {}),
- proposalId: proposal.id,
- architectRunId: architectRun.id,
- architectTrigger: architectRun.trigger,
- approvedPlan: proposal.contextMd,
+ const createdRun = await withBuilderPlanPreflight(
+ db,
+ builderPolicyInput,
+ async (tx, admission) => {
+ await assertPlatformBuilderLane(tx, proposal, architectRun, builderCommand);
+ const run = (
+ await tx
+ // builder-plan-preflight: plan_acceptance_executor
+ .insert(runs)
+ .values({
+ id: newId("run"),
+ orgId: proposal.orgId,
+ projectId: proposalProjectId,
+ agentDefId: builder.id,
+ mode: admission.mode,
+ engine: builder.engine,
+ trigger: builderTrigger,
+ gh: builderGh,
+ // Attribute the durable Builder row to the principal that approved the
+ // proposal, never to a later crash-recovery caller that happened to win
+ // the unique insert race.
+ createdBy: approvalCreator,
+ })
+ .onConflictDoNothing()
+ .returning()
+ )[0];
+ if (run) {
+ await tx.insert(runEvents).values({
+ orgId: proposal.orgId,
+ runId: run.id,
+ seq: 1,
+ type: "queued",
+ data: {
+ queue: "runs.dispatch",
+ source: "plan_acceptance",
+ proposalId: proposal.id,
+ architectRunId: architectRun.id,
+ },
+ });
+ }
+ return run;
+ },
+ );
+ let run = createdRun ?? (await loadPlanBuilderRun(db, proposal));
+ if (!run) {
+ const architectLinkedRun = await loadArchitectBuilderRun(db, proposal);
+ if (architectLinkedRun && (await builderPlanRequired(db, proposal.orgId, proposal.projectId))) {
+ await recordBuilderPlanDenial(
+ db,
+ {
+ orgId: proposal.orgId,
+ projectId: proposal.projectId,
+ mode: builderCommand,
+ agentDefId: builder.id,
+ trigger: {
+ source: "plan_acceptance",
+ proposalId: proposal.id,
+ architectRunId: architectRun.id,
+ },
+ gh: builderGh,
+ actor: { type: auditActorType(actor.type), id: actor.id },
+ source: "plan_acceptance_unique_conflict",
},
- gh: builderGh,
- createdBy: { type: actor.type, id: actor.id, proposalId: proposal.id },
- })
- .onConflictDoNothing()
- .returning()
- )[0];
- const run = createdRun ?? (await loadPlanBuilderRun(db, proposal));
+ "builder_plan_already_consumed",
+ "architect_plan_linked_to_another_run",
+ );
+ throw new ApiError(
+ 409,
+ "builder_plan_already_consumed",
+ "This Architect plan is already linked to another Builder run",
+ );
+ }
+ // Preserve legacy Architect-level de-duplication for optional projects.
+ run = architectLinkedRun;
+ }
if (!run) throw new Error("plan_acceptance_builder_run_not_created");
if (createdRun) {
- await db.insert(runEvents).values({
- orgId: proposal.orgId,
- runId: run.id,
- seq: 1,
- type: "queued",
- data: {
- queue: "runs.dispatch",
- source: "plan_acceptance",
- proposalId: proposal.id,
- architectRunId: architectRun.id,
- },
- });
await createBuilderProgressComment(db, createdRun, architectRun, builderCommand, options);
}
await options.enqueue?.("runs.dispatch", { runId: run.id, orgId: proposal.orgId });
@@ -375,7 +613,7 @@ async function createBuilderProgressComment(
}
}
-async function loadPlanBuilderRun(db: Db, proposal: typeof proposals.$inferSelect) {
+export async function loadPlanBuilderRun(db: Db, proposal: typeof proposals.$inferSelect) {
return (
await db
.select()
@@ -384,7 +622,28 @@ async function loadPlanBuilderRun(db: Db, proposal: typeof proposals.$inferSelec
and(
eq(runs.orgId, proposal.orgId),
eq(runs.projectId, proposal.projectId ?? ""),
- eq(runs.mode, "builder"),
+ inArray(runs.mode, ["builder", "codex-builder"]),
+ sql`${runs.trigger} @> ${JSON.stringify({
+ source: "plan_acceptance",
+ proposalId: proposal.id,
+ architectRunId: proposal.runId,
+ })}::jsonb`,
+ ),
+ )
+ .limit(1)
+ )[0];
+}
+
+async function loadArchitectBuilderRun(db: Db, proposal: typeof proposals.$inferSelect) {
+ return (
+ await db
+ .select()
+ .from(runs)
+ .where(
+ and(
+ eq(runs.orgId, proposal.orgId),
+ eq(runs.projectId, proposal.projectId ?? ""),
+ inArray(runs.mode, ["builder", "codex-builder"]),
sql`${runs.trigger} @> ${JSON.stringify({
source: "plan_acceptance",
architectRunId: proposal.runId,
@@ -399,6 +658,7 @@ async function assertPlatformBuilderLane(
db: Db,
proposal: typeof proposals.$inferSelect,
architectRun: typeof runs.$inferSelect,
+ builderCommand: string,
) {
const projectRepos = await db
.select()
@@ -418,9 +678,23 @@ async function assertPlatformBuilderLane(
const repo =
matchedRepo ?? (!hasRepoIdentity && projectRepos.length === 1 ? projectRepos[0] : undefined);
if (!repo) throw new Error("plan_acceptance_repo_context_ambiguous");
- if (laneFor(repo, "builder") !== "platform") {
+ if (laneFor(repo, builderCommand) !== "platform") {
throw new Error("plan_acceptance_builder_uses_repo_lane");
}
+ if (
+ (await builderPlanRequired(db, proposal.orgId, proposal.projectId ?? "")) &&
+ (!repo.fingerprint ||
+ repo.fingerprintStatus !== "ok" ||
+ !repo.fingerprintVerifiedAt ||
+ Date.now() - repo.fingerprintVerifiedAt.getTime() > 5 * 60_000)
+ ) {
+ throw new ApiError(
+ 409,
+ "builder_plan_context_invalid",
+ "The required Builder plan repository no longer has a verified Facility fingerprint",
+ { reason: "repository_fingerprint_unverified", repo: `${repo.owner}/${repo.name}` },
+ );
+ }
}
async function executeMcpToolCall(
@@ -584,20 +858,35 @@ async function executeKnownMcpTool(
const projectId = requiredString(args.projectId, "projectId");
const agentName = requiredString(args.agentName, "agentName");
const agent = await resolveAgentForMcpRun(db, orgId, projectId, agentName);
+ const trigger = { source: "mcp", agentName, input: args.input };
const run = (
- await db
- .insert(runs)
- .values({
- id: newId("run"),
+ await withBuilderPlanPreflight(
+ db,
+ {
orgId,
projectId,
- agentDefId: agent.id,
mode: agent.name,
- engine: agent.engine,
- trigger: { source: "mcp", agentName, input: args.input },
- createdBy: actor,
- })
- .returning()
+ agentDefId: agent.id,
+ trigger,
+ actor: { type: auditActorType(actor.type), id: actor.id },
+ source: "mcp_trigger_run",
+ },
+ (tx, admission) =>
+ tx
+ // builder-plan-preflight: mcp_trigger_run
+ .insert(runs)
+ .values({
+ id: newId("run"),
+ orgId,
+ projectId,
+ agentDefId: agent.id,
+ mode: admission.mode,
+ engine: agent.engine,
+ trigger,
+ createdBy: actor,
+ })
+ .returning(),
+ )
)[0];
if (run) {
await db.insert(runEvents).values({
@@ -731,25 +1020,42 @@ async function executeKnownMcpTool(
throw new Error("run_not_resumable");
}
const message = optionalString(args.message);
+ const trigger = {
+ type: "resume",
+ resumeOf: parent.id,
+ ...(message ? { message } : {}),
+ };
+ const gh = resumableGithubContext(parent.gh);
const resumed = (
- await db
- .insert(runs)
- .values({
- id: newId("run"),
+ await withBuilderPlanPreflight(
+ db,
+ {
orgId,
projectId: parent.projectId,
- agentDefId: parent.agentDefId,
mode: parent.mode,
- engine: parent.engine,
- trigger: {
- type: "resume",
- resumeOf: parent.id,
- ...(message ? { message } : {}),
- },
- gh: resumableGithubContext(parent.gh),
- createdBy: actor,
- })
- .returning({ id: runs.id })
+ agentDefId: parent.agentDefId,
+ trigger,
+ gh,
+ actor: { type: auditActorType(actor.type), id: actor.id },
+ source: "mcp_resume_run",
+ },
+ (tx, admission) =>
+ tx
+ // builder-plan-preflight: mcp_resume_run
+ .insert(runs)
+ .values({
+ id: newId("run"),
+ orgId,
+ projectId: parent.projectId,
+ agentDefId: parent.agentDefId,
+ mode: admission.mode,
+ engine: parent.engine,
+ trigger,
+ gh,
+ createdBy: actor,
+ })
+ .returning({ id: runs.id }),
+ )
)[0];
if (!resumed) throw new Error("run_resume_failed");
await db.insert(runEvents).values({
@@ -802,75 +1108,110 @@ async function executeKnownMcpTool(
if (toolName === "facility_send_conversation_message") {
const conversationId = requiredString(args.conversationId, "conversationId");
const body = requiredString(args.body, "body");
- const result = await db.transaction(async (tx) => {
- await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${conversationId}))`);
- const conversation = (
+ const conversationForPolicy = (
+ await db
+ .select()
+ .from(conversations)
+ .where(and(eq(conversations.orgId, orgId), eq(conversations.id, conversationId)))
+ .limit(1)
+ )[0];
+ if (!conversationForPolicy) throw new Error("conversation_not_found");
+ const agentForPolicy = (
+ await db
+ .select()
+ .from(agentDefs)
+ .where(
+ and(
+ eq(agentDefs.orgId, orgId),
+ eq(agentDefs.projectId, conversationForPolicy.projectId),
+ eq(agentDefs.id, conversationForPolicy.agentDefId),
+ ),
+ )
+ .limit(1)
+ )[0];
+ if (!agentForPolicy) throw new Error("conversation_agent_not_found");
+ const result = await withBuilderPlanPreflight(
+ db,
+ {
+ orgId,
+ projectId: conversationForPolicy.projectId,
+ mode: "conversation",
+ agentDefId: agentForPolicy.id,
+ trigger: { type: "conversation", conversationId, message: body },
+ actor: { type: auditActorType(actor.type), id: actor.id },
+ source: "mcp_conversation_message",
+ },
+ async (tx, admission) => {
+ await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${conversationId}))`);
+ const conversation = (
+ await tx
+ .update(conversations)
+ .set({ status: "running", updatedAt: new Date() })
+ .where(
+ and(
+ eq(conversations.orgId, orgId),
+ eq(conversations.id, conversationId),
+ eq(conversations.status, "idle"),
+ ),
+ )
+ .returning()
+ )[0];
+ if (!conversation) throw new Error("conversation_turn_in_flight");
+ const rows = await tx
+ .select({ max: sql`coalesce(max(${conversationMessages.seq}), 0)` })
+ .from(conversationMessages)
+ .where(eq(conversationMessages.conversationId, conversationId));
+ const message = (
+ await tx
+ .insert(conversationMessages)
+ .values({
+ id: newId("evt"),
+ orgId,
+ conversationId,
+ seq: Number(rows[0]?.max ?? 0) + 1,
+ role: "user",
+ body,
+ })
+ .returning({ id: conversationMessages.id })
+ )[0];
+ const run = (
+ await tx
+ // builder-plan-preflight: mcp_conversation_message
+ .insert(runs)
+ .values({
+ id: newId("run"),
+ orgId,
+ projectId: conversation.projectId,
+ agentDefId: conversation.agentDefId,
+ mode: admission.mode,
+ engine: "claude_code",
+ trigger: {
+ type: "conversation",
+ conversationId,
+ message: body,
+ ...(conversation.engineSessionId && conversation.lastRunId
+ ? { resumeOf: conversation.lastRunId }
+ : {}),
+ },
+ createdBy: actor,
+ })
+ .returning({ id: runs.id })
+ )[0];
+ if (!message || !run) throw new Error("conversation_turn_create_failed");
await tx
.update(conversations)
- .set({ status: "running", updatedAt: new Date() })
- .where(
- and(
- eq(conversations.orgId, orgId),
- eq(conversations.id, conversationId),
- eq(conversations.status, "idle"),
- ),
- )
- .returning()
- )[0];
- if (!conversation) throw new Error("conversation_turn_in_flight");
- const rows = await tx
- .select({ max: sql`coalesce(max(${conversationMessages.seq}), 0)` })
- .from(conversationMessages)
- .where(eq(conversationMessages.conversationId, conversationId));
- const message = (
- await tx
- .insert(conversationMessages)
- .values({
- id: newId("evt"),
- orgId,
- conversationId,
- seq: Number(rows[0]?.max ?? 0) + 1,
- role: "user",
- body,
- })
- .returning({ id: conversationMessages.id })
- )[0];
- const run = (
- await tx
- .insert(runs)
- .values({
- id: newId("run"),
- orgId,
- projectId: conversation.projectId,
- agentDefId: conversation.agentDefId,
- mode: "conversation",
- engine: "claude_code",
- trigger: {
- type: "conversation",
- conversationId,
- message: body,
- ...(conversation.engineSessionId && conversation.lastRunId
- ? { resumeOf: conversation.lastRunId }
- : {}),
- },
- createdBy: actor,
- })
- .returning({ id: runs.id })
- )[0];
- if (!message || !run) throw new Error("conversation_turn_create_failed");
- await tx
- .update(conversations)
- .set({ lastRunId: run.id, updatedAt: new Date() })
- .where(and(eq(conversations.orgId, orgId), eq(conversations.id, conversationId)));
- await tx.insert(runEvents).values({
- orgId,
- runId: run.id,
- seq: 1,
- type: "queued",
- data: { queue: "runs.dispatch" },
- });
- return { messageId: message.id, runId: run.id };
- });
+ .set({ lastRunId: run.id, updatedAt: new Date() })
+ .where(and(eq(conversations.orgId, orgId), eq(conversations.id, conversationId)));
+ await tx.insert(runEvents).values({
+ orgId,
+ runId: run.id,
+ seq: 1,
+ type: "queued",
+ data: { queue: "runs.dispatch" },
+ });
+ return { messageId: message.id, runId: run.id };
+ },
+ );
await options.enqueue?.("runs.dispatch", { runId: result.runId, orgId });
return { conversationId, ...result };
}
@@ -918,25 +1259,42 @@ async function executeKnownMcpTool(
)[0];
if (!repo) throw new Error("repo_not_found");
const agent = await resolveAgentForMcpRun(db, orgId, projectId, agentName);
+ const trigger = {
+ type: "mcp_issue",
+ repo: { id: repo.id, owner: repo.owner, name: repo.name },
+ issue: { number },
+ };
+ const gh = { owner: repo.owner, repo: repo.name, issueNumber: number };
const run = (
- await db
- .insert(runs)
- .values({
- id: newId("run"),
+ await withBuilderPlanPreflight(
+ db,
+ {
orgId,
projectId,
+ mode: agent.name,
agentDefId: agent.id,
- mode: agentName,
- engine: agent.engine,
- trigger: {
- type: "mcp_issue",
- repo: { id: repo.id, owner: repo.owner, name: repo.name },
- issue: { number },
- },
- gh: { owner: repo.owner, repo: repo.name, issueNumber: number },
- createdBy: actor,
- })
- .returning({ id: runs.id })
+ trigger,
+ gh,
+ actor: { type: auditActorType(actor.type), id: actor.id },
+ source: "mcp_trigger_github_issue",
+ },
+ (tx, admission) =>
+ tx
+ // builder-plan-preflight: mcp_trigger_github_issue
+ .insert(runs)
+ .values({
+ id: newId("run"),
+ orgId,
+ projectId,
+ agentDefId: agent.id,
+ mode: admission.mode,
+ engine: agent.engine,
+ trigger,
+ gh,
+ createdBy: actor,
+ })
+ .returning({ id: runs.id }),
+ )
)[0];
if (!run) throw new Error("run_create_failed");
await db.insert(runEvents).values({
@@ -1040,24 +1398,28 @@ async function executeKnownMcpTool(
const contractItemId = await resolveMcpAgentContract(db, orgId, projectId, args, actor.id);
await assertMcpRegistryReference(db, orgId, projectId, optionalString(args.harnessItemId));
await assertMcpSandboxReference(db, orgId, projectId, optionalString(args.sandboxProfileId));
- const agent = (
- await db
- .insert(agentDefs)
- .values({
- id: newId("agent"),
- orgId,
- projectId,
- name: requiredString(args.name, "name"),
- engine: requiredString(args.engine, "engine"),
- model: args.model ?? {},
- contractItemId,
- harnessItemId: optionalString(args.harnessItemId),
- triggers: Array.isArray(args.triggers) ? args.triggers : [],
- sandboxProfileId: optionalString(args.sandboxProfileId),
- enabled: true,
- })
- .returning()
- )[0];
+ const agent = await db.transaction(async (transaction) => {
+ const tx = transaction as unknown as FacilityDb;
+ await lockBuilderPlanPolicy(tx, orgId, projectId);
+ return (
+ await tx
+ .insert(agentDefs)
+ .values({
+ id: newId("agent"),
+ orgId,
+ projectId,
+ name: requiredString(args.name, "name"),
+ engine: requiredString(args.engine, "engine"),
+ model: args.model ?? {},
+ contractItemId,
+ harnessItemId: optionalString(args.harnessItemId),
+ triggers: Array.isArray(args.triggers) ? args.triggers : [],
+ sandboxProfileId: optionalString(args.sandboxProfileId),
+ enabled: true,
+ })
+ .returning()
+ )[0];
+ });
return { agentId: agent?.id, contractItemId };
}
@@ -1100,19 +1462,23 @@ async function executeKnownMcpTool(
updatedAt: new Date(),
};
if (Object.keys(values).length === 1) throw new Error("mcp_tool_no_changes");
- const updated = (
- await db
- .update(agentDefs)
- .set(values)
- .where(
- and(
- eq(agentDefs.orgId, orgId),
- eq(agentDefs.projectId, projectId),
- eq(agentDefs.id, agentId),
- ),
- )
- .returning({ id: agentDefs.id })
- )[0];
+ const updated = await db.transaction(async (transaction) => {
+ const tx = transaction as unknown as FacilityDb;
+ await lockBuilderPlanPolicy(tx, orgId, projectId);
+ return (
+ await tx
+ .update(agentDefs)
+ .set(values)
+ .where(
+ and(
+ eq(agentDefs.orgId, orgId),
+ eq(agentDefs.projectId, projectId),
+ eq(agentDefs.id, agentId),
+ ),
+ )
+ .returning({ id: agentDefs.id })
+ )[0];
+ });
if (!updated) throw new Error("agent_not_found");
return { agentId: updated.id };
}
@@ -1120,18 +1486,22 @@ async function executeKnownMcpTool(
if (toolName === "facility_retire_agent") {
const projectId = requiredString(args.projectId, "projectId");
const agentId = requiredString(args.agentId, "agentId");
- const retired = (
- await db
- .delete(agentDefs)
- .where(
- and(
- eq(agentDefs.orgId, orgId),
- eq(agentDefs.projectId, projectId),
- eq(agentDefs.id, agentId),
- ),
- )
- .returning({ id: agentDefs.id })
- )[0];
+ const retired = await db.transaction(async (transaction) => {
+ const tx = transaction as unknown as FacilityDb;
+ await lockBuilderPlanPolicy(tx, orgId, projectId);
+ return (
+ await tx
+ .delete(agentDefs)
+ .where(
+ and(
+ eq(agentDefs.orgId, orgId),
+ eq(agentDefs.projectId, projectId),
+ eq(agentDefs.id, agentId),
+ ),
+ )
+ .returning({ id: agentDefs.id })
+ )[0];
+ });
if (!retired) throw new Error("agent_not_found");
return { agentId: retired.id, retired: true };
}
@@ -1449,6 +1819,7 @@ function resumableGithubContext(value: unknown) {
...(typeof source.owner === "string" ? { owner: source.owner } : {}),
...(typeof source.repo === "string" ? { repo: source.repo } : {}),
...(typeof source.branch === "string" ? { branch: source.branch } : {}),
+ ...(typeof source.issueNumber === "number" ? { issueNumber: source.issueNumber } : {}),
};
}
@@ -1778,53 +2149,73 @@ async function connectMcpRepo(
) {
const owner = requiredString(args.owner, "owner");
const name = requiredString(args.name, "name");
- const installation = (
- await db
- .select()
- .from(githubInstallations)
- .where(
- and(
- eq(githubInstallations.orgId, orgId),
- eq(githubInstallations.accountLogin, owner),
- isNull(githubInstallations.suspendedAt),
- ),
- )
- .limit(1)
- )[0];
- if (!installation) throw new Error("github_installation_required");
- const factory = options.githubFactory ?? createGithubClientFactory(requireConfig(options));
- const octokit = await factory(installation.installationId);
const shouldCreate = args.create === true || args.mode === "create";
- const createRepository = octokit.rest.repos.createInOrg;
- const getRepository = octokit.rest.repos.get;
- if (shouldCreate && !createRepository) throw new Error("github_create_unavailable");
- if (!shouldCreate && !getRepository) throw new Error("github_lookup_unavailable");
- const response = shouldCreate
- ? await createRepository?.({
- org: owner,
- name,
- private: args.private !== false,
- description: optionalString(args.description),
- auto_init: args.autoInit !== false,
- })
- : await getRepository?.({ owner, repo: name });
- if (!response) throw new Error("github_repo_unavailable");
- const repo = (
- await db
- .insert(repos)
- .values({
- id: newId("repo"),
- orgId,
- projectId,
- installationId: installation.id,
- owner: response.data.owner?.login ?? owner,
- name: response.data.name,
- defaultBranch: response.data.default_branch ?? optionalString(args.defaultBranch) ?? "main",
- })
- .returning()
- )[0];
- if (!repo) throw new Error("repo_connect_failed");
- return { repoId: repo.id };
+ return db.transaction(async (transaction) => {
+ const tx = transaction as unknown as FacilityDb;
+ await lockBuilderPlanPolicy(tx, orgId, projectId);
+ const project = (
+ await tx
+ .select({ policy: projects.builderPlanPolicy })
+ .from(projects)
+ .where(and(eq(projects.orgId, orgId), eq(projects.id, projectId)))
+ .limit(1)
+ )[0];
+ if (!project) throw new Error("project_not_found");
+ if (project.policy === "required") {
+ throw new ApiError(
+ 409,
+ "builder_plan_platform_lane_required",
+ "Set Builder plan policy to optional before connecting a repository; configure its Builder lane as platform, then re-enable required",
+ );
+ }
+ const installation = (
+ await tx
+ .select()
+ .from(githubInstallations)
+ .where(
+ and(
+ eq(githubInstallations.orgId, orgId),
+ eq(githubInstallations.accountLogin, owner),
+ isNull(githubInstallations.suspendedAt),
+ ),
+ )
+ .limit(1)
+ )[0];
+ if (!installation) throw new Error("github_installation_required");
+ const factory = options.githubFactory ?? createGithubClientFactory(requireConfig(options));
+ const octokit = await factory(installation.installationId);
+ const createRepository = octokit.rest.repos.createInOrg;
+ const getRepository = octokit.rest.repos.get;
+ if (shouldCreate && !createRepository) throw new Error("github_create_unavailable");
+ if (!shouldCreate && !getRepository) throw new Error("github_lookup_unavailable");
+ const response = shouldCreate
+ ? await createRepository?.({
+ org: owner,
+ name,
+ private: args.private !== false,
+ description: optionalString(args.description),
+ auto_init: args.autoInit !== false,
+ })
+ : await getRepository?.({ owner, repo: name });
+ if (!response) throw new Error("github_repo_unavailable");
+ const repo = (
+ await tx
+ .insert(repos)
+ .values({
+ id: newId("repo"),
+ orgId,
+ projectId,
+ installationId: installation.id,
+ owner: response.data.owner?.login ?? owner,
+ name: response.data.name,
+ defaultBranch:
+ response.data.default_branch ?? optionalString(args.defaultBranch) ?? "main",
+ })
+ .returning()
+ )[0];
+ if (!repo) throw new Error("repo_connect_failed");
+ return { repoId: repo.id };
+ });
}
async function executeTaskCreation(
diff --git a/services/api/src/github/agent-routing.ts b/services/api/src/github/agent-routing.ts
new file mode 100644
index 00000000..2878f9d4
--- /dev/null
+++ b/services/api/src/github/agent-routing.ts
@@ -0,0 +1,37 @@
+import { agentDefs, type FacilityDb, type repos } from "@facility/db";
+import { and, eq } from "drizzle-orm";
+
+export function laneFor(repo: typeof repos.$inferSelect, command: string): "repo" | "platform" {
+ const answers = repo.renderAnswers as { execution_lane?: Record } | null;
+ const lane = answers?.execution_lane?.[command] ?? answers?.execution_lane?.[`/${command}`];
+ return lane === "platform" ? "platform" : "repo";
+}
+
+export async function findAgentDef(
+ db: FacilityDb,
+ orgId: string,
+ projectId: string,
+ command: string,
+) {
+ const rows = await db
+ .select()
+ .from(agentDefs)
+ .where(
+ and(
+ eq(agentDefs.orgId, orgId),
+ eq(agentDefs.projectId, projectId),
+ eq(agentDefs.enabled, true),
+ ),
+ );
+ return rows.find((row) => {
+ const triggers = row.triggers as unknown;
+ if (!Array.isArray(triggers)) return row.name === command;
+ return triggers.some((trigger) => {
+ if (!trigger || typeof trigger !== "object") return false;
+ const value =
+ (trigger as { command?: unknown; handle?: unknown }).command ??
+ (trigger as { handle?: unknown }).handle;
+ return value === command || value === `/${command}`;
+ });
+ });
+}
diff --git a/services/api/src/github/architect-plan-publication.ts b/services/api/src/github/architect-plan-publication.ts
new file mode 100644
index 00000000..e331e19f
--- /dev/null
+++ b/services/api/src/github/architect-plan-publication.ts
@@ -0,0 +1,81 @@
+export function architectPlanPublicationKey(runId: string, proposalId: string) {
+ return `architect-plan:${runId}:${proposalId}`;
+}
+
+export function architectPlanPublicationMarker(runId: string, proposalId: string) {
+ return ``;
+}
+
+export function legacyRunProgressMarker(runId: string) {
+ return ``;
+}
+
+export function isGithubNotFound(error: unknown) {
+ if (!error || typeof error !== "object") return false;
+ const candidate = error as {
+ status?: unknown;
+ statusCode?: unknown;
+ response?: { status?: unknown };
+ };
+ return (
+ candidate.status === 404 || candidate.statusCode === 404 || candidate.response?.status === 404
+ );
+}
+
+export function findArchitectPlanPublicationComment(
+ comments: T[],
+ input: { runId: string; publicationMarker: string; allowLegacy: boolean },
+) {
+ const bots = comments.filter((comment) => comment.authorType.toLowerCase() === "bot");
+ return (
+ bots.find((comment) => comment.body.includes(input.publicationMarker)) ??
+ (input.allowLegacy
+ ? bots.find((comment) => comment.body.includes(legacyRunProgressMarker(input.runId)))
+ : undefined)
+ );
+}
+
+export function rotateArchitectPlanPublicationOrgIds(orgIds: string[], now: Date, limit: number) {
+ if (limit <= 0 || orgIds.length === 0) return [];
+ if (orgIds.length <= limit) return [...orgIds];
+ const minuteBucket = Math.floor(now.getTime() / 60_000);
+ const offset = (minuteBucket * limit) % orgIds.length;
+ return Array.from(
+ { length: limit },
+ (_, index) => orgIds[(offset + index) % orgIds.length] ?? "",
+ );
+}
+
+export function effectiveArchitectPlanProposalState(
+ state: string,
+ expiresAt: Date,
+ now = new Date(),
+) {
+ const open = state === "open" && expiresAt.getTime() > now.getTime();
+ return { open, state: state === "open" && !open ? "expired" : state };
+}
+
+export function renderClosedArchitectPlanPublication(input: {
+ runId: string;
+ plan: string;
+ proposalState: string;
+ publicationMarker: string;
+ updatedAt?: Date;
+}) {
+ return [
+ ``,
+ input.publicationMarker,
+ "### ✅ Facility Architect plan",
+ "",
+ `**Human Gate 1:** no longer open (\`${input.proposalState}\`)`,
+ `**Run:** \`${input.runId}\``,
+ "",
+ "This is the plan snapshot produced by the completed Architect run. Facility will not accept a new Builder approval from this closed proposal.",
+ "",
+ "## Plan snapshot",
+ "",
+ input.plan.slice(0, 48_000),
+ "",
+ `_Last updated: ${(input.updatedAt ?? new Date()).toISOString()}_`,
+ ].join("\n");
+}
diff --git a/services/api/src/github/client.ts b/services/api/src/github/client.ts
index a5e0a77b..269c89f2 100644
--- a/services/api/src/github/client.ts
+++ b/services/api/src/github/client.ts
@@ -132,6 +132,7 @@ export type Octokit = {
number: number;
title: string;
body?: string | null;
+ state?: string;
html_url: string;
user?: { login?: string } | null;
labels?: Array;
diff --git a/services/api/src/github/issue-revision.ts b/services/api/src/github/issue-revision.ts
new file mode 100644
index 00000000..96596628
--- /dev/null
+++ b/services/api/src/github/issue-revision.ts
@@ -0,0 +1,125 @@
+import { createHash } from "node:crypto";
+
+export type GithubIssueRevisionComment = {
+ id: number;
+ author: string;
+ authorType: string;
+ body: string;
+ createdAt: string;
+ url: string;
+};
+
+type GithubIssueLike = {
+ title?: unknown;
+ body?: unknown;
+ state?: unknown;
+ user?: { login?: unknown } | null;
+ labels?: unknown;
+ html_url?: unknown;
+};
+
+const FACILITY_COMMENT_MARKERS = ["",
+ "### 🛑 Facility `/builder` blocked",
+ "",
+ "No Builder run was created because Human Gate 1 did not have valid plan evidence.",
+ "",
+ guidance[code] ?? guidance.builder_plan_context_invalid,
+ "",
+ `**Policy reason:** \`${code}\``,
+ ].join("\n");
+}
+
export function progressCommentId(gh: unknown) {
const root = objectOrEmpty(gh);
const progress = objectOrEmpty(root.progressComment);
diff --git a/services/api/src/integrations/inbound.ts b/services/api/src/integrations/inbound.ts
index e2945b90..295bc3aa 100644
--- a/services/api/src/integrations/inbound.ts
+++ b/services/api/src/integrations/inbound.ts
@@ -10,6 +10,7 @@ import {
runs,
} from "@facility/db";
import { and, eq } from "drizzle-orm";
+import { withBuilderPlanPreflight } from "../builder-plan-policy.js";
import { ApiError } from "../errors.js";
import {
type IssueSeverity,
@@ -185,25 +186,42 @@ async function maybeEnqueueRun(
const projectId = await resolveProjectId(db, event.orgId, integration.projectId, payload, config);
if (!projectId) throw new Error("generic_inbound_run_project_required");
const agent = await resolveAgent(db, event.orgId, projectId, runConfig, config);
+ const mode = stringField(runConfig, "mode") ?? stringField(config, "mode") ?? agent.name;
+ const engine = stringField(runConfig, "engine") ?? stringField(config, "engine") ?? agent.engine;
+ const trigger = {
+ type: "generic_inbound",
+ integrationId: integration.id,
+ inboundEventId: event.id,
+ eventType: event.eventType,
+ };
const run = (
- await db
- .insert(runs)
- .values({
- id: newId("run"),
+ await withBuilderPlanPreflight(
+ db,
+ {
orgId: event.orgId,
projectId,
+ mode,
agentDefId: agent.id,
- mode: stringField(runConfig, "mode") ?? stringField(config, "mode") ?? agent.name,
- engine: stringField(runConfig, "engine") ?? stringField(config, "engine") ?? agent.engine,
- trigger: {
- type: "generic_inbound",
- integrationId: integration.id,
- inboundEventId: event.id,
- eventType: event.eventType,
- },
- createdBy: { type: "system", id: `integration:${integration.id}` },
- })
- .returning()
+ trigger,
+ actor: { type: "system", id: `integration:${integration.id}` },
+ source: "generic_inbound",
+ },
+ (tx, admission) =>
+ tx
+ // builder-plan-preflight: inbound_dispatch
+ .insert(runs)
+ .values({
+ id: newId("run"),
+ orgId: event.orgId,
+ projectId,
+ agentDefId: agent.id,
+ mode: admission.mode,
+ engine,
+ trigger,
+ createdBy: { type: "system", id: `integration:${integration.id}` },
+ })
+ .returning(),
+ )
)[0];
if (!run) throw new Error("generic_inbound_run_insert_failed");
await db.insert(runEvents).values({
diff --git a/services/api/src/learning.ts b/services/api/src/learning.ts
index 79cb9dc0..3d73709e 100644
--- a/services/api/src/learning.ts
+++ b/services/api/src/learning.ts
@@ -15,6 +15,7 @@ import {
spendCounters,
} from "@facility/db";
import { and, desc, eq, gte, inArray, lte } from "drizzle-orm";
+import { isBuilderPlanDenialError, withBuilderPlanPreflight } from "./builder-plan-policy.js";
import {
createGithubClientFactory,
FacilityGithubClient,
@@ -473,21 +474,42 @@ export async function runLearningNightly(
await attachGithubReviewEvidence(db, basePacket, githubFactory),
);
const packetUrl = `facility://learning-packets/${agent.projectId}/${packet.date}`;
- const run = (
- await db
- .insert(runs)
- .values({
- id: newId("run"),
- orgId: agent.orgId,
- projectId: agent.projectId,
- agentDefId: agent.id,
- mode: "learning",
- engine: agent.engine,
- trigger: { type: "schedule", packetUrl, packet },
- createdBy: { type: "system", id: "learning.nightly" },
- })
- .returning()
- )[0];
+ const trigger = { type: "schedule", packetUrl, packet };
+ let run: typeof runs.$inferSelect | undefined;
+ try {
+ run = (
+ await withBuilderPlanPreflight(
+ db,
+ {
+ orgId: agent.orgId,
+ projectId: agent.projectId,
+ mode: "learning",
+ agentDefId: agent.id,
+ trigger,
+ actor: { type: "system", id: "learning.nightly" },
+ source: "learning_nightly",
+ },
+ (tx, admission) =>
+ tx
+ // builder-plan-preflight: learning_nightly
+ .insert(runs)
+ .values({
+ id: newId("run"),
+ orgId: agent.orgId,
+ projectId: agent.projectId,
+ agentDefId: agent.id,
+ mode: admission.mode,
+ engine: agent.engine,
+ trigger,
+ createdBy: { type: "system", id: "learning.nightly" },
+ })
+ .returning(),
+ )
+ )[0];
+ } catch (error) {
+ if (isBuilderPlanDenialError(error)) continue;
+ throw error;
+ }
if (run) {
createdRuns.push(run.id);
await enqueue("runs.dispatch", { runId: run.id, orgId: run.orgId });
diff --git a/services/api/src/routes/internal.ts b/services/api/src/routes/internal.ts
index b4ba4c24..69ad884d 100644
--- a/services/api/src/routes/internal.ts
+++ b/services/api/src/routes/internal.ts
@@ -24,6 +24,7 @@ import {
import type { AppConfig } from "../types.js";
const Params = z.object({ runId: z.string() });
+const GitCommitSha = z.string().regex(/^[0-9a-f]{40}$/i);
const TRANSCRIPT_MAX_BYTES = 50 * 1024 * 1024;
const SESSION_STATE_MAX_BYTES = 200 * 1024 * 1024;
const EventBatch = z.array(
@@ -168,6 +169,45 @@ export async function registerInternalRoutes(app: FastifyInstance, config: AppCo
},
);
+ app.post(
+ "/internal/runs/:runId/workspace",
+ {
+ config: { public: true },
+ preHandler: authenticate,
+ schema: {
+ params: Params,
+ body: z.object({ baseSha: GitCommitSha }),
+ response: { 200: z.object({ baseSha: GitCommitSha }) },
+ },
+ },
+ async (request) => {
+ const run = (request as RunnerRequest).runnerRun;
+ if (!run) throw notFound("Run not found");
+ const baseSha = (request.body as { baseSha: string }).baseSha.toLowerCase();
+ const [recorded] = await db
+ .update(runs)
+ .set({ workspaceBaseSha: baseSha, updatedAt: new Date() })
+ .where(and(eq(runs.orgId, run.orgId), eq(runs.id, run.id), isNull(runs.workspaceBaseSha)))
+ .returning({ baseSha: runs.workspaceBaseSha });
+ if (recorded?.baseSha) return { baseSha: recorded.baseSha };
+
+ // Runner lifecycle requests may be replayed after a lost response. The
+ // checkpoint is immutable: an exact replay succeeds, while a different
+ // SHA cannot rewrite the provenance already bound to this run.
+ const [current] = await db
+ .select({ baseSha: runs.workspaceBaseSha })
+ .from(runs)
+ .where(and(eq(runs.orgId, run.orgId), eq(runs.id, run.id)))
+ .limit(1);
+ if (current?.baseSha === baseSha) return { baseSha };
+ throw new ApiError(
+ 409,
+ "workspace_base_mismatch",
+ "Run workspace base commit was already recorded",
+ );
+ },
+ );
+
app.post(
"/internal/runs/:runId/events",
{
@@ -426,6 +466,7 @@ export async function registerInternalRoutes(app: FastifyInstance, config: AppCo
.object({
branch: z.string().optional(),
headSha: z.string().optional(),
+ baseSha: GitCommitSha.optional(),
changed: z.boolean(),
pushError: z.string().optional(),
pullRequestTitle: z.string().optional(),
@@ -448,6 +489,7 @@ export async function registerInternalRoutes(app: FastifyInstance, config: AppCo
git?: {
branch?: string;
headSha?: string;
+ baseSha?: string;
changed: boolean;
pushError?: string;
pullRequestTitle?: string;
diff --git a/services/api/src/routes/v1/agents-sandboxes.ts b/services/api/src/routes/v1/agents-sandboxes.ts
index b3a5fd99..7c2a607c 100644
--- a/services/api/src/routes/v1/agents-sandboxes.ts
+++ b/services/api/src/routes/v1/agents-sandboxes.ts
@@ -1,8 +1,9 @@
import { newId } from "@facility/core";
-import { agentDefs, projects, registryItems, sandboxProfiles } from "@facility/db";
+import { agentDefs, type FacilityDb, projects, registryItems, sandboxProfiles } from "@facility/db";
import { and, asc, eq } from "drizzle-orm";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
+import { lockBuilderPlanPolicy } from "../../builder-plan-policy.js";
import { ApiError } from "../../errors.js";
import {
nestedDockerSettingIsValid,
@@ -258,25 +259,30 @@ function registerCrud(
validateAgentSchedules(body.triggers);
await assertAgentReferences(p, params.projectId, body);
if (body.permissions) assertPermissionsGrantable(p, body.permissions);
- return (
- await app.facilityDb
- .insert(table)
- .values({
- id: newId(prefix),
- orgId: p.orgId,
- projectId: rowProjectId,
- name: body.name,
- engine: body.engine,
- model: body.model,
- contractItemId: body.contractItemId,
- harnessItemId: body.harnessItemId,
- triggers: body.triggers,
- sandboxProfileId: body.sandboxProfileId,
- permissions: body.permissions ?? [],
- enabled: body.enabled,
- })
- .returning()
- )[0];
+ if (!rowProjectId) throw new ApiError(400, "project_required", "Agent project is required");
+ return app.facilityDb.transaction(async (transaction) => {
+ const tx = transaction as unknown as FacilityDb;
+ await lockBuilderPlanPolicy(tx, p.orgId, rowProjectId);
+ return (
+ await tx
+ .insert(table)
+ .values({
+ id: newId(prefix),
+ orgId: p.orgId,
+ projectId: rowProjectId,
+ name: body.name,
+ engine: body.engine,
+ model: body.model,
+ contractItemId: body.contractItemId,
+ harnessItemId: body.harnessItemId,
+ triggers: body.triggers,
+ sandboxProfileId: body.sandboxProfileId,
+ permissions: body.permissions ?? [],
+ enabled: body.enabled,
+ })
+ .returning()
+ )[0];
+ });
}
const body = request.body as {
name: string;
@@ -366,13 +372,20 @@ function registerCrud(
network: (request.body as { network?: Record }).network,
updatedAt: new Date(),
});
- return (
- await app.facilityDb
+ const update = (database: FacilityDb) =>
+ database
.update(table)
.set(set)
.where(and(...clauses))
- .returning()
- )[0];
+ .returning();
+ if (prefix === "agent") {
+ return app.facilityDb.transaction(async (transaction) => {
+ const tx = transaction as unknown as FacilityDb;
+ await lockBuilderPlanPolicy(tx, p.orgId, existing.projectId);
+ return (await update(tx))[0];
+ });
+ }
+ return (await update(app.facilityDb))[0];
},
);
app.delete(
@@ -397,7 +410,15 @@ function registerCrud(
}
const clauses = [eq(table.orgId, p.orgId), eq(table.id, id)];
if (projectId && table.projectId) clauses.push(eq(table.projectId, projectId));
- await app.facilityDb.delete(table).where(and(...clauses));
+ if (prefix === "agent") {
+ await app.facilityDb.transaction(async (transaction) => {
+ const tx = transaction as unknown as FacilityDb;
+ await lockBuilderPlanPolicy(tx, p.orgId, existing.projectId);
+ await tx.delete(table).where(and(...clauses));
+ });
+ } else {
+ await app.facilityDb.delete(table).where(and(...clauses));
+ }
return { ok: true };
},
);
diff --git a/services/api/src/routes/v1/assistant.ts b/services/api/src/routes/v1/assistant.ts
index 7e4d5729..4bb9ca04 100644
--- a/services/api/src/routes/v1/assistant.ts
+++ b/services/api/src/routes/v1/assistant.ts
@@ -11,6 +11,7 @@ import { and, eq, sql } from "drizzle-orm";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { type AssistantModelDriver, runAssistantTurn } from "../../assistant/loop.js";
+import { withBuilderPlanPreflight } from "../../builder-plan-policy.js";
import { ApiError, notFound } from "../../errors.js";
import { terminalStatus } from "../../sandbox/state.js";
import { assertProjectScope, IdParams, principal, type V1RouteContext } from "./shared.js";
@@ -75,6 +76,11 @@ export async function registerAssistantRoutes(app: FastifyInstance, context: V1R
if (!owner) {
throw new ApiError(400, "no_owner_agent", "Project has no project-owner agent");
}
+ const policyTrigger = {
+ type: "conversation",
+ ...(body.conversationId ? { conversationId: body.conversationId } : {}),
+ message: body.body,
+ };
const ownerModel = modelFrom(owner.model) ?? "claude-sonnet-5";
let conversationId = body.conversationId;
@@ -122,86 +128,100 @@ export async function registerAssistantRoutes(app: FastifyInstance, context: V1R
conversationId = created.id;
}
- const result = await db.transaction(async (tx) => {
- await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${conversationId}))`);
- const thread = (
- await tx
- .select()
- .from(conversations)
- .where(and(eq(conversations.orgId, p.orgId), eq(conversations.id, conversationId)))
- .limit(1)
- )[0];
- if (!thread) throw notFound("Conversation not found");
- if (thread.status === "running") {
- // Self-heal: a turn whose pinned run already ended (or died without a
- // trace — API crash before the reconcile sweep) must not deadlock the
- // thread. Anything genuinely in flight stays locked.
- const pinned = thread.lastRunId
- ? (
- await tx
- .select({ status: runs.status, startedAt: runs.startedAt })
- .from(runs)
- .where(and(eq(runs.orgId, p.orgId), eq(runs.id, thread.lastRunId)))
- .limit(1)
- )[0]
- : undefined;
- const stale =
- !pinned ||
- terminalStatus(pinned.status) ||
- (pinned.startedAt !== null && Date.now() - pinned.startedAt.getTime() > STALE_TURN_MS);
- if (!stale) {
- throw new ApiError(409, "turn_in_flight", "The thread already has a turn in flight");
+ const result = await withBuilderPlanPreflight(
+ db,
+ {
+ orgId: p.orgId,
+ projectId,
+ mode: "assistant",
+ agentDefId: owner.id,
+ trigger: policyTrigger,
+ actor: { type: p.type, id: p.id },
+ source: "assistant_conversation",
+ },
+ async (tx, admission) => {
+ await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${conversationId}))`);
+ const thread = (
+ await tx
+ .select()
+ .from(conversations)
+ .where(and(eq(conversations.orgId, p.orgId), eq(conversations.id, conversationId)))
+ .limit(1)
+ )[0];
+ if (!thread) throw notFound("Conversation not found");
+ if (thread.status === "running") {
+ // Self-heal: a turn whose pinned run already ended (or died without a
+ // trace — API crash before the reconcile sweep) must not deadlock the
+ // thread. Anything genuinely in flight stays locked.
+ const pinned = thread.lastRunId
+ ? (
+ await tx
+ .select({ status: runs.status, startedAt: runs.startedAt })
+ .from(runs)
+ .where(and(eq(runs.orgId, p.orgId), eq(runs.id, thread.lastRunId)))
+ .limit(1)
+ )[0]
+ : undefined;
+ const stale =
+ !pinned ||
+ terminalStatus(pinned.status) ||
+ (pinned.startedAt !== null &&
+ Date.now() - pinned.startedAt.getTime() > STALE_TURN_MS);
+ if (!stale) {
+ throw new ApiError(409, "turn_in_flight", "The thread already has a turn in flight");
+ }
}
- }
- await tx
- .update(conversations)
- .set({ status: "running", updatedAt: new Date() })
- .where(and(eq(conversations.orgId, p.orgId), eq(conversations.id, conversationId)));
- const seqRows = await tx
- .select({ max: sql`coalesce(max(seq), 0)` })
- .from(conversationMessages)
- .where(eq(conversationMessages.conversationId, conversationId));
- const seq = Number(seqRows[0]?.max ?? 0) + 1;
- const message = (
await tx
- .insert(conversationMessages)
- .values({
- id: newId("evt"),
- orgId: p.orgId,
- conversationId,
- seq,
- role: "user",
- body: body.body,
- })
- .returning()
- )[0];
- // status starts at "running" — never "queued": the reconcile backstop
- // re-enqueues stale queued runs into runs.dispatch, which would launch
- // a sandbox for what is an in-process turn.
- const run = (
+ .update(conversations)
+ .set({ status: "running", updatedAt: new Date() })
+ .where(and(eq(conversations.orgId, p.orgId), eq(conversations.id, conversationId)));
+ const seqRows = await tx
+ .select({ max: sql`coalesce(max(seq), 0)` })
+ .from(conversationMessages)
+ .where(eq(conversationMessages.conversationId, conversationId));
+ const seq = Number(seqRows[0]?.max ?? 0) + 1;
+ const message = (
+ await tx
+ .insert(conversationMessages)
+ .values({
+ id: newId("evt"),
+ orgId: p.orgId,
+ conversationId,
+ seq,
+ role: "user",
+ body: body.body,
+ })
+ .returning()
+ )[0];
+ // status starts at "running" — never "queued": the reconcile backstop
+ // re-enqueues stale queued runs into runs.dispatch, which would launch
+ // a sandbox for what is an in-process turn.
+ const run = (
+ await tx
+ // builder-plan-preflight: assistant_conversation
+ .insert(runs)
+ .values({
+ id: newId("run"),
+ orgId: p.orgId,
+ projectId,
+ agentDefId: owner.id,
+ mode: admission.mode,
+ engine: "inline",
+ status: "running",
+ startedAt: new Date(),
+ trigger: { type: "conversation", conversationId, message: body.body },
+ createdBy: { type: p.type, id: p.id },
+ })
+ .returning()
+ )[0];
+ if (!message || !run) throw new Error("assistant_turn_create_failed");
await tx
- .insert(runs)
- .values({
- id: newId("run"),
- orgId: p.orgId,
- projectId,
- agentDefId: owner.id,
- mode: "assistant",
- engine: "inline",
- status: "running",
- startedAt: new Date(),
- trigger: { type: "conversation", conversationId, message: body.body },
- createdBy: { type: p.type, id: p.id },
- })
- .returning()
- )[0];
- if (!message || !run) throw new Error("assistant_turn_create_failed");
- await tx
- .update(conversations)
- .set({ lastRunId: run.id, updatedAt: new Date() })
- .where(and(eq(conversations.orgId, p.orgId), eq(conversations.id, conversationId)));
- return { message, run };
- });
+ .update(conversations)
+ .set({ lastRunId: run.id, updatedAt: new Date() })
+ .where(and(eq(conversations.orgId, p.orgId), eq(conversations.id, conversationId)));
+ return { message, run };
+ },
+ );
await insertAuditEvent(db, {
orgId: p.orgId,
diff --git a/services/api/src/routes/v1/conversations.ts b/services/api/src/routes/v1/conversations.ts
index 8463dc8a..840ef685 100644
--- a/services/api/src/routes/v1/conversations.ts
+++ b/services/api/src/routes/v1/conversations.ts
@@ -10,6 +10,7 @@ import {
import { and, desc, eq, sql } from "drizzle-orm";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
+import { withBuilderPlanPreflight } from "../../builder-plan-policy.js";
import { ApiError, notFound } from "../../errors.js";
import {
assertBareRowProjectScope,
@@ -191,79 +192,94 @@ export async function registerConversationsRoutes(app: FastifyInstance, context:
if (conversation.status === "running") {
throw new ApiError(409, "turn_in_flight", "Conversation already has a turn in flight");
}
- const result = await db.transaction(async (tx) => {
- await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${conversationId}))`);
- const claimed = (
+ const agent = await loadAgent(p.orgId, conversation.projectId, conversation.agentDefId);
+ if (!agent) throw new ApiError(409, "conversation_agent_not_found", "Agent not found");
+ const result = await withBuilderPlanPreflight(
+ db,
+ {
+ orgId: p.orgId,
+ projectId: conversation.projectId,
+ mode: "conversation",
+ agentDefId: agent.id,
+ trigger: { type: "conversation", conversationId, message: body.body },
+ actor: { type: p.type, id: p.id },
+ source: "rest_conversation_message",
+ },
+ async (tx, admission) => {
+ await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${conversationId}))`);
+ const claimed = (
+ await tx
+ .update(conversations)
+ .set({ status: "running", updatedAt: new Date() })
+ .where(
+ and(
+ eq(conversations.orgId, p.orgId),
+ eq(conversations.id, conversationId),
+ eq(conversations.status, "idle"),
+ ),
+ )
+ .returning()
+ )[0];
+ if (!claimed) return null;
+ const rows = await tx
+ .select({ max: sql`coalesce(max(seq), 0)` })
+ .from(conversationMessages)
+ .where(eq(conversationMessages.conversationId, conversationId));
+ const seq = Number(rows[0]?.max ?? 0) + 1;
+ const message = (
+ await tx
+ .insert(conversationMessages)
+ .values({
+ id: newId("evt"),
+ orgId: p.orgId,
+ conversationId,
+ seq,
+ role: "user",
+ body: body.body,
+ })
+ .returning()
+ )[0];
+ const trigger: Record = {
+ type: "conversation",
+ conversationId,
+ message: body.body,
+ };
+ if (claimed.engineSessionId && claimed.lastRunId) trigger.resumeOf = claimed.lastRunId;
+ const run = (
+ await tx
+ // builder-plan-preflight: rest_conversation_message
+ .insert(runs)
+ .values({
+ id: newId("run"),
+ orgId: p.orgId,
+ projectId: claimed.projectId,
+ agentDefId: claimed.agentDefId,
+ mode: admission.mode,
+ engine: "claude_code",
+ trigger,
+ createdBy: { type: p.type, id: p.id },
+ })
+ .returning()
+ )[0];
+ if (!message || !run) throw new Error("conversation_turn_create_failed");
+ // Pin the run that OWNS this running turn. Both finalize paths
+ // (finishConversationTurn / releaseConversationOnFailure) require the
+ // finishing run to be this one — so a forged run carrying another
+ // conversation's id can't release or append to a thread it doesn't own.
await tx
.update(conversations)
- .set({ status: "running", updatedAt: new Date() })
- .where(
- and(
- eq(conversations.orgId, p.orgId),
- eq(conversations.id, conversationId),
- eq(conversations.status, "idle"),
- ),
- )
- .returning()
- )[0];
- if (!claimed) return null;
- const rows = await tx
- .select({ max: sql`coalesce(max(seq), 0)` })
- .from(conversationMessages)
- .where(eq(conversationMessages.conversationId, conversationId));
- const seq = Number(rows[0]?.max ?? 0) + 1;
- const message = (
- await tx
- .insert(conversationMessages)
- .values({
- id: newId("evt"),
- orgId: p.orgId,
- conversationId,
- seq,
- role: "user",
- body: body.body,
- })
- .returning()
- )[0];
- const trigger: Record = {
- type: "conversation",
- conversationId,
- message: body.body,
- };
- if (claimed.engineSessionId && claimed.lastRunId) trigger.resumeOf = claimed.lastRunId;
- const run = (
- await tx
- .insert(runs)
- .values({
- id: newId("run"),
- orgId: p.orgId,
- projectId: claimed.projectId,
- agentDefId: claimed.agentDefId,
- mode: "conversation",
- engine: "claude_code",
- trigger,
- createdBy: { type: p.type, id: p.id },
- })
- .returning()
- )[0];
- if (!message || !run) throw new Error("conversation_turn_create_failed");
- // Pin the run that OWNS this running turn. Both finalize paths
- // (finishConversationTurn / releaseConversationOnFailure) require the
- // finishing run to be this one — so a forged run carrying another
- // conversation's id can't release or append to a thread it doesn't own.
- await tx
- .update(conversations)
- .set({ lastRunId: run.id, updatedAt: new Date() })
- .where(and(eq(conversations.orgId, p.orgId), eq(conversations.id, conversationId)));
- await tx.insert(runEvents).values({
- orgId: p.orgId,
- runId: run.id,
- seq: 1,
- type: "queued",
- data: { queue: "runs.dispatch" },
- });
- return { message, run };
- });
+ .set({ lastRunId: run.id, updatedAt: new Date() })
+ .where(and(eq(conversations.orgId, p.orgId), eq(conversations.id, conversationId)));
+ await tx.insert(runEvents).values({
+ orgId: p.orgId,
+ runId: run.id,
+ seq: 1,
+ type: "queued",
+ data: { queue: "runs.dispatch" },
+ });
+ return { message, run };
+ },
+ );
if (!result) {
throw new ApiError(409, "turn_in_flight", "Conversation already has a turn in flight");
}
diff --git a/services/api/src/routes/v1/github.ts b/services/api/src/routes/v1/github.ts
index fa1ec2fc..1eee38f1 100644
--- a/services/api/src/routes/v1/github.ts
+++ b/services/api/src/routes/v1/github.ts
@@ -15,6 +15,7 @@ import {
import { and, desc, eq, gte, inArray, isNull, lt, or, type SQL, sql } from "drizzle-orm";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
+import { assertBuilderPlanDispatch, withBuilderPlanPreflight } from "../../builder-plan-policy.js";
import { ApiError, notFound } from "../../errors.js";
import { createGithubClientFactory, type GithubClientFactory } from "../../github/client.js";
import { createGithubClientForRepo, syncRepoFacilityConfig } from "../../github/kickstart.js";
@@ -938,6 +939,15 @@ export async function registerGithubV1Routes(app: FastifyInstance, context: V1Ro
// context that cannot be dispatched.
const agent = await findAgentDef(db, p.orgId, projectId, body.agent);
if (!agent) throw new ApiError(400, "agent_not_found", "Agent definition not found");
+ await assertBuilderPlanDispatch(db, {
+ orgId: p.orgId,
+ projectId,
+ mode: agent.name,
+ agentDefId: agent.id,
+ trigger: { type: "web_issue" },
+ actor: { type: p.type, id: p.id },
+ source: "web_issue_preflight",
+ });
const githubFactory =
app.githubClientFactory ??
(config.githubAppId && config.githubAppPrivateKey
@@ -966,6 +976,7 @@ export async function registerGithubV1Routes(app: FastifyInstance, context: V1Ro
number: githubIssue.number,
title: githubIssue.title,
body: githubIssue.body,
+ state: githubIssue.state,
user: { login: githubIssue.user?.login },
labels: githubIssue.labels ?? [],
html_url: githubIssue.html_url,
@@ -974,30 +985,47 @@ export async function registerGithubV1Routes(app: FastifyInstance, context: V1Ro
issueComments,
);
assertGithubRequestContextSize(issueRequest);
+ const trigger = {
+ type: "web_issue",
+ ...(p.githubLogin ? { githubLogin: p.githubLogin } : {}),
+ repo: { id: repo.id, owner: repo.owner, name: repo.name },
+ issue: { number },
+ request: issueRequest,
+ };
+ const gh = { owner: repo.owner, repo: repo.name, issueNumber: number };
// No GitHub userCanWrite check: platform RBAC `runs:trigger` is the authority
// for control-plane-originated dispatch.
// No execution_lane gate: an explicit control-plane trigger is platform-lane intent.
const run = (
- await db
- .insert(runs)
- .values({
- id: newId("run"),
+ await withBuilderPlanPreflight(
+ db,
+ {
orgId: p.orgId,
projectId,
- agentDefId: agent.id,
mode: body.agent,
- engine: agent.engine,
- trigger: {
- type: "web_issue",
- ...(p.githubLogin ? { githubLogin: p.githubLogin } : {}),
- repo: { id: repo.id, owner: repo.owner, name: repo.name },
- issue: { number },
- request: issueRequest,
- },
- gh: { owner: repo.owner, repo: repo.name, issueNumber: number },
- createdBy: { type: p.type, id: p.id },
- })
- .returning()
+ agentDefId: agent.id,
+ trigger,
+ gh,
+ actor: { type: p.type, id: p.id },
+ source: "web_issue",
+ },
+ (tx, admission) =>
+ tx
+ // builder-plan-preflight: rest_github_issue
+ .insert(runs)
+ .values({
+ id: newId("run"),
+ orgId: p.orgId,
+ projectId,
+ agentDefId: agent.id,
+ mode: admission.mode,
+ engine: agent.engine,
+ trigger,
+ gh,
+ createdBy: { type: p.type, id: p.id },
+ })
+ .returning(),
+ )
)[0];
if (!run) throw new ApiError(500, "insert_failed", "Could not create run");
await db.insert(runEvents).values({
diff --git a/services/api/src/routes/v1/hitl.ts b/services/api/src/routes/v1/hitl.ts
index 207cd7bd..df3554ec 100644
--- a/services/api/src/routes/v1/hitl.ts
+++ b/services/api/src/routes/v1/hitl.ts
@@ -1,5 +1,12 @@
import { can, newId } from "@facility/core";
-import { actionTypes, platformIssues, proposalEvents, proposals, runs } from "@facility/db";
+import {
+ actionTypes,
+ platformIssues,
+ projects,
+ proposalEvents,
+ proposals,
+ runs,
+} from "@facility/db";
import { and, asc, desc, eq, gt, inArray, isNull, or } from "drizzle-orm";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
@@ -116,9 +123,32 @@ export async function registerHitlRoutes(app: FastifyInstance, context: V1RouteC
.orderBy(desc(proposals.createdAt), desc(proposals.id))
.limit(query.limit)
.offset(query.offset);
+ const failedProposalIds = proposalRows
+ .filter(({ proposal }) => proposal.state === "execution_failed")
+ .map(({ proposal }) => proposal.id);
+ const failureEvents = failedProposalIds.length
+ ? await db
+ .select()
+ .from(proposalEvents)
+ .where(
+ and(
+ eq(proposalEvents.orgId, p.orgId),
+ eq(proposalEvents.type, "execution_failed"),
+ inArray(proposalEvents.proposalId, failedProposalIds),
+ ),
+ )
+ .orderBy(desc(proposalEvents.seq))
+ : [];
+ const executionErrors = new Map();
+ for (const event of failureEvents) {
+ if (!executionErrors.has(event.proposalId)) {
+ executionErrors.set(event.proposalId, executionErrorFromEvents([event]));
+ }
+ }
const proposalResponses = proposalRows.map(({ proposal, actionType }) => ({
...proposal,
actionType,
+ executionError: executionErrors.get(proposal.id) ?? null,
}));
// Issues are watchtower alerts, not proposals: the `state` query param
// scopes proposals only. Always surface actionable (error/critical) issues
@@ -173,7 +203,7 @@ export async function registerHitlRoutes(app: FastifyInstance, context: V1RouteC
.from(proposalEvents)
.where(and(eq(proposalEvents.orgId, p.orgId), eq(proposalEvents.proposalId, proposalId)))
.orderBy(asc(proposalEvents.seq));
- return { ...proposal, events };
+ return { ...proposal, executionError: executionErrorFromEvents(events), events };
},
);
@@ -369,6 +399,13 @@ export async function registerHitlRoutes(app: FastifyInstance, context: V1RouteC
actionType = await ensureIssueUpdateActionType(p.orgId);
}
if (!actionType) throw notFound("Action type not found");
+ if (actionType.name === "plan_acceptance") {
+ throw new ApiError(
+ 403,
+ "reserved_action_type",
+ "plan_acceptance proposals are created only from a completed Facility Architect run",
+ );
+ }
// mcp_tool_call dispatches to privileged operations (create agent, set
// budget, publish registry version, …) whose per-tool permission is checked
// ONLY on the dedicated /v1/mcp/tool-proposals route. The generic route
@@ -446,6 +483,27 @@ export async function registerHitlRoutes(app: FastifyInstance, context: V1RouteC
.limit(1)
)[0];
if (!proposal) throw new ApiError(409, "not_open", "Proposal is not open");
+ if (
+ body.decision === "approve" &&
+ proposal.actionType.name === "plan_acceptance" &&
+ proposal.proposal.projectId &&
+ p.type !== "user"
+ ) {
+ const project = (
+ await tx
+ .select({ builderPlanPolicy: projects.builderPlanPolicy })
+ .from(projects)
+ .where(and(eq(projects.orgId, p.orgId), eq(projects.id, proposal.proposal.projectId)))
+ .limit(1)
+ )[0];
+ if (project?.builderPlanPolicy === "required") {
+ throw new ApiError(
+ 403,
+ "builder_plan_approval_principal_required",
+ "Required Builder plans must be approved by a human user principal",
+ );
+ }
+ }
const opener = (
await tx
.select()
@@ -537,7 +595,11 @@ export async function registerHitlRoutes(app: FastifyInstance, context: V1RouteC
)[0];
if (!proposal) throw notFound("Proposal not found");
assertBareRowProjectScope(p, proposal.projectId, "Proposal not found");
- if (proposal.state !== "execution_failed" && proposal.state !== "approved") {
+ if (
+ proposal.state !== "execution_failed" &&
+ proposal.state !== "approved" &&
+ proposal.state !== "executing"
+ ) {
throw new ApiError(409, "not_executable", "Proposal is not pending execution");
}
const claimed = await executeApprovedProposal(
@@ -644,7 +706,28 @@ export async function registerHitlRoutes(app: FastifyInstance, context: V1RouteC
if (!actionType) {
throw new ApiError(500, "invalid_proposal", "Proposal action type is missing");
}
- return { ...proposal, actionType: actionType.name };
+ return {
+ ...proposal,
+ actionType: actionType.name,
+ executionError: await proposalExecutionError(proposal),
+ };
+ }
+
+ async function proposalExecutionError(proposal: typeof proposals.$inferSelect) {
+ if (proposal.state !== "execution_failed") return null;
+ const events = await db
+ .select()
+ .from(proposalEvents)
+ .where(
+ and(
+ eq(proposalEvents.orgId, proposal.orgId),
+ eq(proposalEvents.proposalId, proposal.id),
+ eq(proposalEvents.type, "execution_failed"),
+ ),
+ )
+ .orderBy(desc(proposalEvents.seq))
+ .limit(1);
+ return executionErrorFromEvents(events);
}
async function proposalRunProject(p: Principal, runId: string | undefined) {
@@ -661,6 +744,15 @@ export async function registerHitlRoutes(app: FastifyInstance, context: V1RouteC
}
}
+function executionErrorFromEvents(events: Array) {
+ const failed = [...events].reverse().find((event) => event.type === "execution_failed");
+ const data = failed?.data;
+ if (!data || typeof data !== "object" || Array.isArray(data)) return null;
+ const error = (data as { error?: unknown }).error;
+ if (typeof error !== "string") return null;
+ return /^[a-z0-9_:-]{1,160}$/i.test(error) ? error : "execution_failed";
+}
+
function sameActor(left: unknown, right: { type: string; id: string }) {
return (
typeof left === "object" &&
diff --git a/services/api/src/routes/v1/kb-tasks.ts b/services/api/src/routes/v1/kb-tasks.ts
index dcaabf83..97a83a8b 100644
--- a/services/api/src/routes/v1/kb-tasks.ts
+++ b/services/api/src/routes/v1/kb-tasks.ts
@@ -19,6 +19,7 @@ import { artifactIdFor, validate } from "@facility/harness";
import { and, asc, desc, eq, ilike, or } from "drizzle-orm";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
+import { withBuilderPlanPreflight } from "../../builder-plan-policy.js";
import { ApiError, notFound } from "../../errors.js";
import {
ensureActive,
@@ -1137,31 +1138,46 @@ export async function registerKbTasksRoutes(app: FastifyInstance, context: V1Rou
if (!owner) {
throw new ApiError(400, "no_owner_agent", "Project has no enabled project-owner agent");
}
+ const trigger = {
+ type: "kb_intake",
+ entryId: entry.id,
+ artifactId,
+ source: body.source,
+ title: body.title,
+ instruction:
+ "A new capture landed in the KB. Read it, read the pipeline and the active decisions, then propose the backlog and decision changes it implies — one proposal per change, citing " +
+ artifactId +
+ " as evidence.",
+ };
const run = (
- await db
- .insert(runs)
- .values({
- id: newId("run"),
+ await withBuilderPlanPreflight(
+ db,
+ {
orgId: p.orgId,
projectId,
- agentDefId: owner.id,
mode: owner.name,
- engine: owner.engine,
- trigger: {
- type: "kb_intake",
- entryId: entry.id,
- artifactId,
- source: body.source,
- title: body.title,
- instruction:
- "A new capture landed in the KB. Read it, read the pipeline and the active decisions, then propose the backlog and decision changes it implies — one proposal per change, citing " +
- artifactId +
- " as evidence.",
- },
- gh: {},
- createdBy: { type: p.type, id: p.id },
- })
- .returning()
+ agentDefId: owner.id,
+ trigger,
+ actor: { type: p.type, id: p.id },
+ source: "kb_intake_dispatch",
+ },
+ (tx, admission) =>
+ tx
+ // builder-plan-preflight: kb_intake_dispatch
+ .insert(runs)
+ .values({
+ id: newId("run"),
+ orgId: p.orgId,
+ projectId,
+ agentDefId: owner.id,
+ mode: admission.mode,
+ engine: owner.engine,
+ trigger,
+ gh: {},
+ createdBy: { type: p.type, id: p.id },
+ })
+ .returning(),
+ )
)[0];
if (!run) throw new ApiError(500, "insert_failed", "Could not dispatch the review run");
await db.insert(runEvents).values({
diff --git a/services/api/src/routes/v1/projects-repos.ts b/services/api/src/routes/v1/projects-repos.ts
index 53c0d3df..8ff90ffb 100644
--- a/services/api/src/routes/v1/projects-repos.ts
+++ b/services/api/src/routes/v1/projects-repos.ts
@@ -4,17 +4,21 @@ import {
analysisSandboxProfileId,
builderSandboxProfileId,
defaultSandboxProfileId,
+ type FacilityDb,
githubInstallations,
projects,
registryItems,
repos,
+ runs,
sandboxProfiles,
withOrg,
} from "@facility/db";
-import { and, asc, eq, isNull } from "drizzle-orm";
+import { and, asc, eq, isNull, notInArray } from "drizzle-orm";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
+import { lockBuilderPlanPolicy } from "../../builder-plan-policy.js";
import { ApiError, notFound } from "../../errors.js";
+import { laneFor } from "../../github/agent-routing.js";
import { createGithubClientFactory } from "../../github/client.js";
import { ensureProjectKbSpace } from "../../harness.js";
import { projectHealth } from "../../watchtower/health.js";
@@ -72,6 +76,7 @@ export async function registerProjectsReposRoutes(app: FastifyInstance, context:
name: z.string(),
slug: z.string(),
description: z.string().optional(),
+ builderPlanPolicy: z.enum(["optional", "required"]).optional(),
settings: AnyObject.optional(),
}),
response: { 200: ProjectSchema },
@@ -83,6 +88,7 @@ export async function registerProjectsReposRoutes(app: FastifyInstance, context:
name: string;
slug: string;
description?: string;
+ builderPlanPolicy?: "optional" | "required";
settings?: Record;
};
const project = (
@@ -94,6 +100,7 @@ export async function registerProjectsReposRoutes(app: FastifyInstance, context:
name: body.name,
slug: body.slug,
description: body.description,
+ builderPlanPolicy: body.builderPlanPolicy ?? "optional",
settings: body.settings ?? { default_branch: "main", check_cmds: [] },
})
.returning()
@@ -151,6 +158,7 @@ export async function registerProjectsReposRoutes(app: FastifyInstance, context:
name: z.string().optional(),
description: z.string().optional(),
status: z.string().optional(),
+ builderPlanPolicy: z.enum(["optional", "required"]).optional(),
settings: AnyObject.optional(),
}),
response: { 200: ProjectSchema },
@@ -160,21 +168,41 @@ export async function registerProjectsReposRoutes(app: FastifyInstance, context:
const p = principal(request);
const { projectId } = request.params as { projectId: string };
assertProjectScope(p, projectId);
- return (
- await db
- .update(projects)
- .set(
- definedFields({
- name: (request.body as { name?: string }).name,
- description: (request.body as { description?: string }).description,
- status: (request.body as { status?: string }).status,
- settings: (request.body as { settings?: Record }).settings,
- updatedAt: new Date(),
- }),
- )
- .where(and(eq(projects.orgId, p.orgId), eq(projects.id, projectId)))
- .returning()
- )[0];
+ const requestedPolicy = (request.body as { builderPlanPolicy?: "optional" | "required" })
+ .builderPlanPolicy;
+ return db.transaction(async (transaction) => {
+ const tx = transaction as unknown as FacilityDb;
+ await lockBuilderPlanPolicy(tx, p.orgId, projectId);
+ const currentProject = (
+ await tx
+ .select({ policy: projects.builderPlanPolicy })
+ .from(projects)
+ .where(and(eq(projects.orgId, p.orgId), eq(projects.id, projectId)))
+ .limit(1)
+ )[0];
+ if (!currentProject) throw notFound("Project not found");
+ if (requestedPolicy === "required" && currentProject.policy !== "required") {
+ await assertPlatformBuilderLanes(tx, p.orgId, projectId);
+ await assertNoActiveRuns(tx, p.orgId, projectId);
+ }
+ return (
+ await tx
+ .update(projects)
+ .set(
+ definedFields({
+ name: (request.body as { name?: string }).name,
+ description: (request.body as { description?: string }).description,
+ status: (request.body as { status?: string }).status,
+ builderPlanPolicy: (request.body as { builderPlanPolicy?: "optional" | "required" })
+ .builderPlanPolicy,
+ settings: (request.body as { settings?: Record }).settings,
+ updatedAt: new Date(),
+ }),
+ )
+ .where(and(eq(projects.orgId, p.orgId), eq(projects.id, projectId)))
+ .returning()
+ )[0];
+ });
},
);
@@ -251,36 +279,73 @@ export async function registerProjectsReposRoutes(app: FastifyInstance, context:
description?: string;
autoInit: boolean;
};
- const creation = body.create === true || body.mode === "create";
- const installation = await loadGithubInstallation(p.orgId, body.owner);
- const githubRepo = creation
- ? await createGithubRepository({
- installationId: installation.installationId,
- owner: body.owner,
- name: body.name,
- description: body.description,
- private: body.private,
- autoInit: body.autoInit,
- })
- : await loadGithubRepository({
- installationId: installation.installationId,
- owner: body.owner,
- name: body.name,
- });
- const row = (
+ const project = (
await db
- .insert(repos)
- .values({
- id: newId("repo"),
- orgId: p.orgId,
- projectId,
- installationId: installation.id,
- owner: githubRepo.owner,
- name: githubRepo.name,
- defaultBranch: githubRepo.defaultBranch ?? body.defaultBranch,
- })
- .returning()
+ .select({ policy: projects.builderPlanPolicy })
+ .from(projects)
+ .where(and(eq(projects.orgId, p.orgId), eq(projects.id, projectId)))
+ .limit(1)
)[0];
+ if (!project) throw notFound("Project not found");
+ if (project.policy === "required") {
+ throw new ApiError(
+ 409,
+ "builder_plan_platform_lane_required",
+ "Set Builder plan policy to optional before connecting a repository; configure its Builder lane as platform, then re-enable required",
+ );
+ }
+ const creation = body.create === true || body.mode === "create";
+ const installation = await loadGithubInstallation(p.orgId, body.owner);
+ const row = await db.transaction(async (transaction) => {
+ const tx = transaction as unknown as FacilityDb;
+ await lockBuilderPlanPolicy(tx, p.orgId, projectId);
+ const currentProject = (
+ await tx
+ .select({ policy: projects.builderPlanPolicy })
+ .from(projects)
+ .where(and(eq(projects.orgId, p.orgId), eq(projects.id, projectId)))
+ .limit(1)
+ )[0];
+ if (!currentProject) throw notFound("Project not found");
+ if (currentProject.policy === "required") {
+ throw new ApiError(
+ 409,
+ "builder_plan_platform_lane_required",
+ "Set Builder plan policy to optional before connecting a repository; configure its Builder lane as platform, then re-enable required",
+ );
+ }
+ // Keep the project lock through the remote create/load and local insert.
+ // Activation cannot slip between the final policy read and the new,
+ // initially-unverified repository row becoming visible.
+ const githubRepo = creation
+ ? await createGithubRepository({
+ installationId: installation.installationId,
+ owner: body.owner,
+ name: body.name,
+ description: body.description,
+ private: body.private,
+ autoInit: body.autoInit,
+ })
+ : await loadGithubRepository({
+ installationId: installation.installationId,
+ owner: body.owner,
+ name: body.name,
+ });
+ return (
+ await tx
+ .insert(repos)
+ .values({
+ id: newId("repo"),
+ orgId: p.orgId,
+ projectId,
+ installationId: installation.id,
+ owner: githubRepo.owner,
+ name: githubRepo.name,
+ defaultBranch: githubRepo.defaultBranch ?? body.defaultBranch,
+ })
+ .returning()
+ )[0];
+ });
if (row) {
await app.enqueue("github.issues-sync", { repoId: row.id, orgId: p.orgId });
}
@@ -288,6 +353,64 @@ export async function registerProjectsReposRoutes(app: FastifyInstance, context:
},
);
+ async function assertPlatformBuilderLanes(
+ database: FacilityDb,
+ orgId: string,
+ projectId: string,
+ ) {
+ const projectRepos = await database
+ .select()
+ .from(repos)
+ .where(and(eq(repos.orgId, orgId), eq(repos.projectId, projectId)));
+ const now = Date.now();
+ const incompatible = projectRepos.filter(
+ (repo) =>
+ laneFor(repo, "builder") !== "platform" ||
+ laneFor(repo, "codex-builder") !== "platform" ||
+ !repo.fingerprint ||
+ repo.fingerprintStatus !== "ok" ||
+ !repo.fingerprintVerifiedAt ||
+ now - repo.fingerprintVerifiedAt.getTime() > 5 * 60_000,
+ );
+ if (incompatible.length > 0) {
+ throw new ApiError(
+ 409,
+ "builder_plan_platform_lane_required",
+ "Builder plan policy required needs a recent verified default-branch Facility fingerprint and platform lanes for /builder and /codex-builder in every connected repository",
+ {
+ repos: incompatible.map((repo) => ({
+ repo: `${repo.owner}/${repo.name}`,
+ fingerprintStatus: repo.fingerprintStatus,
+ fingerprintVerifiedAt: repo.fingerprintVerifiedAt?.toISOString() ?? null,
+ })),
+ },
+ );
+ }
+ }
+
+ async function assertNoActiveRuns(database: FacilityDb, orgId: string, projectId: string) {
+ const active = await database
+ .select({
+ id: runs.id,
+ })
+ .from(runs)
+ .where(
+ and(
+ eq(runs.orgId, orgId),
+ eq(runs.projectId, projectId),
+ notInArray(runs.status, ["succeeded", "failed", "canceled"]),
+ ),
+ );
+ if (active.length > 0) {
+ throw new ApiError(
+ 409,
+ "builder_plan_active_runs_present",
+ "Wait for every existing run to finish before enabling the required plan policy",
+ { runIds: active.map((run) => run.id) },
+ );
+ }
+ }
+
async function loadGithubInstallation(orgId: string, owner: string) {
const installation = await findGithubInstallation(orgId, owner);
if (!installation) {
@@ -393,9 +516,15 @@ export async function registerProjectsReposRoutes(app: FastifyInstance, context:
async (request) => {
const p = principal(request);
const { projectId, repoId } = request.params as { projectId: string; repoId: string };
- await db
- .delete(repos)
- .where(and(eq(repos.orgId, p.orgId), eq(repos.projectId, projectId), eq(repos.id, repoId)));
+ await db.transaction(async (transaction) => {
+ const tx = transaction as unknown as FacilityDb;
+ await lockBuilderPlanPolicy(tx, p.orgId, projectId);
+ await tx
+ .delete(repos)
+ .where(
+ and(eq(repos.orgId, p.orgId), eq(repos.projectId, projectId), eq(repos.id, repoId)),
+ );
+ });
return { ok: true };
},
);
diff --git a/services/api/src/routes/v1/runs.ts b/services/api/src/routes/v1/runs.ts
index 54e5c5b1..ac1dec28 100644
--- a/services/api/src/routes/v1/runs.ts
+++ b/services/api/src/routes/v1/runs.ts
@@ -14,6 +14,7 @@ import { and, desc, eq, notInArray, sql } from "drizzle-orm";
import type { FastifyInstance, FastifyReply } from "fastify";
import postgres from "postgres";
import { z } from "zod";
+import { withBuilderPlanPreflight } from "../../builder-plan-policy.js";
import { readTranscriptObject } from "../../envelopes.js";
import { ApiError, notFound } from "../../errors.js";
import { cancelRun } from "../../sandbox/orchestrator.js";
@@ -216,16 +217,16 @@ export async function registerRunsRoutes(app: FastifyInstance, context: V1RouteC
);
}
const agent = await resolveRunAgentDef(p.orgId, projectId, body);
- const trigger = validatedRunTrigger(agent.name, body.trigger);
- if ("githubLogin" in trigger) {
- delete trigger.githubLogin;
- if (p.githubLogin) trigger.githubLogin = p.githubLogin;
+ const policyTrigger = { ...(body.trigger ?? {}) };
+ if ("githubLogin" in policyTrigger) {
+ delete policyTrigger.githubLogin;
+ if (p.githubLogin) policyTrigger.githubLogin = p.githubLogin;
}
// Preserve issue provenance across generic dispatch (retries pass the
// source run's trigger): without this, a retried issue-run loses its
// gh linkage and disappears from the issue's history and the pipeline.
- const triggerRepo = trigger.repo as { owner?: unknown; name?: unknown } | undefined;
- const triggerIssue = trigger.issue as { number?: unknown } | undefined;
+ const triggerRepo = policyTrigger.repo as { owner?: unknown; name?: unknown } | undefined;
+ const triggerIssue = policyTrigger.issue as { number?: unknown } | undefined;
const gh =
typeof triggerRepo?.owner === "string" &&
typeof triggerRepo?.name === "string" &&
@@ -233,26 +234,48 @@ export async function registerRunsRoutes(app: FastifyInstance, context: V1RouteC
? { owner: triggerRepo.owner, repo: triggerRepo.name, issueNumber: triggerIssue.number }
: {};
const run = (
- await db
- .insert(runs)
- .values({
- id: newId("run"),
+ await withBuilderPlanPreflight(
+ db,
+ {
orgId: p.orgId,
projectId,
- agentDefId: agent.id,
- // The selected agent owns its governed role. Caller labels such as
- // "manual" must not bypass role-specific progress, delivery, receipt,
- // or read-only invariants in the runner.
mode: agent.name,
- // The agent definition owns execution engine selection. Persisting a
- // caller-supplied default made CLI/MCP-triggered Claude/BYO runs look
- // like Codex runs even though orchestration correctly used the agent.
- engine: agent.engine,
- trigger,
- gh,
- createdBy: { type: p.type, id: p.id },
- })
- .returning()
+ agentDefId: agent.id,
+ trigger: policyTrigger,
+ actor: auditActor(p),
+ source: "rest_run",
+ },
+ (tx, admission) => {
+ // Required Builder governance is evaluated before the legacy
+ // objective validation. This keeps the stable Gate 1 denial (and
+ // no-row invariant) authoritative for every Builder request while
+ // optional projects retain the existing objective error.
+ const trigger = validatedRunTrigger(admission.mode, policyTrigger);
+ return (
+ tx
+ // builder-plan-preflight: rest_run
+ .insert(runs)
+ .values({
+ id: newId("run"),
+ orgId: p.orgId,
+ projectId,
+ agentDefId: agent.id,
+ // Persist the role admitted under the project lock. Builder
+ // classification cannot later disappear when an agent's name or
+ // command triggers are edited.
+ mode: admission.mode,
+ // The agent definition owns execution engine selection. Persisting a
+ // caller-supplied default made CLI/MCP-triggered Claude/BYO runs look
+ // like Codex runs even though orchestration correctly used the agent.
+ engine: agent.engine,
+ trigger,
+ gh,
+ createdBy: { type: p.type, id: p.id },
+ })
+ .returning()
+ );
+ },
+ )
)[0];
if (!run) throw new ApiError(500, "insert_failed", "Could not create run");
await db.insert(runEvents).values({
@@ -296,25 +319,40 @@ export async function registerRunsRoutes(app: FastifyInstance, context: V1RouteC
"Consult dispatches read-only architect agents only",
);
}
+ const trigger = {
+ type: "consult",
+ question: body.question,
+ requestedBy: { type: p.type, id: p.id },
+ };
const run = (
- await db
- .insert(runs)
- .values({
- id: newId("run"),
+ await withBuilderPlanPreflight(
+ db,
+ {
orgId: p.orgId,
projectId,
- agentDefId: agent.id,
mode: agent.name,
- engine: agent.engine,
- trigger: {
- type: "consult",
- question: body.question,
- requestedBy: { type: p.type, id: p.id },
- },
- gh: {},
- createdBy: { type: p.type, id: p.id },
- })
- .returning()
+ agentDefId: agent.id,
+ trigger,
+ actor: auditActor(p),
+ source: "rest_consult",
+ },
+ (tx, admission) =>
+ tx
+ // builder-plan-preflight: rest_consult
+ .insert(runs)
+ .values({
+ id: newId("run"),
+ orgId: p.orgId,
+ projectId,
+ agentDefId: agent.id,
+ mode: admission.mode,
+ engine: agent.engine,
+ trigger,
+ gh: {},
+ createdBy: { type: p.type, id: p.id },
+ })
+ .returning(),
+ )
)[0];
if (!run) throw new ApiError(500, "insert_failed", "Could not create run");
await db.insert(runEvents).values({
@@ -773,20 +811,35 @@ export async function registerRunsRoutes(app: FastifyInstance, context: V1RouteC
const trigger: Record = { type: "resume", resumeOf: parent.id };
if (body.message !== undefined) trigger.message = body.message;
const run = (
- await db
- .insert(runs)
- .values({
- id: newId("run"),
+ await withBuilderPlanPreflight(
+ db,
+ {
orgId: p.orgId,
projectId: parent.projectId,
- agentDefId: parent.agentDefId,
mode: parent.mode,
- engine: parent.engine,
+ agentDefId: parent.agentDefId,
trigger,
gh,
- createdBy: { type: p.type, id: p.id },
- })
- .returning()
+ actor: auditActor(p),
+ source: "rest_resume",
+ },
+ (tx, admission) =>
+ tx
+ // builder-plan-preflight: rest_resume_run
+ .insert(runs)
+ .values({
+ id: newId("run"),
+ orgId: p.orgId,
+ projectId: parent.projectId,
+ agentDefId: parent.agentDefId,
+ mode: admission.mode,
+ engine: parent.engine,
+ trigger,
+ gh,
+ createdBy: { type: p.type, id: p.id },
+ })
+ .returning(),
+ )
)[0];
if (!run) throw new ApiError(500, "run_create_failed", "Run could not be created");
await db.insert(runEvents).values({
@@ -823,6 +876,7 @@ function parentGhForResume(value: unknown) {
if (typeof gh.owner === "string") out.owner = gh.owner;
if (typeof gh.repo === "string") out.repo = gh.repo;
if (typeof gh.branch === "string") out.branch = gh.branch;
+ if (typeof gh.issueNumber === "number") out.issueNumber = gh.issueNumber;
return out;
}
diff --git a/services/api/src/routes/v1/shared.ts b/services/api/src/routes/v1/shared.ts
index f1368cb9..656cc7a3 100644
--- a/services/api/src/routes/v1/shared.ts
+++ b/services/api/src/routes/v1/shared.ts
@@ -139,6 +139,7 @@ export const ProjectSchema = z.object({
slug: z.string(),
description: z.string().nullable(),
systemVersion: z.string(),
+ builderPlanPolicy: z.enum(["optional", "required"]),
settings: AnyObject,
status: z.string(),
createdAt: DateValue,
@@ -202,6 +203,7 @@ export const RunSchema = z.object({
engineSessionId: z.string().nullable(),
transcriptUri: z.string().nullable(),
sessionStateUri: z.string().nullable(),
+ workspaceBaseSha: z.string().nullable(),
error: z.string().nullable(),
queuedAt: DateValue,
startedAt: DateValue.nullable(),
@@ -261,6 +263,8 @@ export const ProposalSchema = z.object({
payload: AnyObject,
contextMd: z.string(),
state: z.string(),
+ /** Stable executor failure code when the latest execution attempt failed. */
+ executionError: z.string().nullable().optional(),
decidedBy: z.string().nullable(),
decidedAt: DateValue.nullable(),
expiresAt: DateValue,
diff --git a/services/api/src/sandbox/orchestrator.ts b/services/api/src/sandbox/orchestrator.ts
index f4b653da..8d93f2dc 100644
--- a/services/api/src/sandbox/orchestrator.ts
+++ b/services/api/src/sandbox/orchestrator.ts
@@ -1,3 +1,4 @@
+import { createHash } from "node:crypto";
import {
allowedModelsForEngine,
type FacilityReceipt,
@@ -23,6 +24,7 @@ import {
kbSpaces,
llmRequests,
outcomes,
+ platformIssues,
projects,
proposalEvents,
proposals,
@@ -37,13 +39,36 @@ import {
sandboxProfiles,
virtualKeys,
} from "@facility/db";
-import { and, desc, eq, inArray, isNull, notInArray, or, sql } from "drizzle-orm";
+import { isBuilderMode } from "@facility/run-objective";
+import { and, asc, desc, eq, inArray, isNull, notInArray, or, sql } from "drizzle-orm";
+import {
+ type BuilderPlanFreshnessOptions,
+ resolveBuilderPlanFreshnessForRun,
+} from "../builder-plan-freshness.js";
+import {
+ assertBuilderPlanDispatch,
+ builderPlanDenialCode,
+ builderPlanRequired,
+ recordBuilderPlanDenial,
+ withBuilderPlanPreflight,
+} from "../builder-plan-policy.js";
+import { ApiError } from "../errors.js";
+import {
+ architectPlanPublicationKey,
+ architectPlanPublicationMarker,
+ effectiveArchitectPlanProposalState,
+ findArchitectPlanPublicationComment,
+ isGithubNotFound,
+ renderClosedArchitectPlanPublication,
+ rotateArchitectPlanPublicationOrgIds,
+} from "../github/architect-plan-publication.js";
import {
createGithubClientFactory,
FacilityGithubClient,
type GithubClientFactory,
} from "../github/client.js";
import { pullRequestBodyForIssue } from "../github/closing-issues.js";
+import { githubIssueRevisionSha256 } from "../github/issue-revision.js";
import {
type GithubRunProgressPhase,
progressCommentId,
@@ -84,12 +109,61 @@ type FinishRunDeps = {
config?: AppConfig;
githubClientFactory?: GithubClientFactory;
enqueue?: (queue: string, data: Record) => Promise;
+ /** Test seam for proving terminal run + proposal commit atomicity. */
+ afterArchitectPlanOutboxWrite?: () => Promise | void;
};
type DispatchRunDeps = {
sandboxDriver?: (name: SandboxDriverName) => Promise;
+ githubFactory?: GithubClientFactory;
+ githubClient?: BuilderPlanFreshnessOptions["githubClient"];
+};
+
+type ArchitectPlanPublicationJob = {
+ proposalId?: string;
+ orgId?: string;
};
const RESUME_FALLBACK_SCOPE_MAX_BYTES = 32 * 1024;
+const ARCHITECT_PLAN_PUBLICATION_LIMIT = 25;
+const ARCHITECT_PLAN_PUBLICATION_TIMEOUT_MS = 20_000;
+
+function architectPlanPublicationBaseEligibility(now: Date) {
+ return and(
+ eq(actionTypes.name, "plan_acceptance"),
+ eq(runs.status, "succeeded"),
+ sql`(${runs.mode} = 'architect' or ${runs.mode} like '%-architect')`,
+ sql`(
+ ${platformIssues.id} is null
+ or ${platformIssues.state} = 'resolved'
+ or ${platformIssues.lastSeen} <= ${now.toISOString()}::timestamptz
+ - (interval '1 minute' * (1 << least(greatest(${platformIssues.count} - 1, 0), 6)))
+ )`,
+ );
+}
+
+function globalArchitectPlanPublicationEligibility(now: Date) {
+ return and(
+ architectPlanPublicationBaseEligibility(now),
+ sql`not exists (
+ select 1
+ from proposal_events publication
+ where publication.org_id = ${proposals.orgId}
+ and publication.proposal_id = ${proposals.id}
+ and publication.type in ('publication_suppressed', 'publication_closed')
+ )
+ and (
+ not exists (
+ select 1
+ from proposal_events publication
+ where publication.org_id = ${proposals.orgId}
+ and publication.proposal_id = ${proposals.id}
+ and publication.type = 'published'
+ )
+ or ${proposals.state} <> 'open'
+ or ${proposals.expiresAt} <= ${now.toISOString()}::timestamptz
+ )`,
+ );
+}
export async function dispatchRun(config: AppConfig, job: DispatchJob, deps: DispatchRunDeps = {}) {
if (!job.runId || !job.orgId) throw new Error("runs.dispatch requires runId and orgId");
@@ -99,9 +173,36 @@ export async function dispatchRun(config: AppConfig, job: DispatchJob, deps: Dis
// (failRun revokes by persisted sandbox, which wouldn't yet carry these ids).
const createdKeys: RunSandboxState = {};
let launchedSandbox: { driver: SandboxDriver; ref: string } | undefined;
+ let run: RunRow | undefined;
+ let freshnessFailureSource: "worker_initial_freshness" | "worker_claimed_freshness" | null = null;
try {
- const run = await loadRun(db, job.orgId, job.runId);
+ run = await loadRun(db, job.orgId, job.runId);
if (run?.status !== "queued") return;
+ const requiredBuilderPlan =
+ isBuilderMode(run.mode) && (await builderPlanRequired(db, run.orgId, run.projectId));
+ const requiredPlanFreshness =
+ requiredBuilderPlan && objectOrEmpty(run.trigger).source === "plan_acceptance";
+ freshnessFailureSource = requiredPlanFreshness ? "worker_initial_freshness" : null;
+ const initialFreshness = requiredPlanFreshness
+ ? await resolveBuilderPlanFreshnessForRun(db, run, {
+ config,
+ githubFactory: deps.githubFactory,
+ githubClient: deps.githubClient,
+ })
+ : undefined;
+ freshnessFailureSource = null;
+ await assertBuilderPlanDispatch(db, {
+ orgId: run.orgId,
+ projectId: run.projectId,
+ mode: run.mode,
+ agentDefId: run.agentDefId,
+ trigger: run.trigger,
+ gh: run.gh,
+ runId: run.id,
+ actor: { type: "system", id: "runs.dispatch" },
+ source: "worker_dispatch",
+ freshnessEvidence: initialFreshness,
+ });
// Claim the run atomically. If a duplicate queue delivery raced us and
// another worker already moved it out of "queued", the update touches no
// rows and we must NOT launch a second sandbox for the same run.
@@ -111,9 +212,72 @@ export async function dispatchRun(config: AppConfig, job: DispatchJob, deps: Dis
.where(and(eq(runs.orgId, run.orgId), eq(runs.id, run.id), eq(runs.status, "queued")))
.returning({ id: runs.id });
if (claimed.length === 0) return;
+ // Re-evaluate after the atomic claim. The project policy or mutable agent
+ // definition may change between the producer preflight and queue delivery;
+ // claiming first prevents another worker racing ahead while this final
+ // check fails. The outer failure boundary marks the row failed before any
+ // credential or sandbox side effect is created.
+ freshnessFailureSource = requiredPlanFreshness ? "worker_claimed_freshness" : null;
+ const claimedFreshness = requiredPlanFreshness
+ ? await resolveBuilderPlanFreshnessForRun(db, run, {
+ config,
+ githubFactory: deps.githubFactory,
+ githubClient: deps.githubClient,
+ })
+ : undefined;
+ freshnessFailureSource = null;
+ const claimedRunScope = { orgId: run.orgId, runId: run.id };
+ const dispatchSnapshot = await withBuilderPlanPreflight(
+ db,
+ {
+ orgId: run.orgId,
+ projectId: run.projectId,
+ mode: run.mode,
+ agentDefId: run.agentDefId,
+ trigger: run.trigger,
+ gh: run.gh,
+ runId: run.id,
+ actor: { type: "system", id: "runs.dispatch" },
+ source: "worker_claimed_dispatch",
+ freshnessEvidence: claimedFreshness,
+ },
+ async (tx, admission) => {
+ let claimedRun = await loadRun(tx, claimedRunScope.orgId, claimedRunScope.runId);
+ if (claimedRun?.status !== "provisioning") return null;
+ if (claimedRun.mode !== admission.mode) {
+ const sealed = (
+ await tx
+ .update(runs)
+ .set({ mode: admission.mode, updatedAt: new Date() })
+ .where(and(eq(runs.orgId, claimedRun.orgId), eq(runs.id, claimedRun.id)))
+ .returning()
+ )[0];
+ if (!sealed) return null;
+ claimedRun = sealed;
+ }
+ // Agent definitions are mutable. Build the complete execution snapshot
+ // while holding the same lock used by name/trigger/contract mutations so
+ // the identity that passed Gate 1 is exactly the identity launched.
+ return buildRunBundle(tx, claimedRun, config);
+ },
+ );
+ if (!dispatchSnapshot) return;
+ if (claimedFreshness) {
+ await appendRunEvents(db, run.orgId, run.id, [
+ {
+ type: "builder_plan_admitted",
+ data: {
+ proposalId: stringValue(objectOrEmpty(run.trigger).proposalId),
+ baseSha: claimedFreshness.baseSha,
+ issueRevisionSha256: claimedFreshness.issueRevisionSha256,
+ checkedAt: claimedFreshness.checkedAt,
+ },
+ },
+ ]);
+ }
await appendRunEvents(db, run.orgId, run.id, [{ type: "provisioning", data: {} }]);
- const { bundle, profile, agentPermissions } = await buildRunBundle(db, run, config);
+ const { bundle, profile, agentPermissions } = dispatchSnapshot;
const virtualKey = await generateApiKey("fvk");
await db.insert(virtualKeys).values({
id: virtualKey.id,
@@ -261,9 +425,33 @@ export async function dispatchRun(config: AppConfig, job: DispatchJob, deps: Dis
]);
await updateGithubRunProgress(db, run.id, "provisioning", { config }).catch(() => undefined);
} catch (error) {
- await failRun(db, job.orgId, job.runId, errorMessage(error), "provision_failed").catch(
- () => undefined,
- );
+ const builderPlanCode = error instanceof ApiError ? builderPlanDenialCode(error.code) : null;
+ if (builderPlanCode && freshnessFailureSource && run) {
+ await recordBuilderPlanDenial(
+ db,
+ {
+ orgId: run.orgId,
+ projectId: run.projectId,
+ mode: run.mode,
+ agentDefId: run.agentDefId,
+ trigger: run.trigger,
+ gh: run.gh,
+ runId: run.id,
+ actor: { type: "system", id: "runs.dispatch" },
+ source: freshnessFailureSource,
+ },
+ builderPlanCode,
+ stringValue(objectOrEmpty(error instanceof ApiError ? error.details : null).reason) ??
+ "freshness_resolution_failed",
+ ).catch(() => undefined);
+ }
+ await failRun(
+ db,
+ job.orgId,
+ job.runId,
+ builderPlanCode ?? errorMessage(error),
+ builderPlanCode ? "builder_plan_denied" : "provision_failed",
+ ).catch(() => undefined);
await updateGithubRunProgress(db, job.runId, "failed", { config }).catch(() => undefined);
// failRun revokes by the persisted sandbox, which on a pre-persist failure
// wouldn't carry these — so revoke every key we minted, and destroy the
@@ -288,6 +476,7 @@ export async function finishRun(
git?: {
branch?: string;
headSha?: string;
+ baseSha?: string;
changed: boolean;
pushError?: string;
pullRequestTitle?: string;
@@ -298,7 +487,18 @@ export async function finishRun(
},
deps?: FinishRunDeps,
) {
- if (terminalStatus(run.status)) return run;
+ if (terminalStatus(run.status)) {
+ // Current writes commit Architect success and its Gate 1 proposal together.
+ // Keep this repair path for legacy/incomplete rows and missing canonical
+ // origin events; network delivery remains the cron reconciler's job.
+ if (run.status === "succeeded" && isArchitectMode(run.mode)) {
+ const receipt = FacilityReceiptSchema.safeParse(run.receipt);
+ if (receipt.success && verifyFacilityReceipt(receipt.data)) {
+ await openArchitectPlanAcceptance(db, run, receipt.data);
+ }
+ }
+ return run;
+ }
// A harness run that succeeds must leave the KB valid. If the checkpoint
// fails, the run is a FAILURE — but resources must still be reclaimed, so we
// downgrade status here and fall through to cleanup rather than throwing and
@@ -347,8 +547,19 @@ export async function finishRun(
}
const sandbox = readSandbox(run.sandbox);
const aggregate = await gatewayAggregate(db, run.id);
- let receipt = await canonicalRunReceipt(db, run, input.receipt, aggregate, status);
- const claimed = await db.transaction(async (tx) => {
+ // A delivery receipt names the published range. Runs without a delivery
+ // still expose the prepared workspace base they actually inspected.
+ const receiptBaseSha = input.git?.baseSha ?? run.workspaceBaseSha;
+ let receipt = await canonicalRunReceipt(
+ db,
+ run,
+ input.receipt,
+ aggregate,
+ status,
+ receiptBaseSha,
+ );
+ const claim = await db.transaction(async (transaction) => {
+ const tx = transaction as unknown as ReturnType["db"];
const terminal = (
await tx
.update(runs)
@@ -362,9 +573,10 @@ export async function finishRun(
sandbox: { ...sandbox, finishedAt: new Date().toISOString() },
updatedAt: new Date(),
})
- // The terminal status and durable delivery intent are one commit. A
- // process crash can therefore never strand a successfully pushed
- // branch without the metadata needed to publish its pull request.
+ // Terminal status, durable PR delivery intent, and the Architect Gate
+ // 1 proposal/open event commit together. A process crash can therefore
+ // strand neither a pushed branch nor a succeeded GitHub Architect run
+ // without the durable metadata its delivery reconciler needs.
.where(and(eq(runs.id, run.id), notInArray(runs.status, [...TERMINAL_RUN_STATUSES])))
.returning()
)[0];
@@ -372,9 +584,15 @@ export async function finishRun(
if (status === "succeeded" && deliveryPlan) {
await tx.insert(runDeliveries).values(deliveryPlan);
}
- return terminal;
+ const architectProposal =
+ status === "succeeded" && isArchitectMode(terminal.mode)
+ ? await ensureArchitectPlanAcceptance(tx, terminal, receipt)
+ : undefined;
+ if (architectProposal) await deps?.afterArchitectPlanOutboxWrite?.();
+ return { run: terminal, architectProposalId: architectProposal?.id };
});
- if (!claimed) return run;
+ if (!claim) return run;
+ const claimed = claim.run;
if (sandbox.driver && sandbox.ref) {
const driver = await sandboxDriver(sandbox.driver);
const destroyed = await driver
@@ -411,29 +629,20 @@ export async function finishRun(
) {
await recordRunPullRequestUpdate(db, claimed, input.git.branch, input.git.headSha);
}
- if (status === "succeeded" && isArchitectMode(run.mode)) {
+ if (status === "succeeded" && claim.architectProposalId) {
try {
- await openArchitectPlanAcceptance(db, claimed, receipt, deps);
+ await publishArchitectPlanAcceptance(db, run.orgId, claim.architectProposalId, deps);
} catch (planError) {
- const message = errorMessage(planError);
- status = "failed";
- error = `plan_publication_failed:${message}`;
- receipt = await canonicalRunReceipt(db, run, input.receipt, aggregate, status);
- await db
- .update(runs)
- .set({ status, receipt, error, updatedAt: new Date() })
- .where(and(eq(runs.orgId, run.orgId), eq(runs.id, run.id)));
- await appendRunEvents(db, run.orgId, run.id, [
- { type: "artifact_error", data: { kind: "plan_publication_failed", error: message } },
- ]);
- await raisePlatformIssue(db, {
+ // The sealed Architect work and canonical proposal are already durable.
+ // A transient GitHub publication failure is an artifact-delivery error,
+ // not a reason to rewrite a succeeded receipt as failed. The scheduled
+ // reconciler and terminal finish retry re-enter the idempotent publisher.
+ await recordArchitectPlanPublicationFailure(db, {
orgId: run.orgId,
projectId: run.projectId,
- kind: "plan_publication_failed",
- severity: "error",
- fingerprint: `plan_publication_failed:${run.id}`,
- title: "Failed to publish architect plan",
- bodyMd: `Architect run ${run.id} completed, but Facility could not publish its plan and human approval gate.\n\n${message}`,
+ runId: run.id,
+ proposalId: claim.architectProposalId,
+ error: planError,
});
}
}
@@ -459,7 +668,14 @@ export async function finishRun(
const message = errorMessage(syncError);
status = "failed";
error = `security_issue_sync_failed:${message}`;
- receipt = await canonicalRunReceipt(db, run, input.receipt, aggregate, status);
+ receipt = await canonicalRunReceipt(
+ db,
+ run,
+ input.receipt,
+ aggregate,
+ status,
+ receiptBaseSha,
+ );
await db
.update(runs)
.set({ status, receipt, error, updatedAt: new Date() })
@@ -479,7 +695,12 @@ export async function finishRun(
}
}
await appendRunEvents(db, run.orgId, run.id, [{ type: "result", data: { status, error } }]);
- await updateGithubRunProgress(db, run.id, status, deps).catch(() => undefined);
+ // Architect Gate 1 publication owns the succeeded progress comment. A
+ // second generic update could race a publication_closed reconciliation and
+ // resurrect an approval CTA after the proposal became terminal.
+ if (!(status === "succeeded" && claim.architectProposalId)) {
+ await updateGithubRunProgress(db, run.id, status, deps).catch(() => undefined);
+ }
await insertAuditEvent(db, {
orgId: run.orgId,
projectId: run.projectId,
@@ -636,15 +857,17 @@ export async function updateGithubRunProgress(
const agentProgress = await lastAgentProgress(db, run);
const commentId = progressCommentId(run.gh);
if (!commentId) return false;
- await client.updateIssueComment(
- commentId,
- renderProgressForRun(run, phase, {
- finalText,
- agentProgress,
- proposalId: proposal?.id,
- error: phase === "failed" ? run.error : null,
- }),
- );
+ const rendered = renderProgressForRun(run, phase, {
+ finalText,
+ agentProgress,
+ proposalId: proposal?.id,
+ error: phase === "failed" ? run.error : null,
+ });
+ const body =
+ phase === "succeeded" && proposal
+ ? `${rendered}\n\n${architectPlanPublicationMarker(run.id, proposal.id)}`
+ : rendered;
+ await client.updateIssueComment(commentId, body);
return true;
}
@@ -687,7 +910,23 @@ async function openArchitectPlanAcceptance(
db: ReturnType["db"],
run: RunRow,
receipt: FacilityReceipt,
- deps?: FinishRunDeps,
+) {
+ await db.transaction(async (transaction) =>
+ ensureArchitectPlanAcceptance(
+ transaction as unknown as ReturnType["db"],
+ run,
+ receipt,
+ ),
+ );
+ // Terminal retries repair only the durable DB outbox. Network publication is
+ // left to the scheduled reconciler so an ambiguous prior response observes
+ // durable backoff instead of immediately creating a second comment.
+}
+
+async function ensureArchitectPlanAcceptance(
+ db: ReturnType["db"],
+ run: RunRow,
+ receipt: FacilityReceipt,
) {
const gh = objectOrEmpty(run.gh);
const issueNumber = numberOrUndefined(gh.issueNumber);
@@ -704,96 +943,741 @@ async function openArchitectPlanAcceptance(
.limit(1)
)[0];
if (!actionType) throw new Error("plan_acceptance_action_missing");
- const existing = (
+ const runTrigger = objectOrEmpty(run.trigger);
+ const workspaceBaseSha = gitCommitSha(run.workspaceBaseSha);
+ const issueRevisionSha256 = githubIssueRevisionSha256(runTrigger.request);
+ const canonicalPayload = {
+ architectRunId: run.id,
+ issueNumber,
+ repoId: repo.id,
+ receiptSha256: receipt.integrity?.payload_sha256,
+ planSha256: createHash("sha256").update(plan).digest("hex"),
+ ...(workspaceBaseSha ? { workspaceBaseSha } : {}),
+ ...(issueRevisionSha256 ? { issueRevisionSha256 } : {}),
+ };
+ await db.execute(
+ sql`select pg_advisory_xact_lock(hashtextextended(${`architect-plan:${run.id}`}, 0))`,
+ );
+ const candidates = await db
+ .select()
+ .from(proposals)
+ .where(
+ and(
+ eq(proposals.orgId, run.orgId),
+ eq(proposals.projectId, run.projectId),
+ eq(proposals.runId, run.id),
+ eq(proposals.actionTypeId, actionType.id),
+ ),
+ );
+ for (const existing of candidates) {
+ const payload = objectOrEmpty(existing.payload);
+ if (
+ existing.contextMd !== plan ||
+ payload.architectRunId !== canonicalPayload.architectRunId ||
+ payload.issueNumber !== canonicalPayload.issueNumber ||
+ payload.repoId !== canonicalPayload.repoId ||
+ payload.receiptSha256 !== canonicalPayload.receiptSha256 ||
+ payload.planSha256 !== canonicalPayload.planSha256 ||
+ (gitCommitSha(payload.workspaceBaseSha) ?? null) !== (workspaceBaseSha ?? null) ||
+ (sha256Digest(payload.issueRevisionSha256) ?? null) !== (issueRevisionSha256 ?? null)
+ ) {
+ continue;
+ }
+ const origin = (
+ await db
+ .select({
+ seq: proposalEvents.seq,
+ type: proposalEvents.type,
+ actor: proposalEvents.actor,
+ data: proposalEvents.data,
+ })
+ .from(proposalEvents)
+ .where(
+ and(
+ eq(proposalEvents.orgId, run.orgId),
+ eq(proposalEvents.proposalId, existing.id),
+ eq(proposalEvents.seq, 1),
+ ),
+ )
+ .limit(1)
+ )[0];
+ const originActor = objectOrEmpty(origin?.actor);
+ const originData = objectOrEmpty(origin?.data);
+ if (
+ origin &&
+ (origin.type !== "open" ||
+ originActor.type !== "agent" ||
+ originActor.id !== run.id ||
+ originData.source !== "architect_run")
+ ) {
+ continue;
+ }
+ if (!origin) {
+ await db
+ .insert(proposalEvents)
+ .values({
+ orgId: run.orgId,
+ proposalId: existing.id,
+ seq: 1,
+ type: "open",
+ actor: { type: "agent", id: run.id },
+ data: { source: "architect_run" },
+ })
+ .onConflictDoNothing();
+ }
+ return existing;
+ }
+ const created = (
await db
- .select()
+ .insert(proposals)
+ .values({
+ id: newId("prop"),
+ orgId: run.orgId,
+ projectId: run.projectId,
+ runId: run.id,
+ actionTypeId: actionType.id,
+ payload: canonicalPayload,
+ contextMd: plan,
+ expiresAt: new Date(Date.now() + actionType.defaultTtlHours * 3_600_000),
+ })
+ .returning()
+ )[0];
+ if (!created) throw new Error("plan_acceptance_create_failed");
+ await db.insert(proposalEvents).values({
+ orgId: run.orgId,
+ proposalId: created.id,
+ seq: 1,
+ type: "open",
+ actor: { type: "agent", id: run.id },
+ data: { source: "architect_run" },
+ });
+ return created;
+}
+
+export async function reconcileArchitectPlanPublications(
+ db: ReturnType["db"],
+ config: AppConfig,
+ job: ArchitectPlanPublicationJob = {},
+ githubClientFactory?: GithubClientFactory,
+ now = new Date(),
+) {
+ if ((job.proposalId && !job.orgId) || (!job.proposalId && job.orgId)) {
+ throw new Error("architect-plans.publish requires proposalId and orgId together");
+ }
+ const globalEligibility = globalArchitectPlanPublicationEligibility(now);
+ let selectedOrgIds: string[] | undefined;
+ if (!job.proposalId) {
+ const eligibleOrgs = await db
+ .selectDistinct({ orgId: proposals.orgId })
.from(proposals)
- .where(
+ .innerJoin(
+ actionTypes,
+ and(eq(actionTypes.id, proposals.actionTypeId), eq(actionTypes.orgId, proposals.orgId)),
+ )
+ .innerJoin(
+ runs,
and(
- eq(proposals.orgId, run.orgId),
- eq(proposals.projectId, run.projectId),
- eq(proposals.runId, run.id),
- eq(proposals.actionTypeId, actionType.id),
+ eq(runs.id, proposals.runId),
+ eq(runs.orgId, proposals.orgId),
+ eq(runs.projectId, proposals.projectId),
),
)
- .limit(1)
- )[0];
- const proposal =
- existing ??
- (
- await db
- .insert(proposals)
- .values({
- id: newId("prop"),
- orgId: run.orgId,
- projectId: run.projectId,
- runId: run.id,
- actionTypeId: actionType.id,
- payload: {
- architectRunId: run.id,
- issueNumber,
- repoId: repo.id,
- receiptSha256: receipt.integrity?.payload_sha256,
- },
- contextMd: plan,
- expiresAt: new Date(Date.now() + actionType.defaultTtlHours * 3_600_000),
+ .leftJoin(
+ platformIssues,
+ and(
+ eq(platformIssues.orgId, proposals.orgId),
+ eq(platformIssues.kind, "plan_publication_failed"),
+ sql`${platformIssues.fingerprint} = 'plan_publication_failed:' || ${runs.id}`,
+ ),
+ )
+ .where(globalEligibility)
+ .orderBy(asc(proposals.orgId));
+ selectedOrgIds = rotateArchitectPlanPublicationOrgIds(
+ eligibleOrgs.map((row) => row.orgId),
+ now,
+ ARCHITECT_PLAN_PUBLICATION_LIMIT,
+ );
+ if (selectedOrgIds.length === 0) return [];
+ }
+ const pending = await db
+ .select({
+ proposalId: proposals.id,
+ orgId: proposals.orgId,
+ projectId: proposals.projectId,
+ runId: proposals.runId,
+ })
+ .from(proposals)
+ .innerJoin(
+ actionTypes,
+ and(eq(actionTypes.id, proposals.actionTypeId), eq(actionTypes.orgId, proposals.orgId)),
+ )
+ .innerJoin(
+ runs,
+ and(
+ eq(runs.id, proposals.runId),
+ eq(runs.orgId, proposals.orgId),
+ eq(runs.projectId, proposals.projectId),
+ ),
+ )
+ .leftJoin(
+ platformIssues,
+ and(
+ eq(platformIssues.orgId, proposals.orgId),
+ eq(platformIssues.kind, "plan_publication_failed"),
+ sql`${platformIssues.fingerprint} = 'plan_publication_failed:' || ${runs.id}`,
+ ),
+ )
+ .where(
+ and(
+ job.proposalId ? architectPlanPublicationBaseEligibility(now) : globalEligibility,
+ job.proposalId ? eq(proposals.id, job.proposalId) : undefined,
+ job.orgId ? eq(proposals.orgId, job.orgId) : undefined,
+ selectedOrgIds ? inArray(proposals.orgId, selectedOrgIds) : undefined,
+ ),
+ )
+ .orderBy(
+ job.proposalId
+ ? asc(proposals.createdAt)
+ : sql`row_number() over (
+ partition by ${proposals.orgId}
+ order by
+ case when ${platformIssues.id} is null then 0 else 1 end,
+ ${platformIssues.lastSeen} asc nulls first,
+ ${proposals.createdAt},
+ ${proposals.id}
+ )`,
+ sql`case when ${platformIssues.id} is null then 0 else 1 end`,
+ asc(platformIssues.lastSeen),
+ asc(proposals.createdAt),
+ asc(proposals.id),
+ )
+ .limit(job.proposalId ? 1 : ARCHITECT_PLAN_PUBLICATION_LIMIT);
+
+ const results: Array<{
+ proposalId: string;
+ status: "published" | "closed" | "suppressed" | "pending";
+ }> = [];
+ for (const candidate of pending) {
+ if (!candidate.projectId || !candidate.runId) continue;
+ try {
+ const status = await publishArchitectPlanAcceptance(
+ db,
+ candidate.orgId,
+ candidate.proposalId,
+ { config, githubClientFactory },
+ );
+ results.push({ proposalId: candidate.proposalId, status });
+ } catch (error) {
+ await recordArchitectPlanPublicationFailure(db, {
+ orgId: candidate.orgId,
+ projectId: candidate.projectId,
+ runId: candidate.runId,
+ proposalId: candidate.proposalId,
+ error,
+ });
+ results.push({ proposalId: candidate.proposalId, status: "pending" });
+ }
+ }
+ return results;
+}
+
+async function publishArchitectPlanAcceptance(
+ db: ReturnType["db"],
+ orgId: string,
+ proposalId: string,
+ deps?: Pick,
+) {
+ return db.transaction(async (transaction) => {
+ const tx = transaction as unknown as ReturnType["db"];
+ await tx.execute(
+ sql`select pg_advisory_xact_lock(hashtextextended(${`architect-plan-publication:${proposalId}`}, 0))`,
+ );
+ const context = (
+ await tx
+ .select({ proposal: proposals, run: runs, actionTypeName: actionTypes.name })
+ .from(proposals)
+ .innerJoin(
+ actionTypes,
+ and(eq(actionTypes.id, proposals.actionTypeId), eq(actionTypes.orgId, proposals.orgId)),
+ )
+ .innerJoin(
+ runs,
+ and(
+ eq(runs.id, proposals.runId),
+ eq(runs.orgId, proposals.orgId),
+ eq(runs.projectId, proposals.projectId),
+ ),
+ )
+ .where(and(eq(proposals.orgId, orgId), eq(proposals.id, proposalId)))
+ .for("update", { of: proposals })
+ .limit(1)
+ )[0];
+ if (context?.actionTypeName !== "plan_acceptance") {
+ throw new Error("architect_plan_publication_missing");
+ }
+ const { proposal, run } = context;
+ if (!proposal.projectId || run.status !== "succeeded" || !isArchitectMode(run.mode)) {
+ throw new Error("architect_plan_publication_invalid_run");
+ }
+ const origin = (
+ await tx
+ .select({
+ type: proposalEvents.type,
+ actor: proposalEvents.actor,
+ data: proposalEvents.data,
})
- .returning()
+ .from(proposalEvents)
+ .where(
+ and(
+ eq(proposalEvents.orgId, orgId),
+ eq(proposalEvents.proposalId, proposal.id),
+ eq(proposalEvents.seq, 1),
+ ),
+ )
+ .limit(1)
)[0];
- if (!proposal) throw new Error("plan_acceptance_create_failed");
- if (!existing) {
- await db.insert(proposalEvents).values({
- orgId: run.orgId,
+ const originActor = objectOrEmpty(origin?.actor);
+ const originData = objectOrEmpty(origin?.data);
+ if (
+ origin?.type !== "open" ||
+ originActor.type !== "agent" ||
+ originActor.id !== run.id ||
+ originData.source !== "architect_run"
+ ) {
+ throw new Error("architect_plan_publication_invalid_origin");
+ }
+ const payload = objectOrEmpty(proposal.payload);
+ const issueNumber = numberOrUndefined(payload.issueNumber);
+ const repoId = stringValue(payload.repoId);
+ const receipt = FacilityReceiptSchema.safeParse(run.receipt);
+ const ghIssueNumber = numberOrUndefined(objectOrEmpty(run.gh).issueNumber);
+ if (
+ !issueNumber ||
+ issueNumber !== ghIssueNumber ||
+ !repoId ||
+ payload.architectRunId !== run.id ||
+ payload.planSha256 !== createHash("sha256").update(proposal.contextMd).digest("hex") ||
+ (gitCommitSha(payload.workspaceBaseSha) ?? null) !==
+ (gitCommitSha(run.workspaceBaseSha) ?? null) ||
+ (sha256Digest(payload.issueRevisionSha256) ?? null) !==
+ (githubIssueRevisionSha256(objectOrEmpty(run.trigger).request) ?? null) ||
+ !receipt.success ||
+ !verifyFacilityReceipt(receipt.data) ||
+ payload.receiptSha256 !== receipt.data.integrity?.payload_sha256
+ ) {
+ throw new Error("architect_plan_publication_invalid_context");
+ }
+ const repo = await repoForGithubRun(tx, run);
+ if (!repo || repo.id !== repoId || repo.projectId !== proposal.projectId) {
+ throw new Error("architect_plan_publication_repo_mismatch");
+ }
+ const publicationEvents = await tx
+ .select({ type: proposalEvents.type, data: proposalEvents.data })
+ .from(proposalEvents)
+ .where(
+ and(
+ eq(proposalEvents.orgId, orgId),
+ eq(proposalEvents.proposalId, proposal.id),
+ inArray(proposalEvents.type, [
+ "published",
+ "publication_suppressed",
+ "publication_closed",
+ ]),
+ ),
+ )
+ .orderBy(desc(proposalEvents.seq));
+ const published = publicationEvents.find((event) => event.type === "published");
+ const suppressed = publicationEvents.find((event) => event.type === "publication_suppressed");
+ const closed = publicationEvents.find((event) => event.type === "publication_closed");
+ const publicationFingerprint = `plan_publication_failed:${run.id}`;
+ if (closed) {
+ await resolvePlatformIssue(
+ tx,
+ orgId,
+ publicationFingerprint,
+ "Architect plan publication closed",
+ { projectId: run.projectId },
+ );
+ return "closed" as const;
+ }
+ if (suppressed) {
+ await resolvePlatformIssue(
+ tx,
+ orgId,
+ publicationFingerprint,
+ "Architect plan publication suppressed",
+ { projectId: run.projectId },
+ );
+ return "suppressed" as const;
+ }
+ // Expiry is evaluated after taking the proposal row lock, not from the
+ // sweep's selection timestamp. A long batch can therefore never publish a
+ // CTA that expired while earlier candidates were using GitHub.
+ const effective = effectiveArchitectPlanProposalState(
+ proposal.state,
+ proposal.expiresAt,
+ new Date(),
+ );
+ const effectiveOpen = effective.open;
+ const effectiveState = effective.state;
+ if (published && effectiveOpen) {
+ await resolvePlatformIssue(
+ tx,
+ orgId,
+ publicationFingerprint,
+ "Architect plan publication recovered",
+ { projectId: run.projectId },
+ );
+ return "published" as const;
+ }
+ if (!repo.installationId) throw new Error("run_repo_missing_installation");
+ const installation = (
+ await tx
+ .select()
+ .from(githubInstallations)
+ .where(
+ and(
+ eq(githubInstallations.orgId, orgId),
+ eq(githubInstallations.id, repo.installationId),
+ ),
+ )
+ .limit(1)
+ )[0];
+ if (!installation || installation.suspendedAt) throw new Error("run_installation_unavailable");
+ const factory =
+ deps?.githubClientFactory ??
+ (deps?.config?.githubAppId && deps.config.githubAppPrivateKey
+ ? createGithubClientFactory(deps.config)
+ : null);
+ if (!factory) throw new Error("github_app_unconfigured");
+ const networkDeadline = Date.now() + ARCHITECT_PLAN_PUBLICATION_TIMEOUT_MS;
+ const remote = (operation: () => Promise) =>
+ withArchitectPlanPublicationTimeout(operation, Math.max(1, networkDeadline - Date.now()));
+ const client = new FacilityGithubClient(
+ await remote(() => factory(installation.installationId)),
+ {
+ owner: repo.owner,
+ repo: repo.name,
+ defaultBranch: repo.defaultBranch,
+ },
+ );
+ const publicationKey = architectPlanPublicationKey(run.id, proposal.id);
+ const publicationMarker = architectPlanPublicationMarker(run.id, proposal.id);
+ const publishedData = objectOrEmpty(published?.data);
+ const publishedCommentId = numberOrUndefined(publishedData.commentId);
+ let listedComments: Awaited> | undefined;
+ const recoverComment = async (allowLegacy: boolean) => {
+ listedComments ??= await remote(() => client.listIssueComments(issueNumber));
+ return findArchitectPlanPublicationComment(listedComments, {
+ runId: run.id,
+ publicationMarker,
+ allowLegacy,
+ });
+ };
+ const closedBody = renderClosedArchitectPlanPublication({
+ runId: run.id,
+ plan: proposal.contextMd,
+ proposalState: effectiveState,
+ publicationMarker,
+ });
+ const closePublished = async (
+ remoteComment: { id: number; url?: string } | undefined,
+ reason?: "deleted_comment" | "legacy_comment_untracked",
+ ) => {
+ const latest = (
+ await tx
+ .select({ seq: proposalEvents.seq })
+ .from(proposalEvents)
+ .where(and(eq(proposalEvents.orgId, orgId), eq(proposalEvents.proposalId, proposal.id)))
+ .orderBy(desc(proposalEvents.seq))
+ .limit(1)
+ )[0];
+ await tx.insert(proposalEvents).values({
+ orgId,
+ proposalId: proposal.id,
+ seq: (latest?.seq ?? 0) + 1,
+ type: "publication_closed",
+ actor: { type: "agent", id: run.id },
+ data: {
+ source: "architect_run",
+ issue: issueNumber,
+ publicationKey,
+ proposalState: effectiveState,
+ commentId: remoteComment?.id ?? null,
+ commentUrl: remoteComment?.url ?? stringValue(publishedData.commentUrl) ?? null,
+ ...(reason ? { reason } : {}),
+ },
+ });
+ await resolvePlatformIssue(
+ tx,
+ orgId,
+ publicationFingerprint,
+ `Architect plan publication closed because proposal is ${effectiveState}`,
+ { projectId: run.projectId },
+ );
+ return "closed" as const;
+ };
+ if (!effectiveOpen && published) {
+ const knownCommentId = publishedCommentId ?? progressCommentId(run.gh);
+ if (knownCommentId) {
+ try {
+ await remote(() => client.updateIssueComment(knownCommentId, closedBody));
+ return closePublished({ id: knownCommentId });
+ } catch (error) {
+ if (!isGithubNotFound(error)) throw error;
+ }
+ }
+ const recovered = await recoverComment(true);
+ if (!recovered) {
+ return closePublished(
+ undefined,
+ knownCommentId ? "deleted_comment" : "legacy_comment_untracked",
+ );
+ }
+ await remote(() => client.updateIssueComment(recovered.id, closedBody));
+ return closePublished({ id: recovered.id, url: recovered.url });
+ }
+ const recovered = !effectiveOpen ? await recoverComment(false) : undefined;
+ if (!effectiveOpen && !recovered) {
+ const latest = (
+ await tx
+ .select({ seq: proposalEvents.seq })
+ .from(proposalEvents)
+ .where(and(eq(proposalEvents.orgId, orgId), eq(proposalEvents.proposalId, proposal.id)))
+ .orderBy(desc(proposalEvents.seq))
+ .limit(1)
+ )[0];
+ await tx.insert(proposalEvents).values({
+ orgId,
+ proposalId: proposal.id,
+ seq: (latest?.seq ?? 0) + 1,
+ type: "publication_suppressed",
+ actor: { type: "agent", id: run.id },
+ data: {
+ source: "architect_run",
+ issue: issueNumber,
+ publicationKey,
+ proposalState: effectiveState,
+ reason: "proposal_not_open",
+ },
+ });
+ await resolvePlatformIssue(
+ tx,
+ orgId,
+ publicationFingerprint,
+ `Architect plan publication suppressed because proposal is ${effectiveState}`,
+ { projectId: run.projectId },
+ );
+ return "suppressed" as const;
+ }
+
+ let remoteComment: { id: number; url?: string };
+ if (!effectiveOpen && recovered) {
+ // GitHub already accepted the stable marker before the proposal changed
+ // state. Replace the old CTA with a terminal snapshot, then close the
+ // ledger; never create a fresh approval surface after close/expiry.
+ await remote(() => client.updateIssueComment(recovered.id, closedBody));
+ remoteComment = { id: recovered.id, url: recovered.url };
+ } else {
+ const body = `${renderProgressForRun(run, "succeeded", {
+ finalText: proposal.contextMd,
+ agentProgress: await lastAgentProgress(tx, run),
+ proposalId: proposal.id,
+ })}\n\n${publicationMarker}`;
+ const progressId = progressCommentId(run.gh);
+ if (progressId) {
+ try {
+ await remote(() => client.updateIssueComment(progressId, body));
+ remoteComment = { id: progressId };
+ } catch (error) {
+ if (!isGithubNotFound(error)) throw error;
+ const fallback = await recoverComment(true);
+ if (fallback) {
+ await remote(() => client.updateIssueComment(fallback.id, body));
+ remoteComment = { id: fallback.id, url: fallback.url };
+ } else {
+ remoteComment = await remote(() => client.createIssueComment(issueNumber, body));
+ }
+ }
+ } else {
+ const fallback = await recoverComment(false);
+ if (fallback) {
+ await remote(() => client.updateIssueComment(fallback.id, body));
+ remoteComment = { id: fallback.id, url: fallback.url };
+ } else {
+ remoteComment = await remote(() => client.createIssueComment(issueNumber, body));
+ }
+ }
+ }
+ const latest = (
+ await tx
+ .select({ seq: proposalEvents.seq })
+ .from(proposalEvents)
+ .where(and(eq(proposalEvents.orgId, run.orgId), eq(proposalEvents.proposalId, proposal.id)))
+ .orderBy(desc(proposalEvents.seq))
+ .limit(1)
+ )[0];
+ await tx.insert(proposalEvents).values({
+ orgId,
proposalId: proposal.id,
- seq: 1,
- type: "open",
+ seq: (latest?.seq ?? 0) + 1,
+ type: "published",
actor: { type: "agent", id: run.id },
- data: { source: "architect_run" },
+ data: {
+ source: "architect_run",
+ issue: issueNumber,
+ publicationKey,
+ commentId: remoteComment.id,
+ commentUrl: remoteComment.url ?? null,
+ },
});
+ if (!effectiveOpen) {
+ await tx.insert(proposalEvents).values({
+ orgId,
+ proposalId: proposal.id,
+ seq: (latest?.seq ?? 0) + 2,
+ type: "publication_closed",
+ actor: { type: "agent", id: run.id },
+ data: {
+ source: "architect_run",
+ issue: issueNumber,
+ publicationKey,
+ proposalState: effectiveState,
+ commentId: remoteComment.id,
+ commentUrl: remoteComment.url ?? null,
+ },
+ });
+ }
+ await insertAuditEvent(tx, {
+ orgId,
+ projectId: run.projectId,
+ actor: { type: "agent", id: run.id },
+ action: "hitl.proposed",
+ target: { type: "proposal", id: proposal.id },
+ payload: {
+ action_type: "plan_acceptance",
+ issue: issueNumber,
+ publication_key: publicationKey,
+ comment_id: remoteComment.id,
+ },
+ });
+ await resolvePlatformIssue(
+ tx,
+ orgId,
+ publicationFingerprint,
+ "Architect plan publication recovered",
+ { projectId: run.projectId },
+ );
+ return effectiveOpen ? ("published" as const) : ("closed" as const);
+ });
+}
+
+async function withArchitectPlanPublicationTimeout(
+ operation: () => Promise,
+ timeoutMs: number,
+): Promise {
+ let timeout: ReturnType | undefined;
+ try {
+ return await Promise.race([
+ operation(),
+ new Promise((_, reject) => {
+ timeout = setTimeout(
+ () => reject(new Error("architect_plan_publication_timeout")),
+ timeoutMs,
+ );
+ }),
+ ]);
+ } finally {
+ if (timeout) clearTimeout(timeout);
}
- if (!repo?.installationId) throw new Error("run_repo_missing_installation");
- const installation = (
- await db
- .select()
- .from(githubInstallations)
+}
+
+async function recordArchitectPlanPublicationFailure(
+ db: ReturnType["db"],
+ input: {
+ orgId: string;
+ projectId: string;
+ runId: string;
+ proposalId: string;
+ error: unknown;
+ },
+) {
+ const message = errorMessage(input.error);
+ await db.transaction(async (transaction) => {
+ const tx = transaction as unknown as ReturnType["db"];
+ await tx.execute(
+ sql`select pg_advisory_xact_lock(hashtextextended(${`architect-plan-publication:${input.proposalId}`}, 0))`,
+ );
+ const proposal = (
+ await tx
+ .select({ state: proposals.state, expiresAt: proposals.expiresAt })
+ .from(proposals)
+ .where(and(eq(proposals.orgId, input.orgId), eq(proposals.id, input.proposalId)))
+ .for("update")
+ .limit(1)
+ )[0];
+ const outcomes = await tx
+ .select({ type: proposalEvents.type })
+ .from(proposalEvents)
.where(
and(
- eq(githubInstallations.orgId, run.orgId),
- eq(githubInstallations.id, repo.installationId),
+ eq(proposalEvents.orgId, input.orgId),
+ eq(proposalEvents.proposalId, input.proposalId),
+ inArray(proposalEvents.type, [
+ "published",
+ "publication_suppressed",
+ "publication_closed",
+ ]),
),
- )
- .limit(1)
- )[0];
- if (!installation || installation.suspendedAt) throw new Error("run_installation_unavailable");
- const factory =
- deps?.githubClientFactory ??
- (deps?.config?.githubAppId && deps.config.githubAppPrivateKey
- ? createGithubClientFactory(deps.config)
- : null);
- if (!factory) throw new Error("github_app_unconfigured");
- const client = new FacilityGithubClient(await factory(installation.installationId), {
- owner: repo.owner,
- repo: repo.name,
- defaultBranch: repo.defaultBranch,
- });
- const body = renderProgressForRun(run, "succeeded", {
- finalText: plan,
- agentProgress: await lastAgentProgress(db, run),
- proposalId: proposal.id,
- });
- const commentId = progressCommentId(run.gh);
- if (commentId) {
- await client.updateIssueComment(commentId, body);
- } else {
- await client.createIssueComment(issueNumber, body);
- }
- await insertAuditEvent(db, {
- orgId: run.orgId,
- projectId: run.projectId,
- actor: { type: "agent", id: run.id },
- action: "hitl.proposed",
- target: { type: "proposal", id: proposal.id },
- payload: { action_type: "plan_acceptance", issue: issueNumber },
+ );
+ const effectiveOpen = proposal?.state === "open" && proposal.expiresAt.getTime() > Date.now();
+ const completedOutcome =
+ outcomes.find((outcome) =>
+ ["publication_suppressed", "publication_closed"].includes(outcome.type),
+ ) ?? (effectiveOpen ? outcomes.find((outcome) => outcome.type === "published") : undefined);
+ const fingerprint = `plan_publication_failed:${input.runId}`;
+ if (completedOutcome) {
+ await resolvePlatformIssue(
+ tx,
+ input.orgId,
+ fingerprint,
+ `Architect plan publication reached terminal outcome ${completedOutcome.type}`,
+ { projectId: input.projectId },
+ );
+ return;
+ }
+ const priorFailure = (
+ await tx
+ .select({ seq: runEvents.seq })
+ .from(runEvents)
+ .where(
+ and(
+ eq(runEvents.orgId, input.orgId),
+ eq(runEvents.runId, input.runId),
+ eq(runEvents.type, "artifact_error"),
+ sql`${runEvents.data}->>'kind' = 'plan_publication_failed'`,
+ ),
+ )
+ .limit(1)
+ )[0];
+ if (!priorFailure) {
+ await appendRunEvents(tx, input.orgId, input.runId, [
+ { type: "artifact_error", data: { kind: "plan_publication_failed", error: message } },
+ ]);
+ }
+ await raisePlatformIssue(
+ tx,
+ {
+ orgId: input.orgId,
+ projectId: input.projectId,
+ kind: "plan_publication_failed",
+ severity: "error",
+ fingerprint,
+ title: "Failed to publish architect plan",
+ bodyMd: `Architect run ${input.runId} completed, but Facility could not publish its plan and human approval gate. The durable publication reconciler will retry.\n\n${message}`,
+ },
+ { projectId: input.projectId },
+ );
});
}
@@ -890,6 +1774,7 @@ async function prepareRunDelivery(
git: {
branch?: string;
headSha?: string;
+ baseSha?: string;
changed: boolean;
pushError?: string;
pullRequestTitle?: string;
@@ -967,6 +1852,7 @@ async function prepareRunDelivery(
repoName: repo.name,
headBranch: git.branch,
expectedHeadSha: git.headSha,
+ baseSha: git.baseSha,
baseBranch: repo.defaultBranch,
title: git.pullRequestTitle,
body: pullRequestBody,
@@ -1731,7 +2617,11 @@ async function buildRunBundle(
const contract = renderRunContract(rawContract, provisionSummary, checkCmds);
const githubBranch = typeof runGh.branch === "string" ? runGh.branch : null;
const checkoutBranch = githubPullRequestMode(run.mode) && githubBranch ? githubBranch : null;
- const expectedHeadSha = repairExpectedHeadSha(run.mode, run.trigger);
+ const planProvenance = objectOrEmpty(objectOrEmpty(run.trigger).planProvenance);
+ const acceptedPlanBaseSha = isBuilderMode(run.mode)
+ ? gitCommitSha(planProvenance.workspaceBaseSha)
+ : undefined;
+ const expectedHeadSha = repairExpectedHeadSha(run.mode, run.trigger) ?? acceptedPlanBaseSha;
if (run.mode.replace(/^codex-/, "").replace(/-/g, "_") === "ci_doctor" && !expectedHeadSha) {
throw new Error("ci_doctor_admitted_head_missing");
}
@@ -1767,7 +2657,7 @@ async function buildRunBundle(
? {
cloneUrl: `https://github.com/${repo.owner}/${repo.name}.git`,
branch: checkoutBranch ?? repo.defaultBranch,
- expectedHeadSha,
+ expectedHeadSha: expectedHeadSha ?? null,
installationTokenRef: repo.installationId,
}
: { cloneUrl: null, branch: null, expectedHeadSha: null, installationTokenRef: null },
@@ -2211,6 +3101,7 @@ async function canonicalRunReceipt(
runnerReceipt: Record | undefined,
aggregate: Awaited>,
status: "succeeded" | "failed" | "canceled",
+ baseSha: string | null | undefined,
): Promise {
const runner = objectOrEmpty(runnerReceipt);
const runnerTiming = objectOrEmpty(runner.timing);
@@ -2264,6 +3155,7 @@ async function canonicalRunReceipt(
repo: stringValue(gh.repo),
issue: integerValue(gh.issueNumber),
pr: integerValue(objectOrEmpty(gh.pr).number),
+ base_sha: stringValue(baseSha),
},
timing: {
started_at: stringValue(runnerTiming.started_at) ?? startedAt.toISOString(),
@@ -2335,10 +3227,6 @@ export function platformDeliveryFailure(
return null;
}
-function isBuilderMode(mode: string) {
- return mode === "builder" || mode.endsWith("-builder");
-}
-
function isArchitectMode(mode: string) {
return mode === "architect" || mode.endsWith("-architect");
}
@@ -2403,6 +3291,18 @@ function stringValue(value: unknown) {
return typeof value === "string" && value ? value : undefined;
}
+function gitCommitSha(value: unknown) {
+ return typeof value === "string" && /^[a-f0-9]{40}$/i.test(value)
+ ? value.toLowerCase()
+ : undefined;
+}
+
+function sha256Digest(value: unknown) {
+ return typeof value === "string" && /^[a-f0-9]{64}$/i.test(value)
+ ? value.toLowerCase()
+ : undefined;
+}
+
function integerValue(value: unknown) {
return typeof value === "number" && Number.isInteger(value) ? value : undefined;
}
diff --git a/services/api/src/scheduler.ts b/services/api/src/scheduler.ts
index 33f1f68d..67896b53 100644
--- a/services/api/src/scheduler.ts
+++ b/services/api/src/scheduler.ts
@@ -1,9 +1,21 @@
import { newId } from "@facility/core";
-import { agentDefs, createDb, insertAuditEvent, runEvents, runs } from "@facility/db";
+import {
+ agentDefs,
+ createDb,
+ type FacilityDb,
+ insertAuditEvent,
+ runEvents,
+ runs,
+} from "@facility/db";
// Default-import: cron-parser is CJS and its named exports aren't statically
// visible to Node's ESM loader (tsx runs the worker in real ESM mode).
import cronParser from "cron-parser";
import { and, eq, not, notInArray, sql } from "drizzle-orm";
+import {
+ assertBuilderPlanDispatch,
+ isBuilderPlanDenialError,
+ lockBuilderPlanPolicy,
+} from "./builder-plan-policy.js";
import { TERMINAL_RUN_STATUSES } from "./sandbox/state.js";
import type { AppConfig } from "./types.js";
@@ -66,17 +78,41 @@ export async function runScheduledAgents(
.limit(1)
)[0];
if (!liveRun) {
+ const runTrigger = { type: "schedule", cron: trigger.config.cron };
+ let admittedMode = agent.name;
+ try {
+ const policyTx = tx as unknown as FacilityDb;
+ await lockBuilderPlanPolicy(policyTx, agent.orgId, agent.projectId);
+ const admission = await assertBuilderPlanDispatch(policyTx, {
+ orgId: agent.orgId,
+ projectId: agent.projectId,
+ mode: agent.name,
+ agentDefId: agent.id,
+ trigger: runTrigger,
+ actor: { type: "system", id: "scheduler" },
+ source: "legacy_scheduler",
+ });
+ admittedMode = admission.mode;
+ } catch (error) {
+ if (!isBuilderPlanDenialError(error)) throw error;
+ await tx
+ .update(agentDefs)
+ .set({ lastScheduledAt: now, updatedAt: now })
+ .where(and(eq(agentDefs.orgId, agent.orgId), eq(agentDefs.id, agent.id)));
+ continue;
+ }
const run = (
await tx
+ // builder-plan-preflight: scheduled_run
.insert(runs)
.values({
id: newId("run"),
orgId: agent.orgId,
projectId: agent.projectId,
agentDefId: agent.id,
- mode: agent.name,
+ mode: admittedMode,
engine: agent.engine,
- trigger: { type: "schedule", cron: trigger.config.cron },
+ trigger: runTrigger,
createdBy: { type: "system", id: "scheduler" },
})
.returning()
diff --git a/services/api/src/schedules.ts b/services/api/src/schedules.ts
index f2decd5a..4fe5d4cd 100644
--- a/services/api/src/schedules.ts
+++ b/services/api/src/schedules.ts
@@ -10,7 +10,8 @@ import {
schedulerWatermarks,
} from "@facility/db";
import { and, eq, not, sql } from "drizzle-orm";
-import { laneFor } from "./github/router.js";
+import { isBuilderPlanDenialError, withBuilderPlanPreflight } from "./builder-plan-policy.js";
+import { laneFor } from "./github/agent-routing.js";
import type { AppConfig } from "./types.js";
type Enqueue = (
@@ -77,39 +78,59 @@ export async function runAgentSchedules(config: AppConfig, enqueue: Enqueue, now
if (!cronMatches(trigger.config.cron, instant, trigger.config.timezone ?? "UTC"))
continue;
const runId = scheduledRunId(agent.id, index, instant);
- const inserted = await db.transaction(async (tx) => {
- const run = (
- await tx
- .insert(runs)
- .values({
- id: runId,
+ const runTrigger = {
+ type: "schedule",
+ agentName: agent.name,
+ cron: trigger.config.cron,
+ timezone: trigger.config.timezone ?? "UTC",
+ scheduledFor: minuteIso(instant),
+ };
+ let inserted = false;
+ try {
+ inserted = await withBuilderPlanPreflight(
+ db,
+ {
+ orgId: agent.orgId,
+ projectId: agent.projectId,
+ mode: agent.name,
+ agentDefId: agent.id,
+ trigger: runTrigger,
+ actor: { type: "system", id: "agent-scheduler" },
+ source: "agent_scheduler",
+ },
+ async (tx, admission) => {
+ const run = (
+ await tx
+ // builder-plan-preflight: schedule_dispatch
+ .insert(runs)
+ .values({
+ id: runId,
+ orgId: agent.orgId,
+ projectId: agent.projectId,
+ agentDefId: agent.id,
+ mode: admission.mode,
+ engine: agent.engine,
+ trigger: runTrigger,
+ createdBy: { type: "system", id: "agent-scheduler" },
+ })
+ .onConflictDoNothing()
+ .returning({ id: runs.id })
+ )[0];
+ if (!run) return false;
+ await tx.insert(runEvents).values({
orgId: agent.orgId,
- projectId: agent.projectId,
- agentDefId: agent.id,
- mode: agent.name,
- engine: agent.engine,
- trigger: {
- type: "schedule",
- agentName: agent.name,
- cron: trigger.config.cron,
- timezone: trigger.config.timezone ?? "UTC",
- scheduledFor: minuteIso(instant),
- },
- createdBy: { type: "system", id: "agent-scheduler" },
- })
- .onConflictDoNothing()
- .returning({ id: runs.id })
- )[0];
- if (!run) return false;
- await tx.insert(runEvents).values({
- orgId: agent.orgId,
- runId,
- seq: 1,
- type: "queued",
- data: { queue: "runs.dispatch", source: "schedule" },
- });
- return true;
- });
+ runId,
+ seq: 1,
+ type: "queued",
+ data: { queue: "runs.dispatch", source: "schedule" },
+ });
+ return true;
+ },
+ );
+ } catch (error) {
+ if (isBuilderPlanDenialError(error)) continue;
+ throw error;
+ }
if (inserted) {
await insertAuditEvent(db, {
orgId: agent.orgId,
diff --git a/services/api/src/watchtower/canary.ts b/services/api/src/watchtower/canary.ts
index af07480e..9ea2d0e2 100644
--- a/services/api/src/watchtower/canary.ts
+++ b/services/api/src/watchtower/canary.ts
@@ -10,6 +10,7 @@ import {
runs,
} from "@facility/db";
import { and, eq } from "drizzle-orm";
+import { isBuilderPlanDenialError, withBuilderPlanPreflight } from "../builder-plan-policy.js";
import type { AppConfig } from "../types.js";
import { createGitHubClient, type GitHubClient } from "./github.js";
import { raisePlatformIssue, resolvePlatformIssue } from "./issues.js";
@@ -107,25 +108,56 @@ async function verifyPlatformCanary(
});
return;
}
- const run = (
- await db
- .insert(runs)
- .values({
- id: newId("run"),
- orgId,
- projectId,
- agentDefId: agent.id,
- mode: "architect",
- engine: agent.engine,
- trigger: {
- kind: "watchtower.canary",
- message: CANARY_MESSAGE,
- messageHash: CANARY_MESSAGE_HASH,
+ const trigger = {
+ kind: "watchtower.canary",
+ message: CANARY_MESSAGE,
+ messageHash: CANARY_MESSAGE_HASH,
+ };
+ let run: typeof runs.$inferSelect | undefined;
+ try {
+ run = (
+ await withBuilderPlanPreflight(
+ db,
+ {
+ orgId,
+ projectId,
+ mode: "architect",
+ agentDefId: agent.id,
+ trigger,
+ actor: { type: "system", id: "watchtower" },
+ source: "watchtower_canary",
},
- createdBy: { type: "system", id: "watchtower" },
- })
- .returning()
- )[0];
+ (tx, admission) =>
+ tx
+ // builder-plan-preflight: watchtower_canary
+ .insert(runs)
+ .values({
+ id: newId("run"),
+ orgId,
+ projectId,
+ agentDefId: agent.id,
+ mode: admission.mode,
+ engine: agent.engine,
+ trigger,
+ createdBy: { type: "system", id: "watchtower" },
+ })
+ .returning(),
+ )
+ )[0];
+ } catch (error) {
+ if (!isBuilderPlanDenialError(error)) throw error;
+ await raisePlatformIssue(db, {
+ orgId,
+ projectId,
+ kind: "canary_failure",
+ severity: "error",
+ fingerprint,
+ title: "Canary agent blocked by Builder plan policy",
+ bodyMd:
+ "The configured canary agent also resolves as Builder and cannot run without canonical plan acceptance.",
+ });
+ return;
+ }
if (run) {
await db.insert(runEvents).values({
orgId,
diff --git a/services/api/src/watchtower/hitl.ts b/services/api/src/watchtower/hitl.ts
index 6c8bbb76..1150da82 100644
--- a/services/api/src/watchtower/hitl.ts
+++ b/services/api/src/watchtower/hitl.ts
@@ -12,33 +12,54 @@ export async function runHitlExpire(config: AppConfig) {
}
export async function expireHitlProposals(db: FacilityDb) {
+ const now = new Date();
const overdue = await db
.select()
.from(proposals)
- .where(and(eq(proposals.state, "open"), lt(proposals.expiresAt, new Date())));
+ .where(and(eq(proposals.state, "open"), lt(proposals.expiresAt, now)));
+ let expired = 0;
for (const proposal of overdue) {
- await db
- .update(proposals)
- .set({ state: "expired", updatedAt: new Date() })
- .where(and(eq(proposals.orgId, proposal.orgId), eq(proposals.id, proposal.id)));
- const last = (
- await db
- .select()
- .from(proposalEvents)
- .where(
- and(eq(proposalEvents.orgId, proposal.orgId), eq(proposalEvents.proposalId, proposal.id)),
- )
- .orderBy(desc(proposalEvents.seq))
- .limit(1)
- )[0];
- await db.insert(proposalEvents).values({
- orgId: proposal.orgId,
- proposalId: proposal.id,
- seq: (last?.seq ?? 0) + 1,
- type: "expired",
- actor: { type: "system", name: "hitl.expire" },
- data: {},
+ const claimed = await db.transaction(async (transaction) => {
+ const tx = transaction as unknown as FacilityDb;
+ const updated = (
+ await tx
+ .update(proposals)
+ .set({ state: "expired", updatedAt: now })
+ .where(
+ and(
+ eq(proposals.orgId, proposal.orgId),
+ eq(proposals.id, proposal.id),
+ eq(proposals.state, "open"),
+ lt(proposals.expiresAt, now),
+ ),
+ )
+ .returning()
+ )[0];
+ if (!updated) return false;
+ const last = (
+ await tx
+ .select()
+ .from(proposalEvents)
+ .where(
+ and(
+ eq(proposalEvents.orgId, proposal.orgId),
+ eq(proposalEvents.proposalId, proposal.id),
+ ),
+ )
+ .orderBy(desc(proposalEvents.seq))
+ .limit(1)
+ )[0];
+ await tx.insert(proposalEvents).values({
+ orgId: proposal.orgId,
+ proposalId: proposal.id,
+ seq: (last?.seq ?? 0) + 1,
+ type: "expired",
+ actor: { type: "system", name: "hitl.expire" },
+ data: {},
+ });
+ return true;
});
+ if (claimed) expired += 1;
}
- return overdue.length;
+ return expired;
}
diff --git a/services/api/src/worker.ts b/services/api/src/worker.ts
index ceffed7e..47d095ea 100644
--- a/services/api/src/worker.ts
+++ b/services/api/src/worker.ts
@@ -13,7 +13,11 @@ import { deliverPendingWebhooks } from "./integrations/outbound.js";
import { runLearningNightly } from "./learning.js";
import { destroyPreviewById, provisionPreview, reconcilePreviews } from "./previews.js";
import { deliverPendingRunDeliveries } from "./sandbox/delivery.js";
-import { dispatchRun, reconcileSandboxes } from "./sandbox/orchestrator.js";
+import {
+ dispatchRun,
+ reconcileArchitectPlanPublications,
+ reconcileSandboxes,
+} from "./sandbox/orchestrator.js";
import { runAgentSchedules } from "./schedules.js";
import { runAnalyticsRollup } from "./watchtower/analytics.js";
import { runWatchtowerCanary } from "./watchtower/canary.js";
@@ -35,6 +39,7 @@ export async function startWorker() {
const queues = [
"runs.dispatch",
"deliveries.deliver",
+ "architect-plans.publish",
"watchtower.outcomes",
"watchtower.health",
"watchtower.canary",
@@ -105,6 +110,22 @@ export async function startWorker() {
pending: deliveries.filter((delivery) => delivery.status === "pending").length,
blocked: deliveries.filter((delivery) => delivery.status === "blocked").length,
};
+ } else if (queue === "architect-plans.publish") {
+ const publications = await reconcileArchitectPlanPublications(
+ db,
+ config,
+ data as { proposalId?: string; orgId?: string },
+ githubFactory,
+ );
+ result = {
+ selected: publications.length,
+ published: publications.filter((publication) => publication.status === "published")
+ .length,
+ closed: publications.filter((publication) => publication.status === "closed").length,
+ suppressed: publications.filter((publication) => publication.status === "suppressed")
+ .length,
+ pending: publications.filter((publication) => publication.status === "pending").length,
+ };
} else if (queue === "sandbox.reconcile") {
await reconcileSandboxes(config, (name, payload) => boss.send(name, payload));
} else if (queue === "agent.schedules") {
@@ -155,6 +176,7 @@ export async function startWorker() {
}
await boss.schedule("sandbox.reconcile", "*/2 * * * *", {});
await boss.schedule("deliveries.deliver", "* * * * *", {});
+ await boss.schedule("architect-plans.publish", "* * * * *", {});
await boss.schedule("agent.schedules", "* * * * *", {});
await boss.schedule("webhooks.deliver", "* * * * *", {});
await boss.schedule("idempotency.expire", "15 2 * * *", {});
diff --git a/services/api/test/api.test.ts b/services/api/test/api.test.ts
index c870066a..02caad40 100644
--- a/services/api/test/api.test.ts
+++ b/services/api/test/api.test.ts
@@ -46,7 +46,7 @@ import {
verifyAuditChain,
webhookDeliveries,
} from "@facility/db";
-import { and, eq, ne, sql } from "drizzle-orm";
+import { and, eq, sql } from "drizzle-orm";
import postgres from "postgres";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { buildApp, mintSessionCookie } from "../src/app.js";
@@ -400,7 +400,7 @@ describe("api", async () => {
await db
.select()
.from(agentDefs)
- .where(and(eq(agentDefs.projectId, project.json().id), ne(agentDefs.name, "learning")))
+ .where(and(eq(agentDefs.projectId, project.json().id), eq(agentDefs.name, "architect")))
.limit(1)
)[0];
expect(agent).toBeTruthy();
@@ -638,6 +638,7 @@ describe("api", async () => {
payload: { name: "Project", slug },
});
expect(created.statusCode).toBe(200);
+ expect(created.json().builderPlanPolicy).toBe("optional");
projectId = created.json().id;
const projectSpaces = await db
.select()
@@ -1769,7 +1770,16 @@ describe("api", async () => {
owner: repoOwner,
name: "plan-dispatch",
defaultBranch: "main",
- renderAnswers: { execution_lane: { architect: "platform", builder: "platform" } },
+ renderAnswers: {
+ execution_lane: {
+ architect: "platform",
+ builder: "platform",
+ "codex-builder": "platform",
+ },
+ },
+ fingerprintStatus: "ok",
+ fingerprint: { files: [] },
+ fingerprintVerifiedAt: new Date(),
})
.returning()
)[0];
@@ -1817,6 +1827,205 @@ describe("api", async () => {
if (!planRepo || !builder || !architectRun || !planAcceptance) {
throw new Error("plan fixtures missing");
}
+ const createInternalPlanProposal = async (
+ runId: string,
+ contextMd: string,
+ payload: Record = {},
+ ) => {
+ const id = newId("prop");
+ await db.insert(proposals).values({
+ id,
+ orgId,
+ projectId: planProjectId,
+ runId,
+ actionTypeId: planAcceptance.id,
+ payload,
+ contextMd,
+ expiresAt: new Date(Date.now() + 3_600_000),
+ });
+ await db.insert(proposalEvents).values({
+ orgId,
+ proposalId: id,
+ seq: 1,
+ type: "open",
+ actor: { type: "agent", id: runId },
+ data: { source: "architect_run" },
+ });
+ return { statusCode: 200, json: () => ({ id }) };
+ };
+ await db
+ .update(repos)
+ .set({ fingerprintStatus: "pending_merge" })
+ .where(eq(repos.id, planRepo.id));
+ const unverifiedPolicy = await app.inject({
+ method: "PATCH",
+ url: `/v1/projects/${planProjectId}`,
+ headers: { cookie },
+ payload: { builderPlanPolicy: "required" },
+ });
+ expect(unverifiedPolicy.statusCode).toBe(409);
+ expect(unverifiedPolicy.json().error.code).toBe("builder_plan_platform_lane_required");
+ await db
+ .update(repos)
+ .set({ fingerprintStatus: "ok", fingerprintVerifiedAt: new Date() })
+ .where(eq(repos.id, planRepo.id));
+ await db
+ .update(agentDefs)
+ .set({
+ name: "delivery-specialist",
+ triggers: [{ type: "command", command: "/builder" }],
+ })
+ .where(eq(agentDefs.id, builder.id));
+ const activeLegacyBuilder = await app.inject({
+ method: "POST",
+ url: `/v1/projects/${planProjectId}/runs`,
+ headers: { cookie },
+ payload: {
+ agentDefId: builder.id,
+ trigger: { type: "manual", message: "Persist the admitted Builder identity" },
+ },
+ });
+ expect(activeLegacyBuilder.statusCode).toBe(200);
+ expect(activeLegacyBuilder.json().mode).toBe("builder");
+ await db.update(agentDefs).set({ triggers: [] }).where(eq(agentDefs.id, builder.id));
+ const activeRunPolicy = await app.inject({
+ method: "PATCH",
+ url: `/v1/projects/${planProjectId}`,
+ headers: { cookie },
+ payload: { builderPlanPolicy: "required" },
+ });
+ expect(activeRunPolicy.statusCode).toBe(409);
+ expect(activeRunPolicy.json().error.code).toBe("builder_plan_active_runs_present");
+ await db
+ .update(runs)
+ .set({ status: "failed", endedAt: new Date() })
+ .where(eq(runs.id, activeLegacyBuilder.json().id));
+ const opaqueLegacyRun = (
+ await db
+ .insert(runs)
+ .values({
+ id: newId("run"),
+ orgId,
+ projectId: planProjectId,
+ agentDefId: builder.id,
+ mode: "delivery-specialist",
+ engine: builder.engine,
+ trigger: { type: "manual", source: "pre-immutable-admission" },
+ createdBy: { type: "user", id: "legacy-fixture" },
+ })
+ .returning({ id: runs.id })
+ )[0];
+ const opaqueLegacyPolicy = await app.inject({
+ method: "PATCH",
+ url: `/v1/projects/${planProjectId}`,
+ headers: { cookie },
+ payload: { builderPlanPolicy: "required" },
+ });
+ expect(opaqueLegacyPolicy.statusCode).toBe(409);
+ expect(opaqueLegacyPolicy.json().error.code).toBe("builder_plan_active_runs_present");
+ await db
+ .update(runs)
+ .set({ status: "failed", endedAt: new Date() })
+ .where(eq(runs.id, opaqueLegacyRun?.id ?? ""));
+ await db
+ .update(agentDefs)
+ .set({ triggers: [{ type: "command", command: "builder" }] })
+ .where(eq(agentDefs.id, builder.id));
+ const requiredPolicy = await app.inject({
+ method: "PATCH",
+ url: `/v1/projects/${planProjectId}`,
+ headers: { cookie },
+ payload: { builderPlanPolicy: "required" },
+ });
+ expect(requiredPolicy.statusCode).toBe(200);
+ expect(requiredPolicy.json().builderPlanPolicy).toBe("required");
+
+ await db
+ .update(repos)
+ .set({ fingerprintStatus: "pending", fingerprintVerifiedAt: null })
+ .where(eq(repos.id, planRepo.id));
+ const requiredSettingsDuringDrift = await app.inject({
+ method: "PATCH",
+ url: `/v1/projects/${planProjectId}`,
+ headers: { cookie },
+ payload: {
+ builderPlanPolicy: "required",
+ settings: { gateRegression: "lane-drift" },
+ },
+ });
+ expect(requiredSettingsDuringDrift.statusCode).toBe(200);
+ expect(requiredSettingsDuringDrift.json().settings).toEqual({ gateRegression: "lane-drift" });
+ await db
+ .update(repos)
+ .set({ fingerprintStatus: "ok", fingerprintVerifiedAt: new Date() })
+ .where(eq(repos.id, planRepo.id));
+ const activeRequiredRun = (
+ await db
+ .insert(runs)
+ .values({
+ id: newId("run"),
+ orgId,
+ projectId: planProjectId,
+ mode: "architect",
+ engine: "codex",
+ trigger: { type: "manual", message: "Keep required settings editable" },
+ createdBy: { type: "user", id: "fixture" },
+ })
+ .returning({ id: runs.id })
+ )[0];
+ const requiredSettingsWithActiveRun = await app.inject({
+ method: "PATCH",
+ url: `/v1/projects/${planProjectId}`,
+ headers: { cookie },
+ payload: {
+ builderPlanPolicy: "required",
+ settings: { gateRegression: "active-run" },
+ },
+ });
+ expect(requiredSettingsWithActiveRun.statusCode).toBe(200);
+ expect(requiredSettingsWithActiveRun.json().settings).toEqual({ gateRegression: "active-run" });
+ await db
+ .update(runs)
+ .set({ status: "failed", endedAt: new Date() })
+ .where(eq(runs.id, activeRequiredRun?.id ?? ""));
+
+ const beforeDenied = await db
+ .select({ id: runs.id })
+ .from(runs)
+ .where(and(eq(runs.orgId, orgId), eq(runs.projectId, planProjectId)));
+ const missingPlan = await app.inject({
+ method: "POST",
+ url: `/v1/projects/${planProjectId}/runs`,
+ headers: { cookie },
+ payload: {
+ agentDefId: builder.id,
+ trigger: { type: "manual", message: "Do not bypass Gate 1" },
+ },
+ });
+ expect(missingPlan.statusCode).toBe(409);
+ expect(missingPlan.json().error.code).toBe("builder_plan_required");
+ const missingPlanAndObjective = await app.inject({
+ method: "POST",
+ url: `/v1/projects/${planProjectId}/runs`,
+ headers: { cookie },
+ payload: { agentDefId: builder.id },
+ });
+ expect(missingPlanAndObjective.statusCode).toBe(409);
+ expect(missingPlanAndObjective.json().error.code).toBe("builder_plan_required");
+ expect(
+ await db
+ .select({ id: runs.id })
+ .from(runs)
+ .where(and(eq(runs.orgId, orgId), eq(runs.projectId, planProjectId))),
+ ).toHaveLength(beforeDenied.length);
+ const optionalPolicy = await app.inject({
+ method: "PATCH",
+ url: `/v1/projects/${planProjectId}`,
+ headers: { cookie },
+ payload: { builderPlanPolicy: "optional" },
+ });
+ expect(optionalPolicy.statusCode).toBe(200);
+
const forgedPlanRun = await app.inject({
method: "POST",
url: `/v1/projects/${planProjectId}/runs`,
@@ -1831,7 +2040,7 @@ describe("api", async () => {
expect(forgedPlanRun.statusCode).toBe(400);
expect(forgedPlanRun.json().error.code).toBe("reserved_trigger_source");
- const proposal = await app.inject({
+ const reservedPlanProposal = await app.inject({
method: "POST",
url: "/v1/proposals",
headers: { cookie },
@@ -1843,6 +2052,26 @@ describe("api", async () => {
contextMd: "Approve the implementation plan",
},
});
+ expect(reservedPlanProposal.statusCode).toBe(403);
+ expect(reservedPlanProposal.json().error.code).toBe("reserved_action_type");
+ const reservedNamedPlan = await app.inject({
+ method: "POST",
+ url: "/v1/proposals",
+ headers: { cookie },
+ payload: {
+ projectId: planProjectId,
+ runId: architectRun.id,
+ actionType: "plan_acceptance",
+ payload: {},
+ contextMd: "Attempt the reserved action by name",
+ },
+ });
+ expect(reservedNamedPlan.statusCode).toBe(403);
+ expect(reservedNamedPlan.json().error.code).toBe("reserved_action_type");
+ const proposal = await createInternalPlanProposal(
+ architectRun.id,
+ "Approve the implementation plan",
+ );
expect(proposal.statusCode).toBe(200);
const approved = await app.inject({
method: "POST",
@@ -1860,7 +2089,7 @@ describe("api", async () => {
and(
eq(runs.orgId, orgId),
eq(runs.projectId, planProjectId),
- eq(runs.mode, "builder"),
+ eq(runs.mode, "codex-builder"),
sql`${runs.trigger} @> ${JSON.stringify({
source: "plan_acceptance",
proposalId: proposal.json().id,
@@ -1868,6 +2097,7 @@ describe("api", async () => {
),
);
expect(builderRuns).toHaveLength(1);
+ expect(builderRuns[0]?.mode).toBe("codex-builder");
const codexBuilder = (
await db
.select()
@@ -1901,18 +2131,31 @@ describe("api", async () => {
data: { source: "plan_acceptance", architectRunId: architectRun.id },
});
- const duplicateProposal = await app.inject({
+ // Optional projects preserve the legacy resume shape: provenance is used
+ // only for the policy preflight and is not copied into the new row, where
+ // the plan-acceptance uniqueness indexes would reject it.
+ await db
+ .update(runs)
+ .set({ status: "failed", engine: "claude_code", engineSessionId: "plan-resume-session" })
+ .where(eq(runs.id, builderRuns[0]?.id ?? ""));
+ const resumedPlanRun = await app.inject({
method: "POST",
- url: "/v1/proposals",
+ url: `/v1/runs/${builderRuns[0]?.id}/resume`,
headers: { cookie },
- payload: {
- projectId: planProjectId,
- runId: architectRun.id,
- actionTypeId: planAcceptance.id,
- payload: {},
- contextMd: "A duplicate approval for the same architect plan",
- },
+ payload: { message: "Continue the optional legacy run" },
});
+ expect(resumedPlanRun.statusCode).toBe(200);
+ expect(resumedPlanRun.json().trigger).toMatchObject({
+ type: "resume",
+ resumeOf: builderRuns[0]?.id,
+ });
+ expect(resumedPlanRun.json().trigger).not.toHaveProperty("source");
+ expect(resumedPlanRun.json().trigger).not.toHaveProperty("proposalId");
+
+ const duplicateProposal = await createInternalPlanProposal(
+ architectRun.id,
+ "A duplicate approval for the same architect plan",
+ );
const duplicateApproval = await app.inject({
method: "POST",
url: `/v1/proposals/${duplicateProposal.json().id}/decide`,
@@ -1975,6 +2218,89 @@ describe("api", async () => {
expect(retriedBuilderRuns).toHaveLength(1);
expect(retriedBuilderRuns[0]?.agentDefId).toBe(codexBuilder.id);
+ const racedArchitect = (
+ await db
+ .insert(runs)
+ .values({
+ id: newId("run"),
+ orgId,
+ projectId: planProjectId,
+ mode: "codex-architect",
+ engine: "codex",
+ status: "succeeded",
+ trigger: { type: "github_comment", repo: { id: planRepo.id } },
+ gh: { owner: repoOwner, repo: planRepo.name, issueNumber: 44 },
+ createdBy: { type: "user", id: "architect-requester" },
+ })
+ .returning()
+ )[0];
+ if (!racedArchitect) throw new Error("raced Architect fixture missing");
+ const racedProposal = await createInternalPlanProposal(
+ racedArchitect.id,
+ "Reject repository lane drift inside the admission lock",
+ );
+ const beforeRacedApproval = await db
+ .select({ id: runs.id })
+ .from(runs)
+ .where(
+ sql`${runs.trigger} @> ${JSON.stringify({
+ source: "plan_acceptance",
+ proposalId: racedProposal.json().id,
+ })}::jsonb`,
+ );
+ let racedApproval:
+ | Promise<{ statusCode: number; body: string; json: () => unknown }>
+ | undefined;
+ await db.transaction(async (tx) => {
+ await tx.execute(
+ sql`select pg_advisory_xact_lock(hashtextextended(${`builder-plan:${orgId}:${planProjectId}`}, 0))`,
+ );
+ await tx
+ .update(repos)
+ .set({ renderAnswers: { execution_lane: { architect: "platform", builder: "repo" } } })
+ .where(eq(repos.id, planRepo.id));
+ racedApproval = app.inject({
+ method: "POST",
+ url: `/v1/proposals/${racedProposal.json().id}/decide`,
+ headers: { cookie: approverCookie },
+ payload: { decision: "approve" },
+ });
+ let executing = false;
+ for (let attempt = 0; attempt < 100; attempt += 1) {
+ const candidate = (
+ await db
+ .select({ state: proposals.state })
+ .from(proposals)
+ .where(eq(proposals.id, racedProposal.json().id))
+ .limit(1)
+ )[0];
+ if (candidate?.state === "executing") {
+ executing = true;
+ break;
+ }
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ }
+ expect(executing).toBe(true);
+ });
+ if (!racedApproval) throw new Error("raced approval did not start");
+ const racedResult = await racedApproval;
+ expect(racedResult.statusCode, racedResult.body).toBe(200);
+ expect(racedResult.json()).toMatchObject({
+ state: "execution_failed",
+ executionError: "plan_acceptance_builder_uses_repo_lane",
+ });
+ expect(
+ await db
+ .select({ id: runs.id })
+ .from(runs)
+ .where(
+ sql`${runs.trigger} @> ${JSON.stringify({
+ source: "plan_acceptance",
+ proposalId: racedProposal.json().id,
+ })}::jsonb`,
+ ),
+ ).toHaveLength(beforeRacedApproval.length);
+
await db
.update(repos)
.set({ renderAnswers: { execution_lane: { architect: "platform", builder: "repo" } } })
@@ -1996,18 +2322,10 @@ describe("api", async () => {
.returning()
)[0];
if (!repoLaneArchitect) throw new Error("repo-lane architect fixture missing");
- const repoLaneProposal = await app.inject({
- method: "POST",
- url: "/v1/proposals",
- headers: { cookie },
- payload: {
- projectId: planProjectId,
- runId: repoLaneArchitect.id,
- actionTypeId: planAcceptance.id,
- payload: {},
- contextMd: "Repo lane must still require /builder",
- },
- });
+ const repoLaneProposal = await createInternalPlanProposal(
+ repoLaneArchitect.id,
+ "Repo lane must still require /builder",
+ );
const repoLaneApproval = await app.inject({
method: "POST",
url: `/v1/proposals/${repoLaneProposal.json().id}/decide`,
@@ -2015,6 +2333,7 @@ describe("api", async () => {
payload: { decision: "approve" },
});
expect(repoLaneApproval.json().state).toBe("execution_failed");
+ expect(repoLaneApproval.json().executionError).toBe("plan_acceptance_builder_uses_repo_lane");
const repoLaneEvents = await db
.select()
.from(proposalEvents)
@@ -2627,6 +2946,239 @@ describe("api", async () => {
expect(archived?.status).toBe("archived");
});
+ it("denies MCP Builder trigger families without creating or enqueueing runs when plans are required", async () => {
+ const target = await createProjectWithAgent("MCP Governed Builder");
+ await db
+ .update(agentDefs)
+ .set({
+ name: "delivery-specialist",
+ engine: "claude_code",
+ triggers: [{ type: "command", handle: "/builder" }],
+ enabled: true,
+ })
+ .where(eq(agentDefs.id, target.agent.id));
+ await db
+ .update(projects)
+ .set({ builderPlanPolicy: "required" })
+ .where(and(eq(projects.orgId, orgId), eq(projects.id, target.projectId)));
+
+ const role = await app.inject({
+ method: "POST",
+ url: "/v1/roles",
+ headers: { cookie },
+ payload: {
+ name: `mcp-governed-builder-${Date.now()}`,
+ permissions: ["org:read", "runs:trigger", "repos:write"],
+ },
+ });
+ expect(role.statusCode, role.body).toBe(200);
+ const issued = await app.inject({
+ method: "POST",
+ url: "/v1/keys",
+ headers: { cookie },
+ payload: {
+ name: `mcp-governed-builder-${Date.now()}`,
+ roleId: role.json().id,
+ projectId: target.projectId,
+ },
+ });
+ expect(issued.statusCode, issued.body).toBe(200);
+
+ const installation = (
+ await db
+ .insert(githubInstallations)
+ .values({
+ id: newId("int"),
+ orgId,
+ installationId: Date.now(),
+ accountLogin: `mcp-governed-${Date.now()}`,
+ targetType: "Organization",
+ })
+ .returning()
+ )[0];
+ const repo = (
+ await db
+ .insert(repos)
+ .values({
+ id: newId("repo"),
+ orgId,
+ projectId: target.projectId,
+ installationId: installation?.id,
+ owner: `mcp-governed-${Date.now()}`,
+ name: "facility",
+ defaultBranch: "main",
+ })
+ .returning()
+ )[0];
+ if (!installation || !repo) throw new Error("governed MCP repository fixture missing");
+ await db.insert(ghIssues).values({
+ id: newId("evt"),
+ orgId,
+ projectId: target.projectId,
+ repoId: repo.id,
+ number: 204,
+ title: "Do not bypass Gate 1 from MCP",
+ state: "open",
+ htmlUrl: `https://github.com/${repo.owner}/${repo.name}/issues/204`,
+ });
+ const terminalBuilder = (
+ await db
+ .insert(runs)
+ .values({
+ id: newId("run"),
+ orgId,
+ projectId: target.projectId,
+ agentDefId: target.agent.id,
+ mode: "delivery-specialist",
+ engine: "claude_code",
+ engineSessionId: "governed-mcp-resume",
+ status: "succeeded",
+ createdBy: { type: "test", id: "governed-mcp" },
+ })
+ .returning()
+ )[0];
+ if (!terminalBuilder) throw new Error("governed MCP resume fixture missing");
+ const governedConversation = (
+ await db
+ .insert(conversations)
+ .values({
+ id: newId("evt"),
+ orgId,
+ projectId: target.projectId,
+ agentDefId: target.agent.id,
+ title: "Governed Builder conversation",
+ createdBy: { type: "test", id: "governed-mcp" },
+ })
+ .returning()
+ )[0];
+ if (!governedConversation) throw new Error("governed MCP conversation fixture missing");
+
+ const originalEnqueue = app.enqueue;
+ const originalFactory = app.githubClientFactory;
+ const enqueued: Array<{ queue: string; data: Record }> = [];
+ let githubFactoryCalls = 0;
+ app.enqueue = async (queue, data) => {
+ enqueued.push({ queue, data });
+ return null;
+ };
+ app.githubClientFactory = (async () => {
+ githubFactoryCalls += 1;
+ throw new Error("MCP Builder denial reached GitHub");
+ }) as unknown as GithubClientFactory;
+
+ const executeDenied = async (toolName: string, args: Record) => {
+ const before = await db
+ .select({ id: runs.id })
+ .from(runs)
+ .where(eq(runs.projectId, target.projectId));
+ const proposed = await app.inject({
+ method: "POST",
+ url: "/v1/mcp/tool-proposals",
+ headers: { authorization: `Bearer ${issued.json().secret}` },
+ payload: {
+ toolName,
+ permission: "runs:trigger",
+ projectId: target.projectId,
+ summary: `Attempt governed ${toolName}`,
+ args,
+ },
+ });
+ expect(proposed.statusCode, proposed.body).toBe(200);
+ const approved = await app.inject({
+ method: "POST",
+ url: `/v1/proposals/${proposed.json().id}/decide`,
+ headers: { cookie: approverCookie },
+ payload: { decision: "approve" },
+ });
+ expect(approved.statusCode, approved.body).toBe(200);
+ expect(approved.json()).toMatchObject({
+ state: "execution_failed",
+ executionError: "builder_plan_required",
+ });
+ expect(
+ await db.select({ id: runs.id }).from(runs).where(eq(runs.projectId, target.projectId)),
+ ).toHaveLength(before.length);
+ };
+
+ try {
+ await executeDenied("facility_trigger_run", {
+ projectId: target.projectId,
+ agentName: "builder",
+ input: { objective: "Attempt direct Builder dispatch" },
+ });
+ await executeDenied("facility_trigger_github_issue", {
+ projectId: target.projectId,
+ repoId: repo.id,
+ number: 204,
+ agentName: "builder",
+ });
+ await executeDenied("facility_resume_run", {
+ runId: terminalBuilder.id,
+ message: "Attempt governed MCP resume",
+ });
+ await executeDenied("facility_send_conversation_message", {
+ conversationId: governedConversation.id,
+ body: "Attempt governed Builder conversation turn",
+ });
+ const beforeRepos = await db
+ .select({ id: repos.id })
+ .from(repos)
+ .where(eq(repos.projectId, target.projectId));
+ const repoProposal = await app.inject({
+ method: "POST",
+ url: "/v1/mcp/tool-proposals",
+ headers: { authorization: `Bearer ${issued.json().secret}` },
+ payload: {
+ toolName: "facility_connect_repo",
+ permission: "repos:write",
+ projectId: target.projectId,
+ summary: "Attempt repo connection while Gate 1 is required",
+ args: {
+ projectId: target.projectId,
+ owner: installation.accountLogin,
+ name: "unverified-required-repo",
+ },
+ },
+ });
+ expect(repoProposal.statusCode, repoProposal.body).toBe(200);
+ const repoApproval = await app.inject({
+ method: "POST",
+ url: `/v1/proposals/${repoProposal.json().id}/decide`,
+ headers: { cookie: approverCookie },
+ payload: { decision: "approve" },
+ });
+ expect(repoApproval.statusCode, repoApproval.body).toBe(200);
+ expect(repoApproval.json()).toMatchObject({
+ state: "execution_failed",
+ executionError: "builder_plan_platform_lane_required",
+ });
+ expect(
+ await db.select({ id: repos.id }).from(repos).where(eq(repos.projectId, target.projectId)),
+ ).toHaveLength(beforeRepos.length);
+ expect(enqueued).toEqual([]);
+ expect(githubFactoryCalls).toBe(0);
+ const sources = (
+ await db
+ .select({ action: auditEvents.action, payload: auditEvents.payload })
+ .from(auditEvents)
+ .where(eq(auditEvents.projectId, target.projectId))
+ )
+ .filter((event) => event.action === "run.builder_plan_denied")
+ .map((event) => (event.payload as { source?: unknown }).source);
+ expect(sources).toEqual(
+ expect.arrayContaining([
+ "mcp_trigger_run",
+ "mcp_trigger_github_issue",
+ "mcp_resume_run",
+ "mcp_conversation_message",
+ ]),
+ );
+ } finally {
+ app.enqueue = originalEnqueue;
+ app.githubClientFactory = originalFactory;
+ }
+ });
+
it("executes approved MCP sessions, conversations, and GitHub issue workflows end to end", async () => {
const target = await createProjectWithAgent("MCP Interactive Lifecycle");
await db
@@ -2734,6 +3286,11 @@ describe("api", async () => {
engine: "claude_code",
engineSessionId: "session_mcp_resume",
status: "succeeded",
+ trigger: {
+ source: "plan_acceptance",
+ proposalId: newId("prop"),
+ architectRunId: newId("run"),
+ },
createdBy: { type: "test", id: "mcp-interactive" },
})
.returning()
@@ -2756,6 +3313,8 @@ describe("api", async () => {
resumeOf: terminal?.id,
message: "Continue from the verified checkpoint",
});
+ expect(resumed?.trigger).not.toHaveProperty("source");
+ expect(resumed?.trigger).not.toHaveProperty("proposalId");
const title = `MCP conversation ${Date.now()}`;
await execute("facility_start_conversation", "runs:trigger", {
@@ -6200,6 +6759,31 @@ describe("api", async () => {
expect(codex.statusCode).toBe(409);
expect(codex.json().error.code).toBe("not_resumable");
+ // A resume is a new Builder row, not a second consumption of the
+ // parent's plan acceptance. Required projects therefore deny it before
+ // insertion instead of copying provenance into a non-canonical trigger.
+ await db
+ .update(projects)
+ .set({ builderPlanPolicy: "required" })
+ .where(and(eq(projects.orgId, orgId), eq(projects.id, target.projectId)));
+ const beforeRequiredResume = await db
+ .select({ id: runs.id })
+ .from(runs)
+ .where(eq(runs.projectId, target.projectId));
+ const dispatchedBeforeRequiredResume = dispatched.length;
+ const governedResume = await app.inject({
+ method: "POST",
+ url: `/v1/runs/${parent?.id}/resume`,
+ headers: { cookie },
+ payload: { message: "attempt a governed resume" },
+ });
+ expect(governedResume.statusCode, governedResume.body).toBe(409);
+ expect(governedResume.json().error.code).toBe("builder_plan_required");
+ expect(
+ await db.select({ id: runs.id }).from(runs).where(eq(runs.projectId, target.projectId)),
+ ).toHaveLength(beforeRequiredResume.length);
+ expect(dispatched).toHaveLength(dispatchedBeforeRequiredResume);
+
const other = (
await db
.insert(projects)
diff --git a/services/api/test/architect-plan-publication.test.ts b/services/api/test/architect-plan-publication.test.ts
new file mode 100644
index 00000000..800c2e4c
--- /dev/null
+++ b/services/api/test/architect-plan-publication.test.ts
@@ -0,0 +1,167 @@
+import { describe, expect, it } from "vitest";
+import {
+ architectPlanPublicationKey,
+ architectPlanPublicationMarker,
+ effectiveArchitectPlanProposalState,
+ findArchitectPlanPublicationComment,
+ isGithubNotFound,
+ legacyRunProgressMarker,
+ renderClosedArchitectPlanPublication,
+ rotateArchitectPlanPublicationOrgIds,
+} from "../src/github/architect-plan-publication.js";
+
+describe("Architect plan publication", () => {
+ it("builds a stable tenant-independent publication identity", () => {
+ expect(architectPlanPublicationKey("run_123", "prop_456")).toBe(
+ "architect-plan:run_123:prop_456",
+ );
+ });
+
+ it("wraps the stable identity in a recoverable GitHub marker", () => {
+ expect(architectPlanPublicationMarker("run_123", "prop_456")).toBe(
+ "",
+ );
+ });
+
+ it("recognizes Octokit not-found errors without swallowing other failures", () => {
+ expect(isGithubNotFound({ status: 404 })).toBe(true);
+ expect(isGithubNotFound({ statusCode: 404 })).toBe(true);
+ expect(isGithubNotFound({ response: { status: 404 } })).toBe(true);
+ expect(isGithubNotFound({ status: 500 })).toBe(false);
+ });
+
+ it("prefers the new marker but can recover a bot-authored legacy progress comment", () => {
+ const comments = [
+ {
+ id: 1,
+ authorType: "User",
+ body: legacyRunProgressMarker("run_123"),
+ },
+ {
+ id: 2,
+ authorType: "Bot",
+ body: legacyRunProgressMarker("run_123"),
+ },
+ {
+ id: 3,
+ authorType: "Bot",
+ body: architectPlanPublicationMarker("run_123", "prop_456"),
+ },
+ ];
+ expect(
+ findArchitectPlanPublicationComment(comments, {
+ runId: "run_123",
+ publicationMarker: architectPlanPublicationMarker("run_123", "prop_456"),
+ allowLegacy: true,
+ })?.id,
+ ).toBe(3);
+ expect(
+ findArchitectPlanPublicationComment(comments.slice(0, 2), {
+ runId: "run_123",
+ publicationMarker: architectPlanPublicationMarker("run_123", "prop_456"),
+ allowLegacy: true,
+ })?.id,
+ ).toBe(2);
+ });
+
+ it("does not treat a legacy progress marker as an unpublished outbox delivery", () => {
+ expect(
+ findArchitectPlanPublicationComment(
+ [{ id: 2, authorType: "Bot", body: legacyRunProgressMarker("run_123") }],
+ {
+ runId: "run_123",
+ publicationMarker: architectPlanPublicationMarker("run_123", "prop_456"),
+ allowLegacy: false,
+ },
+ ),
+ ).toBeUndefined();
+ });
+
+ it("rotates a bounded organization window without starving the remainder", () => {
+ const orgIds = Array.from({ length: 30 }, (_, index) => `org_${index}`);
+ const first = rotateArchitectPlanPublicationOrgIds(
+ orgIds,
+ new Date("1970-01-01T00:00:00.000Z"),
+ 25,
+ );
+ const second = rotateArchitectPlanPublicationOrgIds(
+ orgIds,
+ new Date("1970-01-01T00:01:00.000Z"),
+ 25,
+ );
+
+ expect(first).toHaveLength(25);
+ expect(second).toHaveLength(25);
+ expect(new Set([...first, ...second])).toEqual(new Set(orgIds));
+ });
+
+ it("preserves all eligible organizations when they fit in one window", () => {
+ expect(
+ rotateArchitectPlanPublicationOrgIds(
+ ["org_a", "org_b"],
+ new Date("2026-08-26T12:34:00.000Z"),
+ 25,
+ ),
+ ).toEqual(["org_a", "org_b"]);
+ });
+
+ it("keeps an open proposal effective strictly before expiry", () => {
+ expect(
+ effectiveArchitectPlanProposalState(
+ "open",
+ new Date("2026-08-26T12:01:00.000Z"),
+ new Date("2026-08-26T12:00:00.000Z"),
+ ),
+ ).toEqual({ open: true, state: "open" });
+ });
+
+ it("treats an open proposal as expired at the exact boundary", () => {
+ expect(
+ effectiveArchitectPlanProposalState(
+ "open",
+ new Date("2026-08-26T12:00:00.000Z"),
+ new Date("2026-08-26T12:00:00.000Z"),
+ ),
+ ).toEqual({ open: false, state: "expired" });
+ });
+
+ it("preserves an explicit terminal proposal state", () => {
+ expect(
+ effectiveArchitectPlanProposalState(
+ "rejected",
+ new Date("2026-08-27T12:00:00.000Z"),
+ new Date("2026-08-26T12:00:00.000Z"),
+ ),
+ ).toEqual({ open: false, state: "rejected" });
+ });
+
+ it("renders a terminal snapshot with its marker and without an approval CTA", () => {
+ const body = renderClosedArchitectPlanPublication({
+ runId: "run_123",
+ plan: "1. Keep the gate closed.",
+ proposalState: "executed",
+ publicationMarker: architectPlanPublicationMarker("run_123", "prop_456"),
+ updatedAt: new Date("2026-08-26T12:00:00.000Z"),
+ });
+
+ expect(body).toContain("");
+ expect(body).toContain("Human Gate 1:** no longer open (`executed`)");
+ expect(body).toContain("1. Keep the gate closed.");
+ expect(body).toContain("2026-08-26T12:00:00.000Z");
+ expect(body).not.toContain("Approve this plan");
+ expect(body).not.toContain("/builder");
+ });
+
+ it("bounds the terminal plan snapshot", () => {
+ const body = renderClosedArchitectPlanPublication({
+ runId: "run_123",
+ plan: `start${"x".repeat(60_000)}tail`,
+ proposalState: "expired",
+ publicationMarker: architectPlanPublicationMarker("run_123", "prop_456"),
+ updatedAt: new Date("2026-08-26T12:00:00.000Z"),
+ });
+
+ expect(body).toContain("start");
+ expect(body).not.toContain("tail");
+ });
+});
diff --git a/services/api/test/assistant.test.ts b/services/api/test/assistant.test.ts
index 2eb89570..bc6e520f 100644
--- a/services/api/test/assistant.test.ts
+++ b/services/api/test/assistant.test.ts
@@ -136,6 +136,33 @@ describe("assistant ask endpoint", async () => {
expect(response.statusCode).toBe(404);
});
+ it("denies an inline project-owner that also exposes /builder before creating a run", async () => {
+ const project = await insertProject("Assistant Builder Alias Guard");
+ await db
+ .update(projects)
+ .set({ builderPlanPolicy: "required" })
+ .where(eq(projects.id, project.id));
+ await insertOwnerAgent(project.id, [{ type: "command", handle: "/builder" }]);
+ const before = await db
+ .select({ id: runs.id })
+ .from(runs)
+ .where(eq(runs.projectId, project.id));
+
+ const response = await app.inject({
+ method: "POST",
+ url: `/v1/projects/${project.id}/ask`,
+ headers: { cookie },
+ payload: { body: "attempt the inline bypass" },
+ });
+
+ expect(response.statusCode).toBe(409);
+ expect((response.json() as { error: { code: string } }).error.code).toBe(
+ "builder_plan_required",
+ );
+ const after = await db.select({ id: runs.id }).from(runs).where(eq(runs.projectId, project.id));
+ expect(after).toHaveLength(before.length);
+ });
+
it("runs a stubbed turn end to end: tool call, reply, revoked key, idle thread", async () => {
const project = await insertProject("Assistant Happy Path");
await insertOwnerAgent(project.id);
@@ -345,7 +372,7 @@ describe("assistant ask endpoint", async () => {
return project;
}
- async function insertOwnerAgent(projectId: string) {
+ async function insertOwnerAgent(projectId: string, triggers: unknown[] = []) {
const contract = (
await db
.insert(registryItems)
@@ -371,7 +398,7 @@ describe("assistant ask endpoint", async () => {
engine: "claude_code",
model: { model: "claude-sonnet-5" },
contractItemId: contract.id,
- triggers: [],
+ triggers,
permissions: ["kb:read", "kb:write", "tasks:read", "tasks:write", "runs:read"],
enabled: true,
})
diff --git a/services/api/test/builder-plan-policy.integration.test.ts b/services/api/test/builder-plan-policy.integration.test.ts
new file mode 100644
index 00000000..223f3ea6
--- /dev/null
+++ b/services/api/test/builder-plan-policy.integration.test.ts
@@ -0,0 +1,1275 @@
+import { createHash } from "node:crypto";
+import { newId, sealFacilityReceipt } from "@facility/core";
+import {
+ actionTypes,
+ agentDefs,
+ auditEvents,
+ createDb,
+ type FacilityDb,
+ ghIssues,
+ migrate,
+ orgs,
+ projects,
+ proposalEvents,
+ proposals,
+ registryItems,
+ repos,
+ runs,
+} from "@facility/db";
+import { and, desc, eq } from "drizzle-orm";
+import postgres from "postgres";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import {
+ assertBuilderPlanDispatch,
+ lockBuilderPlanPolicy,
+ withBuilderPlanPreflight,
+} from "../src/builder-plan-policy.js";
+import { ApiError } from "../src/errors.js";
+import { githubIssueRevisionSha256 } from "../src/github/issue-revision.js";
+import { syncRepoFacilityConfig } from "../src/github/kickstart.js";
+import { routeTrigger, type TriggerPayload } from "../src/github/router.js";
+import { dispatchRun } from "../src/sandbox/orchestrator.js";
+import type { AppConfig } from "../src/types.js";
+
+const databaseUrl =
+ process.env.DATABASE_URL ?? "postgres://facility:facility@127.0.0.1:5461/facility_test";
+
+async function canConnect() {
+ const sqlClient = postgres(databaseUrl, { max: 1, connect_timeout: 2 });
+ try {
+ await sqlClient`select 1`;
+ return true;
+ } catch {
+ return false;
+ } finally {
+ await sqlClient.end().catch(() => undefined);
+ }
+}
+
+describe("builder plan policy integration", async () => {
+ const reachable = await canConnect();
+ if (!reachable) {
+ it.skip("Postgres is unreachable at DATABASE_URL; Builder plan tests skipped", () => undefined);
+ return;
+ }
+
+ const { db, client } = createDb(databaseUrl);
+
+ beforeAll(async () => {
+ await migrate(databaseUrl);
+ });
+
+ afterAll(async () => {
+ await client.end();
+ });
+
+ it("allows one canonical, fresh plan acceptance and rejects a second consumption", async () => {
+ const fixture = await canonicalFixture();
+ await expect(assertBuilderPlanDispatch(db, fixture.dispatch)).resolves.toEqual({
+ mode: "builder",
+ isBuilder: true,
+ });
+
+ const linkedRunId = newId("run");
+ await db.insert(runs).values({
+ id: linkedRunId,
+ orgId: fixture.orgId,
+ projectId: fixture.projectId,
+ mode: "builder",
+ engine: "codex",
+ trigger: fixture.dispatch.trigger,
+ gh: fixture.dispatch.gh,
+ createdBy: { type: "user", id: "approver" },
+ });
+ await db
+ .update(proposals)
+ .set({ state: "executed" })
+ .where(eq(proposals.id, fixture.proposalId));
+
+ await expect(
+ assertBuilderPlanDispatch(db, { ...fixture.dispatch, runId: linkedRunId }),
+ ).resolves.toEqual({ mode: "builder", isBuilder: true });
+ const before = await projectRuns(fixture.orgId, fixture.projectId);
+ await expect(assertBuilderPlanDispatch(db, fixture.dispatch)).rejects.toMatchObject({
+ code: "builder_plan_already_consumed",
+ });
+ expect(await projectRuns(fixture.orgId, fixture.projectId)).toHaveLength(before.length);
+ await expect(lastDenialCode(fixture.orgId)).resolves.toBe("builder_plan_already_consumed");
+
+ const original = (
+ await db.select().from(proposals).where(eq(proposals.id, fixture.proposalId)).limit(1)
+ )[0];
+ if (!original) throw new Error("original proposal fixture missing");
+ const duplicateProposalId = newId("prop");
+ await db.insert(proposals).values({
+ ...original,
+ id: duplicateProposalId,
+ state: "executing",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ });
+ await db.insert(proposalEvents).values([
+ {
+ orgId: fixture.orgId,
+ proposalId: duplicateProposalId,
+ seq: 1,
+ type: "open",
+ actor: { type: "agent", id: original.runId },
+ data: { source: "architect_run" },
+ },
+ {
+ orgId: fixture.orgId,
+ proposalId: duplicateProposalId,
+ seq: 2,
+ type: "approved",
+ actor: { type: "user", id: "approver" },
+ data: {},
+ },
+ ]);
+ await expect(
+ assertBuilderPlanDispatch(db, {
+ ...fixture.dispatch,
+ trigger: { ...fixture.dispatch.trigger, proposalId: duplicateProposalId },
+ }),
+ ).rejects.toMatchObject({ code: "builder_plan_already_consumed" });
+ });
+
+ it("recognizes a Builder by agentDefId when the run mode is a surface alias", async () => {
+ const fixture = await canonicalFixture();
+ const contractId = newId("item");
+ const agentDefId = newId("agent");
+ await db.insert(registryItems).values({
+ id: contractId,
+ orgId: fixture.orgId,
+ scope: "project",
+ projectId: fixture.projectId,
+ kind: "contract",
+ name: "builder-contract",
+ });
+ await db.insert(agentDefs).values({
+ id: agentDefId,
+ orgId: fixture.orgId,
+ projectId: fixture.projectId,
+ name: "codex-builder",
+ engine: "codex",
+ model: { primary: "gpt-5" },
+ contractItemId: contractId,
+ });
+ const before = await projectRuns(fixture.orgId, fixture.projectId);
+ await expect(
+ assertBuilderPlanDispatch(db, {
+ orgId: fixture.orgId,
+ projectId: fixture.projectId,
+ mode: "conversation",
+ agentDefId,
+ trigger: { type: "conversation", message: "try to hide the role" },
+ actor: { type: "system", id: "integration-test" },
+ source: "integration_test_alias",
+ }),
+ ).rejects.toMatchObject({ code: "builder_plan_required" });
+ expect(await projectRuns(fixture.orgId, fixture.projectId)).toHaveLength(before.length);
+ });
+
+ it("recognizes a renamed Builder from its governed command trigger", async () => {
+ const fixture = await canonicalFixture();
+ const contractId = newId("item");
+ const agentDefId = newId("agent");
+ await db.insert(registryItems).values({
+ id: contractId,
+ orgId: fixture.orgId,
+ scope: "project",
+ projectId: fixture.projectId,
+ kind: "contract",
+ name: "renamed-builder-contract",
+ });
+ await db.insert(agentDefs).values({
+ id: agentDefId,
+ orgId: fixture.orgId,
+ projectId: fixture.projectId,
+ name: "implementation-agent",
+ engine: "codex",
+ model: { primary: "gpt-5" },
+ contractItemId: contractId,
+ triggers: [{ type: "command", handle: "/codex-builder" }],
+ });
+ await expect(
+ assertBuilderPlanDispatch(db, {
+ orgId: fixture.orgId,
+ projectId: fixture.projectId,
+ mode: "conversation",
+ agentDefId,
+ trigger: { type: "conversation", message: "renamed role bypass" },
+ source: "integration_test_trigger_alias",
+ }),
+ ).rejects.toMatchObject({ code: "builder_plan_required" });
+ });
+
+ it("serializes policy activation ahead of preflight and leaves no denied run row", async () => {
+ const fixture = await githubRouteFixture("open");
+ let releaseActivation!: () => void;
+ const holdActivation = new Promise((resolve) => {
+ releaseActivation = resolve;
+ });
+ let activationLocked!: () => void;
+ const locked = new Promise((resolve) => {
+ activationLocked = resolve;
+ });
+ const activation = db.transaction(async (transaction) => {
+ const tx = transaction as unknown as FacilityDb;
+ await lockBuilderPlanPolicy(tx, fixture.orgId, fixture.projectId);
+ await tx
+ .update(projects)
+ .set({ builderPlanPolicy: "required" })
+ .where(eq(projects.id, fixture.projectId));
+ activationLocked();
+ await holdActivation;
+ });
+ await locked;
+ const before = await projectRuns(fixture.orgId, fixture.projectId);
+ const denied = withBuilderPlanPreflight(
+ db,
+ {
+ orgId: fixture.orgId,
+ projectId: fixture.projectId,
+ mode: "builder",
+ agentDefId: fixture.builderAgentId,
+ trigger: { type: "manual" },
+ source: "activation_race_test",
+ },
+ (tx) =>
+ tx
+ .insert(runs)
+ .values({
+ id: newId("run"),
+ orgId: fixture.orgId,
+ projectId: fixture.projectId,
+ agentDefId: fixture.builderAgentId,
+ mode: "builder",
+ engine: "codex",
+ trigger: { type: "manual" },
+ createdBy: { type: "system", id: "activation-race-test" },
+ })
+ .returning(),
+ );
+ const denialAssertion = expect(denied).rejects.toMatchObject({
+ code: "builder_plan_required",
+ });
+ await new Promise((resolve) => setImmediate(resolve));
+ releaseActivation();
+ await activation;
+
+ await denialAssertion;
+ expect(await projectRuns(fixture.orgId, fixture.projectId)).toHaveLength(before.length);
+ });
+
+ it("fails closed when agentDefId belongs to another project", async () => {
+ const fixture = await canonicalFixture();
+ const otherProjectId = newId("proj");
+ await db.insert(projects).values({
+ id: otherProjectId,
+ orgId: fixture.orgId,
+ name: "Other project",
+ slug: `other-${crypto.randomUUID()}`,
+ });
+ const contractId = newId("item");
+ const crossProjectAgentId = newId("agent");
+ await db.insert(registryItems).values({
+ id: contractId,
+ orgId: fixture.orgId,
+ scope: "project",
+ projectId: otherProjectId,
+ kind: "contract",
+ name: `cross-project-${crypto.randomUUID()}`,
+ });
+ await db.insert(agentDefs).values({
+ id: crossProjectAgentId,
+ orgId: fixture.orgId,
+ projectId: otherProjectId,
+ name: "implementation-agent",
+ engine: "codex",
+ model: { primary: "gpt-5" },
+ contractItemId: contractId,
+ triggers: [{ type: "command", handle: "/builder" }],
+ });
+
+ await expect(
+ assertBuilderPlanDispatch(db, {
+ orgId: fixture.orgId,
+ projectId: fixture.projectId,
+ mode: "conversation",
+ agentDefId: crossProjectAgentId,
+ trigger: { type: "conversation" },
+ source: "integration_test_cross_project_agent",
+ }),
+ ).rejects.toMatchObject({ code: "builder_plan_context_invalid" });
+ await expect(lastDenialCode(fixture.orgId)).resolves.toBe("builder_plan_context_invalid");
+ });
+
+ it("does not accept a canonical proposal from another tenant or project", async () => {
+ const source = await canonicalFixture();
+ const target = await canonicalFixture();
+ const before = await projectRuns(target.orgId, target.projectId);
+ await expect(
+ assertBuilderPlanDispatch(db, {
+ ...target.dispatch,
+ trigger: source.dispatch.trigger,
+ }),
+ ).rejects.toMatchObject({ code: "builder_plan_context_invalid" });
+ expect(await projectRuns(target.orgId, target.projectId)).toHaveLength(before.length);
+ });
+
+ it("marks a required repository drifted before rejecting a malformed manifest", async () => {
+ const fixture = await canonicalFixture();
+ const repo = (
+ await db.select().from(repos).where(eq(repos.projectId, fixture.projectId)).limit(1)
+ )[0];
+ if (!repo) throw new Error("manifest repo fixture missing");
+ await db
+ .update(repos)
+ .set({
+ fingerprint: { files: [] },
+ fingerprintStatus: "ok",
+ fingerprintVerifiedAt: new Date(),
+ renderAnswers: {
+ execution_lane: { builder: "platform", "codex-builder": "platform" },
+ },
+ })
+ .where(eq(repos.id, repo.id));
+ const client = {
+ getContent: async () => ({
+ type: "file",
+ encoding: "base64",
+ content: Buffer.from("{invalid").toString("base64"),
+ }),
+ } as never;
+
+ await expect(syncRepoFacilityConfig({ db, client, repo })).rejects.toMatchObject({
+ code: "builder_plan_platform_lane_required",
+ });
+ const updated = (
+ await db
+ .select({ status: repos.fingerprintStatus })
+ .from(repos)
+ .where(eq(repos.id, repo.id))
+ .limit(1)
+ )[0];
+ expect(updated?.status).toBe("drifted");
+ });
+
+ it("routes GitHub /builder through the canonical executor and ignores a poisoned proposalId", async () => {
+ const fixture = await githubRouteFixture("open");
+ await db
+ .update(projects)
+ .set({ builderPlanPolicy: "required" })
+ .where(eq(projects.id, fixture.projectId));
+ await db
+ .update(repos)
+ .set({
+ fingerprint: { files: [] },
+ fingerprintStatus: "ok",
+ fingerprintVerifiedAt: new Date(),
+ })
+ .where(eq(repos.projectId, fixture.projectId));
+ const poisonId = newId("run");
+ await db.insert(runs).values({
+ id: poisonId,
+ orgId: fixture.orgId,
+ projectId: fixture.projectId,
+ mode: "project-owner",
+ engine: "codex",
+ trigger: { source: "manual", proposalId: fixture.proposalId },
+ createdBy: { type: "user", id: "poison" },
+ });
+ const enqueued: Array<{ queue: string; data: Record }> = [];
+ const deliveryId = `delivery_${crypto.randomUUID()}`;
+
+ const result = await routeTrigger(
+ db,
+ fixture.orgId,
+ fixture.client,
+ fixture.payload,
+ async (queue, data) => {
+ enqueued.push({ queue, data });
+ return null;
+ },
+ deliveryId,
+ );
+
+ const denial = (
+ await db
+ .select({ payload: auditEvents.payload })
+ .from(auditEvents)
+ .where(
+ and(
+ eq(auditEvents.orgId, fixture.orgId),
+ eq(auditEvents.action, "run.builder_plan_denied"),
+ ),
+ )
+ .orderBy(desc(auditEvents.seq))
+ .limit(1)
+ )[0];
+ const execution = (
+ await db
+ .select({ data: proposalEvents.data })
+ .from(proposalEvents)
+ .where(eq(proposalEvents.proposalId, fixture.proposalId))
+ .orderBy(desc(proposalEvents.seq))
+ .limit(1)
+ )[0];
+ expect(
+ result.routed,
+ JSON.stringify({ result, denial: denial?.payload, execution: execution?.data }),
+ ).toBe(true);
+ expect(result.runId).not.toBe(poisonId);
+ const canonical = (
+ await db
+ .select()
+ .from(runs)
+ .where(eq(runs.id, result.runId ?? ""))
+ .limit(1)
+ )[0];
+ expect(canonical?.trigger).toMatchObject({
+ source: "plan_acceptance",
+ proposalId: fixture.proposalId,
+ architectRunId: fixture.architectRunId,
+ });
+ expect(enqueued).toEqual([
+ { queue: "runs.dispatch", data: { runId: canonical?.id, orgId: fixture.orgId } },
+ ]);
+ const storedProposal = (
+ await db.select().from(proposals).where(eq(proposals.id, fixture.proposalId)).limit(1)
+ )[0];
+ expect(storedProposal?.state).toBe("executed");
+ const deliveryReplay = await routeTrigger(
+ db,
+ fixture.orgId,
+ fixture.client,
+ fixture.payload,
+ async () => null,
+ deliveryId,
+ );
+ expect(deliveryReplay).toMatchObject({
+ routed: true,
+ reason: "delivery_replayed",
+ runId: canonical?.id,
+ });
+ const differentComment = await routeTrigger(db, fixture.orgId, fixture.client, {
+ ...fixture.payload,
+ comment: { id: 205, body: "/builder" },
+ });
+ expect(differentComment).toMatchObject({
+ routed: false,
+ reason: "builder_plan_already_consumed",
+ runId: canonical?.id,
+ });
+ const decisions = (
+ await db
+ .select()
+ .from(auditEvents)
+ .where(and(eq(auditEvents.orgId, fixture.orgId), eq(auditEvents.action, "hitl.decided")))
+ ).filter((event) => (event.target as { id?: unknown }).id === fixture.proposalId);
+ expect(decisions).toHaveLength(1);
+ });
+
+ it.each([
+ {
+ name: "default branch",
+ drift: (fixture: Awaited>) => {
+ fixture.live.baseSha = "b".repeat(40);
+ },
+ },
+ {
+ name: "issue scope",
+ drift: (fixture: Awaited>) => {
+ fixture.live.issueBody = "Implement it, plus a newly-added requirement.";
+ },
+ },
+ ])("rejects a required GitHub approval when the live $name changed", async ({ drift }) => {
+ const fixture = await githubRouteFixture("open");
+ await db
+ .update(projects)
+ .set({ builderPlanPolicy: "required" })
+ .where(eq(projects.id, fixture.projectId));
+ await db
+ .update(repos)
+ .set({
+ fingerprint: { files: [] },
+ fingerprintStatus: "ok",
+ fingerprintVerifiedAt: new Date(),
+ })
+ .where(eq(repos.projectId, fixture.projectId));
+ drift(fixture);
+ const before = await projectRuns(fixture.orgId, fixture.projectId);
+
+ const result = await routeTrigger(db, fixture.orgId, fixture.client, fixture.payload);
+
+ expect(result).toMatchObject({ routed: false, reason: "builder_plan_stale" });
+ expect(await projectRuns(fixture.orgId, fixture.projectId)).toHaveLength(before.length);
+ await expect(lastDenialCode(fixture.orgId)).resolves.toBe("builder_plan_stale");
+ const denial = await lastDenialPayload(fixture.orgId);
+ const provenance = fixture.dispatch.trigger.planProvenance as Record;
+ const expected = {
+ baseSha: provenance.workspaceBaseSha,
+ issueRevisionSha256: provenance.issueRevisionSha256,
+ };
+ const observed = denial.observedPlanInputs as Record;
+ expect(denial).toMatchObject({
+ code: "builder_plan_stale",
+ expectedPlanInputs: expected,
+ observedPlanInputs: { checkedAt: expect.any(String) },
+ });
+ expect({
+ baseSha: observed.baseSha,
+ issueRevisionSha256: observed.issueRevisionSha256,
+ }).not.toEqual(expected);
+ });
+
+ it("fails closed and audits a required approval when GitHub freshness is unavailable", async () => {
+ const fixture = await githubRouteFixture("open");
+ await db
+ .update(projects)
+ .set({ builderPlanPolicy: "required" })
+ .where(eq(projects.id, fixture.projectId));
+ await db
+ .update(repos)
+ .set({
+ fingerprint: { files: [] },
+ fingerprintStatus: "ok",
+ fingerprintVerifiedAt: new Date(),
+ })
+ .where(eq(repos.projectId, fixture.projectId));
+ fixture.live.issueError = new Error("GitHub fixture unavailable");
+ const before = await projectRuns(fixture.orgId, fixture.projectId);
+
+ const result = await routeTrigger(db, fixture.orgId, fixture.client, fixture.payload);
+
+ expect(result).toMatchObject({
+ routed: false,
+ reason: "builder_plan_freshness_unavailable",
+ });
+ expect(await projectRuns(fixture.orgId, fixture.projectId)).toHaveLength(before.length);
+ await expect(lastDenialCode(fixture.orgId)).resolves.toBe("builder_plan_freshness_unavailable");
+ });
+
+ it.each([
+ {
+ name: "issue drift",
+ expected: "builder_plan_stale",
+ secondIssueBody: "Implement it, plus a requirement added during worker claim.",
+ secondIssueError: null,
+ },
+ {
+ name: "GitHub freshness outage",
+ expected: "builder_plan_freshness_unavailable",
+ secondIssueBody: null,
+ secondIssueError: "GitHub fixture unavailable during worker claim",
+ },
+ ])("revalidates a required plan after the worker claim and launches no sandbox on $name", async ({
+ expected,
+ secondIssueBody,
+ secondIssueError,
+ }) => {
+ const fixture = await githubRouteFixture("open");
+ await db
+ .update(projects)
+ .set({ builderPlanPolicy: "required" })
+ .where(eq(projects.id, fixture.projectId));
+ await db
+ .update(repos)
+ .set({
+ fingerprint: { files: [] },
+ fingerprintStatus: "ok",
+ fingerprintVerifiedAt: new Date(),
+ })
+ .where(eq(repos.projectId, fixture.projectId));
+ const routed = await routeTrigger(db, fixture.orgId, fixture.client, fixture.payload);
+ expect(routed).toMatchObject({ routed: true });
+ if (!routed.runId) throw new Error("required Builder run was not created");
+
+ const repository = fixture.payload.repository;
+ const owner = repository?.owner?.login;
+ const repo = repository?.name;
+ if (!owner || !repo) throw new Error("worker freshness repository fixture missing");
+ let issueReads = 0;
+ let launches = 0;
+ const workerClient = {
+ getDefaultBranchSha: async () => fixture.live.baseSha,
+ getIssue: async () => {
+ issueReads += 1;
+ if (issueReads === 2 && secondIssueError) throw new Error(secondIssueError);
+ return {
+ number: 204,
+ title: "Require a plan",
+ body: issueReads === 1 ? "Implement it" : secondIssueBody,
+ state: "open",
+ user: { login: "requester" },
+ labels: [],
+ html_url: `https://github.test/${owner}/${repo}/issues/204`,
+ };
+ },
+ listIssueComments: async () => [
+ {
+ id: 204,
+ author: "maintainer",
+ authorType: "User",
+ body: "/builder",
+ createdAt: "2026-08-26T10:00:00Z",
+ url: "https://github.test/comments/204",
+ },
+ ],
+ };
+
+ await expect(
+ dispatchRun(
+ { databaseUrl } as AppConfig,
+ { runId: routed.runId, orgId: fixture.orgId },
+ {
+ githubClient: { owner, repo, client: workerClient },
+ sandboxDriver: async () => {
+ launches += 1;
+ return {
+ name: "docker",
+ launch: async () => ({ ref: "must-not-launch" }),
+ status: async () => "running",
+ async *logs() {},
+ stop: async () => undefined,
+ destroy: async () => undefined,
+ } as never;
+ },
+ },
+ ),
+ ).rejects.toMatchObject({ code: expected });
+ expect(issueReads).toBe(2);
+ expect(launches).toBe(0);
+ expect(
+ (
+ await db
+ .select({ status: runs.status, error: runs.error })
+ .from(runs)
+ .where(eq(runs.id, routed.runId))
+ .limit(1)
+ )[0],
+ ).toEqual({ status: "failed", error: expected });
+ await expect(lastDenialCode(fixture.orgId)).resolves.toBe(expected);
+ });
+
+ it("recovers an executing GitHub plan and a crash after the exact row was created", async () => {
+ const executing = await githubRouteFixture("executing");
+ const recovered = await routeTrigger(
+ db,
+ executing.orgId,
+ executing.client,
+ executing.payload,
+ async () => null,
+ );
+ expect(recovered).toMatchObject({ routed: true });
+ const recoveredRun = (
+ await db
+ .select()
+ .from(runs)
+ .where(eq(runs.id, recovered.runId ?? ""))
+ .limit(1)
+ )[0];
+ expect(recoveredRun?.createdBy).toMatchObject({
+ type: "user",
+ id: "github:maintainer",
+ proposalId: executing.proposalId,
+ });
+ expect(
+ (await db.select().from(proposals).where(eq(proposals.id, executing.proposalId)).limit(1))[0]
+ ?.state,
+ ).toBe("executed");
+
+ const crashed = await githubRouteFixture("executed");
+ const exactId = newId("run");
+ await db.insert(runs).values({
+ id: exactId,
+ orgId: crashed.orgId,
+ projectId: crashed.projectId,
+ agentDefId: crashed.builderAgentId,
+ mode: "codex-builder",
+ engine: "codex",
+ status: "queued",
+ trigger: crashed.dispatch.trigger,
+ gh: crashed.dispatch.gh,
+ createdBy: { type: "user", id: "approver" },
+ });
+ const jobs: Record[] = [];
+ const crashRecovery = await routeTrigger(
+ db,
+ crashed.orgId,
+ crashed.client,
+ crashed.payload,
+ async (_queue, data) => {
+ jobs.push(data);
+ return null;
+ },
+ `delivery_${crypto.randomUUID()}`,
+ );
+ expect(crashRecovery).toMatchObject({ routed: true, runId: exactId });
+ expect(jobs).toEqual([{ runId: exactId, orgId: crashed.orgId }]);
+ });
+
+ it("does not enqueue an exact queued row when proposal recovery fails the required gate", async () => {
+ const fixture = await githubRouteFixture("execution_failed");
+ await db
+ .update(projects)
+ .set({ builderPlanPolicy: "required" })
+ .where(eq(projects.id, fixture.projectId));
+ const exactId = newId("run");
+ await db.insert(runs).values({
+ id: exactId,
+ orgId: fixture.orgId,
+ projectId: fixture.projectId,
+ agentDefId: fixture.builderAgentId,
+ mode: "builder",
+ engine: "codex",
+ status: "queued",
+ trigger: fixture.dispatch.trigger,
+ gh: fixture.dispatch.gh,
+ createdBy: { type: "user", id: "github:maintainer" },
+ });
+ const jobs: Record[] = [];
+ const result = await routeTrigger(
+ db,
+ fixture.orgId,
+ fixture.client,
+ fixture.payload,
+ async (_queue, data) => {
+ jobs.push(data);
+ return null;
+ },
+ `delivery_${crypto.randomUUID()}`,
+ );
+
+ expect(result).toMatchObject({
+ routed: false,
+ reason: "builder_plan_context_invalid",
+ });
+ expect(jobs).toHaveLength(0);
+ expect(
+ (await db.select({ status: runs.status }).from(runs).where(eq(runs.id, exactId)).limit(1))[0]
+ ?.status,
+ ).toBe("queued");
+ });
+
+ it("returns the stable expired code for a persisted expired GitHub proposal", async () => {
+ const fixture = await githubRouteFixture("expired");
+ await db
+ .update(projects)
+ .set({ builderPlanPolicy: "required" })
+ .where(eq(projects.id, fixture.projectId));
+ const before = await projectRuns(fixture.orgId, fixture.projectId);
+ const result = await routeTrigger(db, fixture.orgId, fixture.client, fixture.payload);
+ expect(result).toMatchObject({ routed: false, reason: "builder_plan_expired" });
+ expect(await projectRuns(fixture.orgId, fixture.projectId)).toHaveLength(before.length);
+ });
+
+ it("returns the stable expired code when an open GitHub proposal has passed expiresAt", async () => {
+ const fixture = await githubRouteFixture("open");
+ await db
+ .update(projects)
+ .set({ builderPlanPolicy: "required" })
+ .where(eq(projects.id, fixture.projectId));
+ await db
+ .update(proposals)
+ .set({ expiresAt: new Date(Date.now() - 1_000) })
+ .where(eq(proposals.id, fixture.proposalId));
+ const before = await projectRuns(fixture.orgId, fixture.projectId);
+ const result = await routeTrigger(db, fixture.orgId, fixture.client, fixture.payload);
+ expect(result).toMatchObject({ routed: false, reason: "builder_plan_expired" });
+ expect(await projectRuns(fixture.orgId, fixture.projectId)).toHaveLength(before.length);
+ });
+
+ it.each([
+ "expired",
+ "rejected",
+ "canceled",
+ "execution_failed",
+ ] as const)("preserves optional-policy /builder compatibility after a %s proposal", async (state) => {
+ const fixture = await githubRouteFixture(state);
+ const result = await routeTrigger(db, fixture.orgId, fixture.client, fixture.payload);
+ expect(result).toMatchObject({ routed: true });
+ const run = (
+ await db
+ .select()
+ .from(runs)
+ .where(eq(runs.id, result.runId ?? ""))
+ .limit(1)
+ )[0];
+ expect(run?.trigger).toMatchObject({ type: "github_comment" });
+ expect(run?.trigger).not.toMatchObject({ source: "plan_acceptance" });
+ });
+
+ it("persists the canonical Builder mode for a renamed optional GitHub agent", async () => {
+ const fixture = await githubRouteFixture("rejected");
+ await db
+ .update(agentDefs)
+ .set({
+ name: "delivery-specialist",
+ triggers: [{ type: "command", handle: "/builder" }],
+ })
+ .where(eq(agentDefs.id, fixture.builderAgentId));
+ const result = await routeTrigger(db, fixture.orgId, fixture.client, fixture.payload);
+ expect(result).toMatchObject({ routed: true });
+ const run = (
+ await db
+ .select()
+ .from(runs)
+ .where(eq(runs.id, result.runId ?? ""))
+ .limit(1)
+ )[0];
+ expect(run).toMatchObject({ mode: "builder", agentDefId: fixture.builderAgentId });
+ });
+
+ it.each([
+ {
+ name: "missing",
+ expected: "builder_plan_required",
+ arrange: async () => {
+ const fixture = await canonicalFixture();
+ return { fixture, dispatch: { ...fixture.dispatch, trigger: { type: "manual" } } };
+ },
+ },
+ {
+ name: "expired",
+ expected: "builder_plan_expired",
+ arrange: async () => {
+ const fixture = await canonicalFixture({ expiresAt: new Date(Date.now() - 60_000) });
+ return { fixture, dispatch: fixture.dispatch };
+ },
+ },
+ {
+ name: "persisted expired state",
+ expected: "builder_plan_expired",
+ arrange: async () => {
+ const fixture = await canonicalFixture({ state: "expired" });
+ return { fixture, dispatch: fixture.dispatch };
+ },
+ },
+ {
+ name: "rejected",
+ expected: "builder_plan_rejected",
+ arrange: async () => {
+ const fixture = await canonicalFixture({ state: "rejected" });
+ return { fixture, dispatch: fixture.dispatch };
+ },
+ },
+ {
+ name: "stale",
+ expected: "builder_plan_stale",
+ arrange: async () => {
+ const fixture = await canonicalFixture();
+ return {
+ fixture,
+ dispatch: {
+ ...fixture.dispatch,
+ freshnessEvidence: {
+ ...fixture.dispatch.freshnessEvidence,
+ baseSha: "b".repeat(40),
+ },
+ },
+ };
+ },
+ },
+ {
+ name: "invalid canonical context",
+ expected: "builder_plan_context_invalid",
+ arrange: async () => {
+ const fixture = await canonicalFixture();
+ return {
+ fixture,
+ dispatch: {
+ ...fixture.dispatch,
+ trigger: { ...fixture.dispatch.trigger, planSha256: "f".repeat(64) },
+ },
+ };
+ },
+ },
+ {
+ name: "non-human approval principal",
+ expected: "builder_plan_context_invalid",
+ arrange: async () => {
+ const fixture = await canonicalFixture();
+ await db
+ .update(proposalEvents)
+ .set({ actor: { type: "key", id: "approver" } })
+ .where(
+ and(
+ eq(proposalEvents.proposalId, fixture.proposalId),
+ eq(proposalEvents.type, "approved"),
+ ),
+ );
+ return { fixture, dispatch: fixture.dispatch };
+ },
+ },
+ {
+ name: "freshness unavailable",
+ expected: "builder_plan_freshness_unavailable",
+ arrange: async () => {
+ const fixture = await canonicalFixture();
+ const { freshnessEvidence: _freshnessEvidence, ...dispatch } = fixture.dispatch;
+ return { fixture, dispatch };
+ },
+ },
+ ])("denies $name with a stable code and no new run row", async ({ expected, arrange }) => {
+ const { fixture, dispatch } = await arrange();
+ const before = await projectRuns(fixture.orgId, fixture.projectId);
+ await expect(assertBuilderPlanDispatch(db, dispatch)).rejects.toSatisfy(
+ (error: unknown) => error instanceof ApiError && error.code === expected,
+ );
+ expect(await projectRuns(fixture.orgId, fixture.projectId)).toHaveLength(before.length);
+ await expect(lastDenialCode(fixture.orgId)).resolves.toBe(expected);
+ });
+
+ async function canonicalFixture(options: { state?: string; expiresAt?: Date } = {}): Promise<{
+ orgId: string;
+ projectId: string;
+ proposalId: string;
+ dispatch: Parameters[1] & {
+ trigger: Record;
+ gh: Record;
+ freshnessEvidence: { baseSha: string; issueRevisionSha256: string; checkedAt: string };
+ };
+ }> {
+ const suffix = crypto.randomUUID().replaceAll("-", "");
+ const orgId = `org_plan_${suffix}`;
+ const projectId = `proj_plan_${suffix}`;
+ const repoId = `repo_plan_${suffix}`;
+ const architectRunId = `run_arch_${suffix}`;
+ const proposalId = `prop_plan_${suffix}`;
+ const actionTypeId = `act_plan_${suffix}`;
+ const baseSha = "a".repeat(40);
+ const issueUrl = `https://github.test/facility-test/plan-${suffix}/issues/204`;
+ const issueRequest = {
+ title: "Require a plan",
+ body: "Implement it",
+ state: "open",
+ author: "requester",
+ url: issueUrl,
+ labels: [],
+ comments: [],
+ };
+ const issueRevisionSha256 = githubIssueRevisionSha256(issueRequest);
+ if (!issueRevisionSha256) throw new Error("issue revision fixture missing");
+ const plan = "Implement the reviewed change and run the named checks.";
+ const planSha256 = createHash("sha256").update(plan).digest("hex");
+ const decidedAt = new Date();
+
+ await db.insert(orgs).values({ id: orgId, name: `Plan ${suffix}`, slug: `plan-${suffix}` });
+ await db.insert(projects).values({
+ id: projectId,
+ orgId,
+ name: "Plan policy",
+ slug: `plan-policy-${suffix}`,
+ builderPlanPolicy: "required",
+ });
+ await db.insert(repos).values({
+ id: repoId,
+ orgId,
+ projectId,
+ owner: "facility-test",
+ name: `plan-${suffix}`,
+ defaultBranch: "main",
+ });
+ await db.insert(ghIssues).values({
+ id: `ghi_${suffix}`,
+ orgId,
+ projectId,
+ repoId,
+ number: 204,
+ title: "Require a plan",
+ state: "open",
+ htmlUrl: issueUrl,
+ ghUpdatedAt: new Date(),
+ });
+ const receipt = sealFacilityReceipt(
+ {
+ schema: "facility.run.v1",
+ run_id: architectRunId,
+ project_id: projectId,
+ provider: "codex_cli",
+ mode: "architect",
+ result: "succeeded",
+ usage: {
+ input_tokens: 10,
+ output_tokens: 20,
+ cost_cents: 1,
+ cost_source: "test",
+ },
+ activity: {
+ turns: 1,
+ shell_commands: 0,
+ file_changes: 0,
+ mcp_tool_calls: 0,
+ web_searches: 0,
+ tool_calls: 1,
+ errors: 0,
+ },
+ github: {
+ owner: "facility-test",
+ repo: `plan-${suffix}`,
+ issue: 204,
+ base_sha: baseSha,
+ },
+ timing: { started_at: decidedAt.toISOString(), ended_at: decidedAt.toISOString() },
+ },
+ null,
+ );
+ await db.insert(runs).values({
+ id: architectRunId,
+ orgId,
+ projectId,
+ mode: "architect",
+ engine: "codex",
+ status: "succeeded",
+ trigger: {
+ type: "github_comment",
+ repo: {
+ id: repoId,
+ owner: "facility-test",
+ name: `plan-${suffix}`,
+ baseSha,
+ },
+ issue: { number: 204 },
+ request: issueRequest,
+ },
+ workspaceBaseSha: baseSha,
+ receipt,
+ gh: { owner: "facility-test", repo: `plan-${suffix}`, issueNumber: 204 },
+ createdBy: { type: "user", id: "requester" },
+ });
+ await db.insert(actionTypes).values({
+ id: actionTypeId,
+ orgId,
+ name: "plan_acceptance",
+ payloadSchema: { type: "object" },
+ resolver: { type: "permission", config: { permission: "hitl:decide" } },
+ executor: { type: "internal", config: {} },
+ defaultTtlHours: 72,
+ });
+ await db.insert(proposals).values({
+ id: proposalId,
+ orgId,
+ projectId,
+ runId: architectRunId,
+ actionTypeId,
+ payload: {
+ architectRunId,
+ issueNumber: 204,
+ repoId,
+ receiptSha256: receipt.integrity?.payload_sha256,
+ planSha256,
+ workspaceBaseSha: baseSha,
+ issueRevisionSha256,
+ },
+ contextMd: plan,
+ state: options.state ?? "executing",
+ decidedBy: "approver",
+ decidedAt,
+ expiresAt: options.expiresAt ?? new Date(Date.now() + 3_600_000),
+ });
+ await db.insert(proposalEvents).values([
+ {
+ orgId,
+ proposalId,
+ seq: 1,
+ type: "open",
+ actor: { type: "agent", id: architectRunId },
+ data: { source: "architect_run" },
+ },
+ {
+ orgId,
+ proposalId,
+ seq: 2,
+ type: "approved",
+ actor: { type: "user", id: "approver" },
+ data: {},
+ },
+ ]);
+ return {
+ orgId,
+ projectId,
+ proposalId,
+ dispatch: {
+ orgId,
+ projectId,
+ mode: "builder",
+ trigger: {
+ source: "plan_acceptance",
+ proposalId,
+ architectRunId,
+ approvedPlan: plan,
+ planSha256,
+ approval: { principal: "approver", at: decidedAt.toISOString() },
+ planProvenance: { workspaceBaseSha: baseSha, issueRevisionSha256 },
+ },
+ gh: { owner: "facility-test", repo: `plan-${suffix}`, issueNumber: 204 },
+ actor: { type: "user", id: "approver" },
+ source: "integration_test",
+ freshnessEvidence: {
+ baseSha,
+ issueRevisionSha256,
+ checkedAt: new Date().toISOString(),
+ },
+ },
+ };
+ }
+
+ async function githubRouteFixture(
+ state:
+ | "open"
+ | "executing"
+ | "executed"
+ | "expired"
+ | "rejected"
+ | "canceled"
+ | "execution_failed",
+ ) {
+ const fixture = await canonicalFixture({ state });
+ await db
+ .update(projects)
+ .set({ builderPlanPolicy: "optional" })
+ .where(eq(projects.id, fixture.projectId));
+ if (state === "open") {
+ await db
+ .update(proposals)
+ .set({ decidedBy: null, decidedAt: null })
+ .where(eq(proposals.id, fixture.proposalId));
+ await db
+ .delete(proposalEvents)
+ .where(and(eq(proposalEvents.proposalId, fixture.proposalId), eq(proposalEvents.seq, 2)));
+ } else {
+ await db
+ .update(proposalEvents)
+ .set({
+ actor: { type: "user", id: "github:maintainer", name: "maintainer" },
+ data: { source: "github_command", commentId: 204 },
+ })
+ .where(
+ and(
+ eq(proposalEvents.proposalId, fixture.proposalId),
+ eq(proposalEvents.type, "approved"),
+ ),
+ );
+ await db
+ .update(proposals)
+ .set({ decidedBy: "github:maintainer" })
+ .where(eq(proposals.id, fixture.proposalId));
+ }
+ const repo = (
+ await db.select().from(repos).where(eq(repos.projectId, fixture.projectId)).limit(1)
+ )[0];
+ if (!repo) throw new Error("route repo fixture missing");
+ const contractId = newId("item");
+ const builderAgentId = newId("agent");
+ await db.insert(registryItems).values({
+ id: contractId,
+ orgId: fixture.orgId,
+ scope: "project",
+ projectId: fixture.projectId,
+ kind: "contract",
+ name: `route-builder-${crypto.randomUUID()}`,
+ });
+ await db.insert(agentDefs).values({
+ id: builderAgentId,
+ orgId: fixture.orgId,
+ projectId: fixture.projectId,
+ name: "builder",
+ engine: "codex",
+ model: { primary: "gpt-5" },
+ contractItemId: contractId,
+ triggers: [
+ { type: "command", handle: "/builder" },
+ { type: "command", handle: "/codex-builder" },
+ ],
+ });
+ let nextCommentId = 1;
+ const live: { baseSha: string; issueBody: string; issueError?: Error } = {
+ baseSha: fixture.dispatch.freshnessEvidence.baseSha,
+ issueBody: "Implement it",
+ };
+ const client = {
+ userCanWrite: async () => true,
+ getContent: async () => ({
+ type: "file",
+ encoding: "base64",
+ content: Buffer.from(
+ JSON.stringify({
+ executionLane: { builder: "platform", "codex-builder": "platform" },
+ }),
+ ).toString("base64"),
+ }),
+ listIssueComments: async () => [
+ {
+ id: 204,
+ author: "maintainer",
+ authorType: "User",
+ body: "/builder",
+ createdAt: "2026-08-26T10:00:00Z",
+ url: "https://github.test/comments/204",
+ },
+ ],
+ getDefaultBranchSha: async () => live.baseSha,
+ getIssue: async () => {
+ if (live.issueError) throw live.issueError;
+ return {
+ number: 204,
+ title: "Require a plan",
+ body: live.issueBody,
+ state: "open",
+ user: { login: "requester" },
+ labels: [],
+ html_url: `https://github.test/${repo.owner}/${repo.name}/issues/204`,
+ };
+ },
+ assignIssue: async () => true,
+ createIssueComment: async () => ({ id: nextCommentId++ }),
+ } as never;
+ const payload: TriggerPayload = {
+ action: "created",
+ comment: { id: 204, body: "/builder" },
+ issue: {
+ number: 204,
+ title: "Require a plan",
+ body: "Implement it",
+ user: { login: "requester" },
+ labels: [],
+ html_url: `https://github.test/${repo.owner}/${repo.name}/issues/204`,
+ },
+ repository: { owner: { login: repo.owner }, name: repo.name },
+ sender: { login: "maintainer", type: "User" },
+ };
+ return {
+ ...fixture,
+ architectRunId: String(fixture.dispatch.trigger.architectRunId),
+ builderAgentId,
+ client,
+ live,
+ payload,
+ };
+ }
+
+ async function projectRuns(orgId: string, projectId: string) {
+ return db
+ .select({ id: runs.id })
+ .from(runs)
+ .where(and(eq(runs.orgId, orgId), eq(runs.projectId, projectId)));
+ }
+
+ async function lastDenialCode(orgId: string) {
+ return (await lastDenialPayload(orgId)).code;
+ }
+
+ async function lastDenialPayload(orgId: string): Promise> {
+ const row = (
+ await db
+ .select({ payload: auditEvents.payload })
+ .from(auditEvents)
+ .where(and(eq(auditEvents.orgId, orgId), eq(auditEvents.action, "run.builder_plan_denied")))
+ .orderBy(desc(auditEvents.seq))
+ .limit(1)
+ )[0];
+ return (row?.payload as Record | undefined) ?? {};
+ }
+});
diff --git a/services/api/test/builder-plan-policy.test.ts b/services/api/test/builder-plan-policy.test.ts
new file mode 100644
index 00000000..c7f39992
--- /dev/null
+++ b/services/api/test/builder-plan-policy.test.ts
@@ -0,0 +1,92 @@
+import { describe, expect, it } from "vitest";
+import { builderPlanDecision } from "../src/builder-plan-policy.js";
+
+describe("builder plan policy", () => {
+ it("preserves direct Builder dispatch for optional projects", () => {
+ expect(
+ builderPlanDecision({
+ policy: "optional",
+ mode: "builder",
+ trigger: { type: "manual" },
+ acceptanceValid: false,
+ }),
+ ).toEqual({ allowed: true });
+ });
+
+ it("denies Builder without a plan when the project requires one", () => {
+ expect(
+ builderPlanDecision({
+ policy: "required",
+ mode: "codex-builder",
+ trigger: { type: "web_issue" },
+ acceptanceValid: false,
+ }),
+ ).toEqual({ allowed: false, code: "builder_plan_required" });
+ });
+
+ it("accepts only a durably validated plan_acceptance in required mode", () => {
+ const trigger = {
+ source: "plan_acceptance",
+ proposalId: "prop_test",
+ architectRunId: "run_test",
+ };
+ expect(
+ builderPlanDecision({
+ policy: "required",
+ mode: "builder",
+ trigger,
+ acceptanceValid: false,
+ }),
+ ).toEqual({ allowed: false, code: "builder_plan_context_invalid" });
+ expect(
+ builderPlanDecision({
+ policy: "required",
+ mode: "builder",
+ trigger,
+ acceptanceValid: true,
+ }),
+ ).toEqual({ allowed: true });
+ });
+
+ it("does not gate read-only Architect runs", () => {
+ expect(
+ builderPlanDecision({
+ policy: "required",
+ mode: "codex-architect",
+ trigger: { type: "github_comment" },
+ acceptanceValid: false,
+ }),
+ ).toEqual({ allowed: true });
+ });
+
+ it("derives Builder identity from the canonical agent when mode describes a surface", () => {
+ expect(
+ builderPlanDecision({
+ policy: "required",
+ mode: "conversation",
+ agentName: "codex-builder",
+ trigger: { type: "conversation" },
+ acceptanceValid: false,
+ }),
+ ).toEqual({ allowed: false, code: "builder_plan_required" });
+ });
+
+ it.each([
+ "builder_plan_expired",
+ "builder_plan_rejected",
+ "builder_plan_already_consumed",
+ "builder_plan_stale",
+ "builder_plan_freshness_unavailable",
+ "builder_plan_context_invalid",
+ ] as const)("preserves the stable %s denial code", (denialCode) => {
+ expect(
+ builderPlanDecision({
+ policy: "required",
+ mode: "codex_builder",
+ trigger: { source: "plan_acceptance" },
+ acceptanceValid: false,
+ denialCode,
+ }),
+ ).toEqual({ allowed: false, code: denialCode });
+ });
+});
diff --git a/services/api/test/builder-plan-producer-inventory.test.ts b/services/api/test/builder-plan-producer-inventory.test.ts
new file mode 100644
index 00000000..55af3246
--- /dev/null
+++ b/services/api/test/builder-plan-producer-inventory.test.ts
@@ -0,0 +1,159 @@
+import { readdirSync, readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import ts from "typescript";
+import { describe, expect, it } from "vitest";
+
+const sourceRoot = fileURLToPath(new URL("../src", import.meta.url));
+const expectedProducerCounts = new Map([
+ ["executors.ts", 5],
+ ["github/processor.ts", 1],
+ ["github/router.ts", 1],
+ ["integrations/inbound.ts", 1],
+ ["learning.ts", 1],
+ ["routes/v1/assistant.ts", 1],
+ ["routes/v1/conversations.ts", 1],
+ ["routes/v1/github.ts", 1],
+ ["routes/v1/kb-tasks.ts", 1],
+ ["routes/v1/runs.ts", 3],
+ ["scheduler.ts", 1],
+ ["schedules.ts", 1],
+ ["watchtower/canary.ts", 1],
+]);
+
+describe("Builder plan producer inventory", () => {
+ it("requires every production run insert to declare a reviewed preflight", () => {
+ const inserts: Array<{
+ file: string;
+ relativeFile: string;
+ offset: number;
+ context: string;
+ guarded: boolean;
+ admissionModePersisted: boolean;
+ }> = [];
+ for (const file of typescriptFiles(sourceRoot)) {
+ const source = readFileSync(file, "utf8");
+ const relativeFile = file.slice(sourceRoot.length + 1);
+ const sourceFile = ts.createSourceFile(
+ file,
+ source,
+ ts.ScriptTarget.Latest,
+ true,
+ ts.ScriptKind.TS,
+ );
+ visit(sourceFile, (node) => {
+ if (!isRunsInsert(node)) return;
+ const offset = node.getStart(sourceFile);
+ inserts.push({
+ file,
+ relativeFile,
+ offset,
+ context: source.slice(Math.max(0, offset - 220), offset + 220),
+ guarded: hasTransactionalAdmissionAncestor(node, relativeFile, sourceFile),
+ admissionModePersisted: persistsAdmissionMode(node, relativeFile, sourceFile),
+ });
+ });
+ }
+
+ expect(inserts).toHaveLength(19);
+ expect(
+ new Map(
+ [...new Set(inserts.map((insert) => insert.relativeFile))].map((file) => [
+ file,
+ inserts.filter((insert) => insert.relativeFile === file).length,
+ ]),
+ ),
+ ).toEqual(expectedProducerCounts);
+ for (const insert of inserts) {
+ expect(
+ insert.context,
+ `${insert.file}:${insert.offset} inserts a run without the Builder plan producer review marker`,
+ ).toMatch(/builder-plan-preflight:\s*[a-z0-9_:-]+\s*(?:\n|\r\n)/);
+ expect(
+ insert.guarded,
+ `${insert.file}:${insert.offset} does not couple Builder preflight and run insertion under the shared project lock`,
+ ).toBe(true);
+ expect(
+ insert.admissionModePersisted,
+ `${insert.file}:${insert.offset} does not persist the immutable Builder admission mode`,
+ ).toBe(true);
+ }
+ });
+});
+
+function isRunsInsert(node: ts.Node): node is ts.CallExpression {
+ const target = ts.isCallExpression(node) ? node.arguments.at(0) : undefined;
+ return (
+ ts.isCallExpression(node) &&
+ ts.isPropertyAccessExpression(node.expression) &&
+ node.expression.name.text === "insert" &&
+ node.arguments.length === 1 &&
+ target !== undefined &&
+ ts.isIdentifier(target) &&
+ target.text === "runs"
+ );
+}
+
+function persistsAdmissionMode(
+ insert: ts.CallExpression,
+ relativeFile: string,
+ sourceFile: ts.SourceFile,
+) {
+ for (let current: ts.Node | undefined = insert.parent; current; current = current.parent) {
+ if (!ts.isArrowFunction(current) && !ts.isFunctionExpression(current)) continue;
+ const call: ts.Node = current.parent;
+ if (!ts.isCallExpression(call) || !call.arguments.includes(current)) continue;
+ const body = current.getText(sourceFile);
+ if (ts.isIdentifier(call.expression) && call.expression.text === "withBuilderPlanPreflight") {
+ const parameter = current.parameters[1]?.name;
+ if (!parameter || !ts.isIdentifier(parameter)) return false;
+ return new RegExp(`mode\\s*:\\s*${parameter.text}\\.mode\\b`).test(body);
+ }
+ if (
+ relativeFile === "scheduler.ts" &&
+ ts.isPropertyAccessExpression(call.expression) &&
+ call.expression.name.text === "transaction"
+ ) {
+ return (
+ /admittedMode\s*=\s*admission\.mode\b/.test(body) && /mode\s*:\s*admittedMode\b/.test(body)
+ );
+ }
+ }
+ return false;
+}
+
+function hasTransactionalAdmissionAncestor(
+ insert: ts.CallExpression,
+ relativeFile: string,
+ sourceFile: ts.SourceFile,
+) {
+ for (let current: ts.Node | undefined = insert.parent; current; current = current.parent) {
+ if (!ts.isArrowFunction(current) && !ts.isFunctionExpression(current)) continue;
+ const call: ts.Node = current.parent;
+ if (!ts.isCallExpression(call) || !call.arguments.includes(current)) continue;
+ if (ts.isIdentifier(call.expression) && call.expression.text === "withBuilderPlanPreflight") {
+ return true;
+ }
+ if (
+ relativeFile === "scheduler.ts" &&
+ ts.isPropertyAccessExpression(call.expression) &&
+ call.expression.name.text === "transaction"
+ ) {
+ const body = current.getText(sourceFile);
+ return body.includes("lockBuilderPlanPolicy(") && body.includes("assertBuilderPlanDispatch(");
+ }
+ }
+ return false;
+}
+
+function visit(node: ts.Node, callback: (node: ts.Node) => void) {
+ callback(node);
+ node.forEachChild((child) => visit(child, callback));
+}
+
+function typescriptFiles(directory: string): string[] {
+ return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
+ const path = `${directory}/${entry.name}`;
+ if (entry.isDirectory()) return typescriptFiles(path);
+ return entry.isFile() && entry.name.endsWith(".ts") ? [path] : [];
+ });
+}
diff --git a/services/api/test/github-issue-revision.test.ts b/services/api/test/github-issue-revision.test.ts
new file mode 100644
index 00000000..4d3597d9
--- /dev/null
+++ b/services/api/test/github-issue-revision.test.ts
@@ -0,0 +1,95 @@
+import { describe, expect, it } from "vitest";
+import {
+ githubIssueRevisionContext,
+ githubIssueRevisionSha256,
+} from "../src/github/issue-revision.js";
+
+describe("GitHub issue revision", () => {
+ const issue = {
+ title: "Keep sources consistent",
+ body: "Apply the same rule on every surface.",
+ state: "open",
+ user: { login: "requester" },
+ labels: [{ name: "frontend" }, "delivery"],
+ html_url: "https://github.test/theam/aifindr-ui/issues/116",
+ };
+ const comments = [
+ {
+ id: 1,
+ author: "requester",
+ authorType: "User",
+ body: "/architect\nInclude the permission fix.",
+ createdAt: "2026-08-26T10:00:00Z",
+ url: "https://github.test/comments/1",
+ },
+ {
+ id: 2,
+ author: "facility-agent[bot]",
+ authorType: "Bot",
+ body: "\n_Last updated: now_",
+ createdAt: "2026-08-26T10:01:00Z",
+ url: "https://github.test/comments/2",
+ },
+ {
+ id: 3,
+ author: "maintainer",
+ authorType: "User",
+ body: "/builder",
+ createdAt: "2026-08-26T10:02:00Z",
+ url: "https://github.test/comments/3",
+ },
+ {
+ id: 4,
+ author: "facility-agent[bot]",
+ authorType: "Bot",
+ body: "\nPublished plan",
+ createdAt: "2026-08-26T10:03:00Z",
+ url: "https://github.test/comments/4",
+ },
+ ];
+
+ it("is stable across label ordering and Facility or approval-only comments", () => {
+ const [requestComment, progressComment, approvalComment] = comments;
+ if (!requestComment || !progressComment || !approvalComment) {
+ throw new Error("issue revision comments fixture is incomplete");
+ }
+ const baseline = githubIssueRevisionSha256(githubIssueRevisionContext(issue, comments));
+ const updated = githubIssueRevisionSha256(
+ githubIssueRevisionContext({ ...issue, labels: ["delivery", { name: "frontend" }] }, [
+ requestComment,
+ { ...progressComment, body: "\nchanged" },
+ { ...approvalComment, body: " /codex-builder! " },
+ ]),
+ );
+ expect(updated).toBe(baseline);
+ });
+
+ it("changes for material issue, state, or comment edits", () => {
+ const baseline = githubIssueRevisionSha256(githubIssueRevisionContext(issue, comments));
+ expect(
+ githubIssueRevisionSha256(
+ githubIssueRevisionContext(issue, [
+ ...comments,
+ {
+ id: 5,
+ author: "maintainer",
+ authorType: "User",
+ body: "/builder and also change the API contract",
+ createdAt: "2026-08-26T10:04:00Z",
+ url: "https://github.test/comments/5",
+ },
+ ]),
+ ),
+ ).not.toBe(baseline);
+ expect(
+ githubIssueRevisionSha256(
+ githubIssueRevisionContext({ ...issue, state: "closed" }, comments),
+ ),
+ ).not.toBe(baseline);
+ expect(
+ githubIssueRevisionSha256(
+ githubIssueRevisionContext({ ...issue, body: "A changed scope." }, comments),
+ ),
+ ).not.toBe(baseline);
+ });
+});
diff --git a/services/api/test/github-platform-lane.test.ts b/services/api/test/github-platform-lane.test.ts
index 6e8b7adf..bc2c494d 100644
--- a/services/api/test/github-platform-lane.test.ts
+++ b/services/api/test/github-platform-lane.test.ts
@@ -1,5 +1,6 @@
import { generateApiKey, hashKey, newId } from "@facility/core";
import {
+ actionTypes,
agentDefs,
apiKeys,
auditEvents,
@@ -16,6 +17,7 @@ import {
platformIssues,
previewSandboxes,
projects,
+ proposalEvents,
proposals,
registryItems,
repos,
@@ -33,6 +35,7 @@ import { buildApp } from "../src/app.js";
import { executeApprovedProposal } from "../src/executors.js";
import type { GithubClientFactory, Octokit } from "../src/github/client.js";
import { pullRequestBodyForIssue } from "../src/github/closing-issues.js";
+import { githubIssueRevisionSha256 } from "../src/github/issue-revision.js";
import { syncRepoIssues, upsertGhIssueFromWebhook } from "../src/github/issues-sync.js";
import {
enqueueGithubIssuesSync,
@@ -49,8 +52,10 @@ import {
finishRun,
publishRunDelivery,
RunDeliveryLeaseLostError,
+ reconcileArchitectPlanPublications,
} from "../src/sandbox/orchestrator.js";
import type { AppConfig } from "../src/types.js";
+import { expireHitlProposals } from "../src/watchtower/hitl.js";
const databaseUrl =
process.env.DATABASE_URL ?? "postgres://facility:facility@127.0.0.1:5461/facility_test";
@@ -2303,18 +2308,43 @@ describe("github platform lane", async () => {
observedAt: new Date("2026-08-01T01:02:00Z"),
},
]);
- const legacyProposal = await app.inject({
- method: "POST",
- url: "/v1/proposals",
- headers: { cookie },
- payload: {
- projectId,
- actionType: "plan_acceptance",
- payload: { issueNumber: number },
- contextMd: "Legacy proposal without a repository id",
- },
+ const planAcceptance = (
+ await db
+ .select({ id: actionTypes.id })
+ .from(actionTypes)
+ .where(and(eq(actionTypes.orgId, orgId), eq(actionTypes.name, "plan_acceptance")))
+ .limit(1)
+ )[0];
+ if (!planAcceptance) throw new Error("plan_acceptance action fixture missing");
+ const legacyArchitectRun = await insertRun({
+ mode: "architect",
+ status: "succeeded",
+ gh: { issueNumber: number },
+ });
+ const legacyProposal = (
+ await db
+ .insert(proposals)
+ .values({
+ id: newId("prop"),
+ orgId,
+ projectId,
+ runId: legacyArchitectRun.id,
+ actionTypeId: planAcceptance.id,
+ payload: { architectRunId: legacyArchitectRun.id, issueNumber: number },
+ contextMd: "Legacy proposal without a repository id",
+ expiresAt: new Date(Date.now() + 3_600_000),
+ })
+ .returning()
+ )[0];
+ if (!legacyProposal) throw new Error("legacy proposal fixture missing");
+ await db.insert(proposalEvents).values({
+ orgId,
+ proposalId: legacyProposal.id,
+ seq: 1,
+ type: "open",
+ actor: { type: "agent", id: legacyArchitectRun.id },
+ data: { source: "architect_run" },
});
- expect(legacyProposal.statusCode, legacyProposal.body).toBe(200);
const pipeline = await app.inject({
method: "GET",
@@ -3109,7 +3139,14 @@ describe("github platform lane", async () => {
}) as never;
const repo = await insertRepoWithInstallation(`trigger-${Date.now()}`);
await insertIssue(repo.id, 44, "open", "2026-03-01T00:00:00Z");
- await insertAgent("builder");
+ const storyBuilder = await insertAgent("builder");
+ await db
+ .update(agentDefs)
+ .set({
+ name: "delivery-specialist",
+ triggers: [{ type: "command", handle: "/builder" }],
+ })
+ .where(eq(agentDefs.id, storyBuilder.id));
const forged = await app.inject({
method: "POST",
@@ -3162,6 +3199,7 @@ describe("github platform lane", async () => {
payload: { agent: "builder" },
});
expect(response.statusCode).toBe(200);
+ expect(response.json().mode).toBe("builder");
expect(response.json().gh.issueNumber).toBe(44);
const [manifestSyncedRepo] = await db.select().from(repos).where(eq(repos.id, repo.id));
expect(manifestSyncedRepo?.renderAnswers).toMatchObject({
@@ -3404,6 +3442,123 @@ describe("github platform lane", async () => {
app.githubClientFactory = undefined;
});
+ it("denies Story Build before GitHub access, row creation, or enqueue when plans are required", async () => {
+ const suffix = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
+ const governedProject = (
+ await db
+ .insert(projects)
+ .values({
+ id: newId("proj"),
+ orgId,
+ name: `Governed Story Build ${suffix}`,
+ slug: `governed-story-build-${suffix}`,
+ builderPlanPolicy: "required",
+ settings: {},
+ })
+ .returning()
+ )[0];
+ if (!governedProject) throw new Error("governed project insert failed");
+ const contract = (
+ await db
+ .insert(registryItems)
+ .values({
+ id: newId("item"),
+ orgId,
+ scope: "project",
+ projectId: governedProject.id,
+ kind: "agent_contract",
+ name: `governed-story-build-${suffix}`,
+ latestVersion: 1,
+ })
+ .returning()
+ )[0];
+ if (!contract) throw new Error("governed contract insert failed");
+ const governedBuilder = (
+ await db
+ .insert(agentDefs)
+ .values({
+ id: newId("agent"),
+ orgId,
+ projectId: governedProject.id,
+ name: "delivery-specialist",
+ engine: "codex",
+ model: { primary: "gpt-5.5" },
+ contractItemId: contract.id,
+ triggers: [{ type: "command", handle: "/builder" }],
+ enabled: true,
+ })
+ .returning()
+ )[0];
+ if (!governedBuilder) throw new Error("governed Builder insert failed");
+ const governedRepo = (
+ await db
+ .insert(repos)
+ .values({
+ id: newId("repo"),
+ orgId,
+ projectId: governedProject.id,
+ owner: `governed-story-${suffix}`,
+ name: "facility",
+ defaultBranch: "main",
+ })
+ .returning()
+ )[0];
+ if (!governedRepo) throw new Error("governed repository insert failed");
+ await db.insert(ghIssues).values({
+ id: newId("ghi"),
+ orgId,
+ projectId: governedProject.id,
+ repoId: governedRepo.id,
+ number: 204,
+ title: "Build only after Gate 1",
+ state: "open",
+ labels: [],
+ assignees: [],
+ htmlUrl: `https://github.test/${governedRepo.owner}/${governedRepo.name}/issues/204`,
+ });
+
+ const originalFactory = app.githubClientFactory;
+ const originalEnqueue = app.enqueue;
+ let githubFactoryCalls = 0;
+ const enqueued: Array<{ queue: string; data: Record }> = [];
+ app.githubClientFactory = async () => {
+ githubFactoryCalls += 1;
+ throw new Error("Story Build denial reached GitHub");
+ };
+ app.enqueue = async (queue, data) => {
+ enqueued.push({ queue, data });
+ return null;
+ };
+ const before = await db
+ .select({ id: runs.id })
+ .from(runs)
+ .where(eq(runs.projectId, governedProject.id));
+ try {
+ const denied = await app.inject({
+ method: "POST",
+ url: `/v1/projects/${governedProject.id}/issues/204/trigger?repoId=${governedRepo.id}`,
+ headers: { cookie },
+ payload: { agent: "builder" },
+ });
+ expect(denied.statusCode, denied.body).toBe(409);
+ expect(denied.json().error.code).toBe("builder_plan_required");
+ expect(
+ await db.select({ id: runs.id }).from(runs).where(eq(runs.projectId, governedProject.id)),
+ ).toHaveLength(before.length);
+ expect(githubFactoryCalls).toBe(0);
+ expect(enqueued).toEqual([]);
+ const denial = (
+ await db.select().from(auditEvents).where(eq(auditEvents.projectId, governedProject.id))
+ ).find((event) => event.action === "run.builder_plan_denied");
+ expect(denial).toMatchObject({
+ payload: { code: "builder_plan_required", source: "web_issue_preflight" },
+ });
+ } finally {
+ app.githubClientFactory = originalFactory;
+ app.enqueue = originalEnqueue;
+ }
+ });
+
it("pins project-scoped keys to their project across the issue mirror (404 elsewhere)", async () => {
// A key pinned to ANOTHER project — with full engineer permissions — must
// not read this project's issues nor trigger runs in it.
@@ -3677,6 +3832,7 @@ describe("github platform lane", async () => {
const run = await insertRun({
status: "running",
gh: { owner: repo.owner, repo: repo.name, issueNumber: 91 },
+ workspaceBaseSha: "b".repeat(40),
});
const queued: Array<{ queue: string; data: Record }> = [];
await finishRun(
@@ -3688,6 +3844,7 @@ describe("github platform lane", async () => {
changed: true,
branch: "feature/exact-delivery",
headSha: "expected-sha",
+ baseSha: "a".repeat(40),
pullRequestTitle: "fix: bind delivery to the pushed commit",
pullRequestBody: "Exact delivery",
},
@@ -3707,7 +3864,12 @@ describe("github platform lane", async () => {
owner: repo.owner,
repoName: repo.name,
expectedHeadSha: "expected-sha",
+ baseSha: "a".repeat(40),
});
+ const [finishedRun] = await db.select().from(runs).where(eq(runs.id, run.id));
+ expect((finishedRun?.receipt as { github?: { base_sha?: string } })?.github?.base_sha).toBe(
+ "a".repeat(40),
+ );
let createCalls = 0;
const blocked = await deliverPendingRunDeliveries(db, config, {
@@ -4552,10 +4714,37 @@ describe("github platform lane", async () => {
it("finishRun publishes an architect plan and opens the human Gate 1 proposal", async () => {
const repo = await insertRepoWithInstallation(`plan-${Date.now()}`);
+ const workspaceBaseSha = "d".repeat(40);
+ const issueRequest = {
+ title: "Preserve Architect publication marker",
+ body: "Publish one immutable plan.",
+ state: "open",
+ author: "maintainer",
+ url: `https://github.test/${repo.owner}/${repo.name}/issues/71`,
+ labels: ["governance"],
+ comments: [],
+ };
const run = await insertRun({
mode: "architect",
status: "running",
- gh: { owner: repo.owner, repo: repo.name, issueNumber: 71 },
+ workspaceBaseSha,
+ trigger: {
+ type: "github_comment",
+ repo: { id: repo.id, owner: repo.owner, name: repo.name },
+ issue: { number: 71 },
+ request: issueRequest,
+ },
+ gh: {
+ owner: repo.owner,
+ repo: repo.name,
+ issueNumber: 71,
+ progressComment: {
+ id: 7101,
+ command: "architect",
+ issueTitle: "Preserve Architect publication marker",
+ sender: "maintainer",
+ },
+ },
});
await db.insert(runEvents).values({
orgId,
@@ -4565,7 +4754,255 @@ describe("github platform lane", async () => {
data: { text: "1. Add the behavior.\n2. Prove it with the mirror test." },
});
const comments: string[] = [];
+ let missingProgressUpdates = 0;
+ const publicationFactory = async () =>
+ ({
+ rest: {
+ issues: {
+ listComments: async () => ({ data: [] }),
+ createComment: async ({ body }: { body: string }) => {
+ comments.push(body);
+ return { data: { id: 1 } };
+ },
+ updateComment: async () => {
+ missingProgressUpdates += 1;
+ throw Object.assign(new Error("progress comment deleted"), { status: 404 });
+ },
+ },
+ repos: {},
+ pulls: {},
+ git: {},
+ },
+ }) as never;
+ await expect(
+ finishRun(
+ db,
+ run,
+ { status: "succeeded" },
+ {
+ config,
+ githubClientFactory: publicationFactory,
+ afterArchitectPlanOutboxWrite: () => {
+ throw new Error("injected_outbox_commit_failure");
+ },
+ },
+ ),
+ ).rejects.toThrow("injected_outbox_commit_failure");
+ expect(comments).toHaveLength(0);
+ expect((await db.select().from(runs).where(eq(runs.id, run.id)).limit(1))[0]?.status).toBe(
+ "running",
+ );
+ expect(await db.select().from(proposals).where(eq(proposals.runId, run.id))).toHaveLength(0);
+
const finished = await finishRun(
+ db,
+ run,
+ { status: "succeeded" },
+ {
+ config,
+ githubClientFactory: publicationFactory,
+ },
+ );
+ expect(finished.status).toBe("succeeded");
+ expect(missingProgressUpdates).toBe(1);
+ expect(comments).toHaveLength(1);
+ expect(comments.at(-1)).toContain("Human Gate 1");
+ expect(comments.at(-1)).toContain("Prove it with the mirror test");
+ expect(comments.at(-1)).toContain("");
+ expect(body).toContain("No Builder run was created");
+ expect(body).toContain("approved base and live issue revision");
+ expect(body).toContain("`builder_plan_freshness_unavailable`");
+ });
});
diff --git a/services/api/test/sandbox.test.ts b/services/api/test/sandbox.test.ts
index 81d8f38a..28bf3d90 100644
--- a/services/api/test/sandbox.test.ts
+++ b/services/api/test/sandbox.test.ts
@@ -28,7 +28,7 @@ import {
seed,
virtualKeys,
} from "@facility/db";
-import { eq } from "drizzle-orm";
+import { and, eq, sql } from "drizzle-orm";
import postgres from "postgres";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildApp } from "../src/app.js";
@@ -182,6 +182,226 @@ describe("sandbox api", async () => {
await client.end();
});
+ it("fails a queued required-plan Builder before any sandbox or credential is created", async () => {
+ const suffix = crypto.randomUUID().replaceAll("-", "");
+ const guardedProject = (
+ await db
+ .insert(projects)
+ .values({
+ id: newId("proj"),
+ orgId,
+ name: "Required Builder Gate",
+ slug: `required-builder-${suffix}`,
+ builderPlanPolicy: "required",
+ settings: {},
+ })
+ .returning()
+ )[0];
+ if (!guardedProject) throw new Error("guarded project fixture missing");
+ const contract = (
+ await db
+ .insert(registryItems)
+ .values({
+ id: newId("item"),
+ orgId,
+ projectId: guardedProject.id,
+ scope: "project",
+ kind: "agent_contract",
+ name: `required-builder-contract-${suffix}`,
+ })
+ .returning()
+ )[0];
+ if (!contract) throw new Error("guarded contract fixture missing");
+ const agent = (
+ await db
+ .insert(agentDefs)
+ .values({
+ id: newId("agent"),
+ orgId,
+ projectId: guardedProject.id,
+ name: "codex_builder",
+ engine: "codex",
+ model: { primary: "gpt-5" },
+ contractItemId: contract.id,
+ })
+ .returning()
+ )[0];
+ if (!agent) throw new Error("guarded Builder fixture missing");
+ const run = (
+ await db
+ .insert(runs)
+ .values({
+ id: newId("run"),
+ orgId,
+ projectId: guardedProject.id,
+ agentDefId: agent.id,
+ mode: "builder",
+ engine: agent.engine,
+ trigger: { type: "conversation", message: "bypass attempt" },
+ createdBy: { type: "system", id: "worker-policy-test" },
+ })
+ .returning()
+ )[0];
+ if (!run) throw new Error("guarded run fixture missing");
+ let launches = 0;
+ const driver: SandboxDriver = {
+ name: "docker",
+ launch: async () => {
+ launches += 1;
+ return { ref: "must-not-launch" };
+ },
+ status: async () => "running",
+ async *logs() {},
+ stop: async () => undefined,
+ destroy: async () => undefined,
+ };
+
+ await expect(
+ dispatchRun(config, { runId: run.id, orgId }, { sandboxDriver: async () => driver }),
+ ).rejects.toMatchObject({ code: "builder_plan_required" });
+ expect(launches).toBe(0);
+ await expect(
+ db.select({ id: virtualKeys.id }).from(virtualKeys).where(eq(virtualKeys.runId, run.id)),
+ ).resolves.toHaveLength(0);
+ await expect(
+ db.select({ id: apiKeys.id }).from(apiKeys).where(eq(apiKeys.runId, run.id)),
+ ).resolves.toHaveLength(0);
+ const failed = (
+ await db
+ .select({ status: runs.status, error: runs.error })
+ .from(runs)
+ .where(eq(runs.id, run.id))
+ .limit(1)
+ )[0];
+ expect(failed).toEqual({ status: "failed", error: "builder_plan_required" });
+ const result = (
+ await db
+ .select({ data: runEvents.data })
+ .from(runEvents)
+ .where(and(eq(runEvents.runId, run.id), eq(runEvents.type, "result")))
+ .limit(1)
+ )[0];
+ expect(result?.data).toMatchObject({
+ status: "failed",
+ kind: "builder_plan_denied",
+ error: "builder_plan_required",
+ });
+ });
+
+ it("denies after a concurrent agent mutation wins before the claimed worker guard", async () => {
+ const suffix = crypto.randomUUID().replaceAll("-", "");
+ const guardedProject = (
+ await db
+ .insert(projects)
+ .values({
+ id: newId("proj"),
+ orgId,
+ name: "Worker Agent Snapshot Gate",
+ slug: `worker-agent-snapshot-${suffix}`,
+ builderPlanPolicy: "required",
+ settings: {},
+ })
+ .returning()
+ )[0];
+ if (!guardedProject) throw new Error("worker snapshot project fixture missing");
+ const contract = (
+ await db
+ .insert(registryItems)
+ .values({
+ id: newId("item"),
+ orgId,
+ projectId: guardedProject.id,
+ scope: "project",
+ kind: "agent_contract",
+ name: `worker-agent-snapshot-${suffix}`,
+ })
+ .returning()
+ )[0];
+ if (!contract) throw new Error("worker snapshot contract fixture missing");
+ const agent = (
+ await db
+ .insert(agentDefs)
+ .values({
+ id: newId("agent"),
+ orgId,
+ projectId: guardedProject.id,
+ name: "worker-canary-specialist",
+ engine: "codex",
+ model: { primary: "gpt-5" },
+ contractItemId: contract.id,
+ triggers: [{ type: "manual" }],
+ })
+ .returning()
+ )[0];
+ if (!agent) throw new Error("worker snapshot agent fixture missing");
+ const run = (
+ await db
+ .insert(runs)
+ .values({
+ id: newId("run"),
+ orgId,
+ projectId: guardedProject.id,
+ agentDefId: agent.id,
+ mode: "conversation",
+ engine: agent.engine,
+ trigger: { type: "conversation", message: "snapshot race" },
+ createdBy: { type: "system", id: "worker-snapshot-test" },
+ })
+ .returning()
+ )[0];
+ if (!run) throw new Error("worker snapshot run fixture missing");
+ let launches = 0;
+ const driver: SandboxDriver = {
+ name: "docker",
+ launch: async () => {
+ launches += 1;
+ return { ref: "must-not-launch-after-agent-race" };
+ },
+ status: async () => "running",
+ async *logs() {},
+ stop: async () => undefined,
+ destroy: async () => undefined,
+ };
+ let dispatch: Promise | undefined;
+ await db.transaction(async (tx) => {
+ await tx.execute(
+ sql`select pg_advisory_xact_lock(hashtextextended(${`builder-plan:${orgId}:${guardedProject.id}`}, 0))`,
+ );
+ await tx
+ .update(agentDefs)
+ .set({ triggers: [{ type: "command", handle: "/builder" }] })
+ .where(eq(agentDefs.id, agent.id));
+ dispatch = dispatchRun(
+ config,
+ { runId: run.id, orgId },
+ { sandboxDriver: async () => driver },
+ );
+ let provisioning = false;
+ for (let attempt = 0; attempt < 100; attempt += 1) {
+ const current = (
+ await db.select({ status: runs.status }).from(runs).where(eq(runs.id, run.id)).limit(1)
+ )[0];
+ if (current?.status === "provisioning") {
+ provisioning = true;
+ break;
+ }
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ }
+ expect(provisioning).toBe(true);
+ });
+ if (!dispatch) throw new Error("worker snapshot dispatch did not start");
+ await expect(dispatch).rejects.toMatchObject({ code: "builder_plan_required" });
+ expect(launches).toBe(0);
+ expect(
+ (
+ await db
+ .select({ status: runs.status, error: runs.error })
+ .from(runs)
+ .where(eq(runs.id, run.id))
+ )[0],
+ ).toEqual({ status: "failed", error: "builder_plan_required" });
+ });
+
it("imageExists reports daemon image presence without pulling", async () => {
const withInspect = (inspect: () => Promise) =>
new DockerSandboxDriver({ getImage: () => ({ inspect }) } as unknown as ConstructorParameters<
@@ -940,6 +1160,62 @@ describe("sandbox api", async () => {
expect(stored).toEqual({ engineSessionId: "sess_early_123", status: "running" });
});
+ it("records an authenticated runner workspace base once and accepts exact replays", async () => {
+ const token = "frt_workspace_base";
+ const run = await insertRunnerRun(token, "running");
+ const otherToken = "frt_workspace_other_run";
+ await insertRunnerRun(otherToken, "running");
+ const baseSha = "A".repeat(40);
+
+ const invalid = await app.inject({
+ method: "POST",
+ url: `/internal/runs/${run.id}/workspace`,
+ headers: { authorization: `Bearer ${token}` },
+ payload: { baseSha: "not-a-commit" },
+ });
+ expect(invalid.statusCode).toBe(400);
+
+ const crossRun = await app.inject({
+ method: "POST",
+ url: `/internal/runs/${run.id}/workspace`,
+ headers: { authorization: `Bearer ${otherToken}` },
+ payload: { baseSha },
+ });
+ expect(crossRun.statusCode).toBe(401);
+
+ const first = await app.inject({
+ method: "POST",
+ url: `/internal/runs/${run.id}/workspace`,
+ headers: { authorization: `Bearer ${token}` },
+ payload: { baseSha },
+ });
+ expect(first.statusCode, first.body).toBe(200);
+ expect(first.json()).toEqual({ baseSha: "a".repeat(40) });
+
+ const replay = await app.inject({
+ method: "POST",
+ url: `/internal/runs/${run.id}/workspace`,
+ headers: { authorization: `Bearer ${token}` },
+ payload: { baseSha: "a".repeat(40) },
+ });
+ expect(replay.statusCode, replay.body).toBe(200);
+
+ const mismatch = await app.inject({
+ method: "POST",
+ url: `/internal/runs/${run.id}/workspace`,
+ headers: { authorization: `Bearer ${token}` },
+ payload: { baseSha: "b".repeat(40) },
+ });
+ expect(mismatch.statusCode).toBe(409);
+ expect(mismatch.json()).toMatchObject({ error: { code: "workspace_base_mismatch" } });
+
+ const [stored] = await db
+ .select({ workspaceBaseSha: runs.workspaceBaseSha })
+ .from(runs)
+ .where(eq(runs.id, run.id));
+ expect(stored?.workspaceBaseSha).toBe("a".repeat(40));
+ });
+
it("finishRun synchronizes only trusted qualifying security findings", async () => {
const suffix = Date.now();
const installation = (
@@ -1238,6 +1514,8 @@ describe("sandbox api", async () => {
it("stores actual check outcomes and provenance in the run receipt", async () => {
const token = "frt_receipt_checks";
const run = await insertRunnerRun(token, "running");
+ const workspaceBaseSha = "c".repeat(40);
+ await db.update(runs).set({ workspaceBaseSha }).where(eq(runs.id, run.id));
await appendRunEvents(db, orgId, run.id, [
{
type: "check",
@@ -1266,6 +1544,9 @@ describe("sandbox api", async () => {
{ name: "pnpm test", status: "passed", source: "platform", exit_code: 0 },
{ name: "agent smoke", status: "skipped", source: "agent" },
]);
+ expect((finished?.receipt as { github?: { base_sha?: string } })?.github?.base_sha).toBe(
+ workspaceBaseSha,
+ );
expect((finished?.receipt as { checks_truncated?: boolean })?.checks_truncated).toBe(false);
});
diff --git a/services/api/test/scheduler.test.ts b/services/api/test/scheduler.test.ts
index 95e5e973..8fd2b45e 100644
--- a/services/api/test/scheduler.test.ts
+++ b/services/api/test/scheduler.test.ts
@@ -9,11 +9,13 @@ import {
registryItems,
runEvents,
runs,
+ schedulerWatermarks,
} from "@facility/db";
import { eq } from "drizzle-orm";
import postgres from "postgres";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { runScheduledAgents } from "../src/scheduler.js";
+import { runAgentSchedules } from "../src/schedules.js";
import type { AppConfig } from "../src/types.js";
const databaseUrl =
@@ -203,9 +205,98 @@ describe("scheduled agents", async () => {
expect(stored?.lastScheduledAt?.toISOString()).toBe(now.toISOString());
});
+ it("does not create or enqueue a legacy scheduled Builder run when plans are required", async () => {
+ const now = new Date("2032-09-14T11:08:30.000Z");
+ const agent = await insertAgent("governed-legacy-schedule", {
+ triggers: [
+ { type: "schedule", config: { cron: "* * * * *", timezone: "UTC" } },
+ { type: "command", command: "builder" },
+ ],
+ lastScheduledAt: new Date("2032-09-14T11:07:00.000Z"),
+ builderPlanPolicy: "required",
+ });
+ const enqueued: Array<{ queue: string; data: Record }> = [];
+
+ await runScheduledAgents(
+ config,
+ async (queue, data) => {
+ enqueued.push({ queue, data });
+ },
+ { now },
+ );
+
+ expect(await db.select().from(runs).where(eq(runs.agentDefId, agent.id))).toEqual([]);
+ expect(enqueued.filter((job) => job.data.orgId === agent.orgId)).toEqual([]);
+ const denial = (
+ await db.select().from(auditEvents).where(eq(auditEvents.orgId, agent.orgId))
+ ).find(
+ (event) =>
+ event.action === "run.builder_plan_denied" &&
+ (event.payload as { source?: unknown }).source === "legacy_scheduler",
+ );
+ expect(denial).toMatchObject({
+ projectId: agent.projectId,
+ payload: { code: "builder_plan_required", source: "legacy_scheduler" },
+ });
+ const stored = (
+ await db.select().from(agentDefs).where(eq(agentDefs.id, agent.id)).limit(1)
+ )[0];
+ expect(stored?.lastScheduledAt?.toISOString()).toBe(now.toISOString());
+ });
+
+ it("does not create or enqueue a canonical scheduled Builder run when plans are required", async () => {
+ const now = new Date("2032-09-14T12:08:30.000Z");
+ const agent = await insertAgent("governed-canonical-schedule", {
+ triggers: [
+ { type: "schedule", config: { cron: "* * * * *", timezone: "UTC" } },
+ { type: "command", command: "builder" },
+ ],
+ lastScheduledAt: new Date("2032-09-14T12:07:00.000Z"),
+ builderPlanPolicy: "required",
+ });
+ await db
+ .insert(schedulerWatermarks)
+ .values({
+ name: "agent.schedules",
+ lastTick: new Date("2032-09-14T12:07:00.000Z"),
+ })
+ .onConflictDoUpdate({
+ target: schedulerWatermarks.name,
+ set: { lastTick: new Date("2032-09-14T12:07:00.000Z"), updatedAt: new Date() },
+ });
+ const enqueued: Array<{ queue: string; data: Record }> = [];
+
+ await runAgentSchedules(
+ config,
+ async (queue, data) => {
+ enqueued.push({ queue, data });
+ return null;
+ },
+ now,
+ );
+
+ expect(await db.select().from(runs).where(eq(runs.agentDefId, agent.id))).toEqual([]);
+ expect(enqueued.filter((job) => job.data.orgId === agent.orgId)).toEqual([]);
+ const denial = (
+ await db.select().from(auditEvents).where(eq(auditEvents.orgId, agent.orgId))
+ ).find(
+ (event) =>
+ event.action === "run.builder_plan_denied" &&
+ (event.payload as { source?: unknown }).source === "agent_scheduler",
+ );
+ expect(denial).toMatchObject({
+ projectId: agent.projectId,
+ payload: { code: "builder_plan_required", source: "agent_scheduler" },
+ });
+ });
+
async function insertAgent(
name: string,
- input: { triggers: unknown[]; lastScheduledAt: Date | null },
+ input: {
+ triggers: unknown[];
+ lastScheduledAt: Date | null;
+ builderPlanPolicy?: "optional" | "required";
+ },
) {
const suffix = `${name}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const orgId = newId("org");
@@ -216,6 +307,7 @@ describe("scheduled agents", async () => {
orgId,
name: `Project ${suffix}`,
slug: `project-${suffix}`,
+ builderPlanPolicy: input.builderPlanPolicy ?? "optional",
settings: {},
});
const item = (
diff --git a/services/api/test/signals.test.ts b/services/api/test/signals.test.ts
index 1422a313..cdceae51 100644
--- a/services/api/test/signals.test.ts
+++ b/services/api/test/signals.test.ts
@@ -1,5 +1,7 @@
import { newId } from "@facility/core";
import {
+ agentDefs,
+ auditEvents,
createDb,
inboundEvents,
integrations,
@@ -7,6 +9,8 @@ import {
orgs,
platformIssues,
projects,
+ registryItems,
+ runs,
} from "@facility/db";
import { eq } from "drizzle-orm";
import postgres from "postgres";
@@ -94,4 +98,98 @@ describe("typed operational signals", async () => {
)[0];
expect(issue).toMatchObject({ projectId, kind: "deployment_failure", state: "resolved" });
});
+
+ it("does not create or enqueue an inbound Builder run when plans are required", async () => {
+ const suffix = newId("evt");
+ const orgId = newId("org");
+ const projectId = newId("proj");
+ const integrationId = newId("int");
+ const inboundEventId = newId("evt");
+ await db.insert(orgs).values({ id: orgId, name: suffix, slug: suffix });
+ await db.insert(projects).values({
+ id: projectId,
+ orgId,
+ name: "Governed inbound",
+ slug: `governed-inbound-${suffix}`,
+ builderPlanPolicy: "required",
+ settings: {},
+ });
+ const contract = (
+ await db
+ .insert(registryItems)
+ .values({
+ id: newId("item"),
+ orgId,
+ scope: "project",
+ projectId,
+ kind: "agent_contract",
+ name: `governed-inbound-${suffix}`,
+ })
+ .returning()
+ )[0];
+ const agent = (
+ await db
+ .insert(agentDefs)
+ .values({
+ id: newId("agent"),
+ orgId,
+ projectId,
+ name: "inbound-delivery",
+ engine: "codex",
+ model: {},
+ contractItemId: contract?.id ?? "",
+ triggers: [{ type: "command", handle: "/builder" }],
+ enabled: true,
+ })
+ .returning()
+ )[0];
+ if (!agent) throw new Error("failed to insert inbound agent");
+ await db.insert(integrations).values({
+ id: integrationId,
+ orgId,
+ projectId,
+ kind: "generic_inbound",
+ name: "Governed inbound adapter",
+ config: { projectId, enqueueRun: true, agent: agent.name },
+ });
+ await db.insert(inboundEvents).values({
+ id: inboundEventId,
+ orgId,
+ integrationId,
+ verified: true,
+ eventType: "delivery.requested",
+ payload: {
+ projectId,
+ issue: {
+ fingerprint: `governed-inbound:${suffix}`,
+ title: "Attempt governed delivery",
+ },
+ },
+ });
+ const enqueued: Array<{ queue: string; data: Record }> = [];
+
+ await expect(
+ processGenericInboundEvent(db, inboundEventId, async (queue, data) => {
+ enqueued.push({ queue, data });
+ return null;
+ }),
+ ).rejects.toMatchObject({ code: "builder_plan_required" });
+
+ expect(await db.select().from(runs).where(eq(runs.agentDefId, agent.id))).toEqual([]);
+ expect(enqueued).toEqual([]);
+ const storedEvent = (
+ await db.select().from(inboundEvents).where(eq(inboundEvents.id, inboundEventId)).limit(1)
+ )[0];
+ expect(storedEvent).toMatchObject({ processedAt: null });
+ expect(storedEvent?.error).toContain("approved Architect plan");
+ const denial = (await db.select().from(auditEvents).where(eq(auditEvents.orgId, orgId))).find(
+ (event) =>
+ event.action === "run.builder_plan_denied" &&
+ (event.payload as { source?: unknown }).source === "generic_inbound",
+ );
+ expect(denial).toMatchObject({
+ projectId,
+ payload: { code: "builder_plan_required", source: "generic_inbound" },
+ });
+ });
});
diff --git a/services/api/test/watchtower.test.ts b/services/api/test/watchtower.test.ts
index 7bff43df..3f024528 100644
--- a/services/api/test/watchtower.test.ts
+++ b/services/api/test/watchtower.test.ts
@@ -503,6 +503,69 @@ describe("watchtower", async () => {
expect(repoIssue?.state).toBe("open");
});
+ it("preserves optional canary agent selection and gates a required dual-role fallback", async () => {
+ const configuredProject = await insertProject({
+ watchtower: { canary: { enabled: true, lane: "platform" } },
+ });
+ const configuredAgent = await insertAgent(configuredProject.id, "Configured Probe");
+ await db
+ .update(projects)
+ .set({
+ settings: {
+ watchtower: {
+ canary: { enabled: true, lane: "platform", agentDefId: configuredAgent.id },
+ },
+ },
+ })
+ .where(eq(projects.id, configuredProject.id));
+ await collectCanaries(db, new FakeGitHub(), async () => undefined);
+ expect(
+ (await db.select().from(runs).where(eq(runs.projectId, configuredProject.id)).limit(1))[0],
+ ).toMatchObject({ agentDefId: configuredAgent.id, mode: "architect", status: "queued" });
+
+ const fallbackProject = await insertProject({
+ watchtower: { canary: { enabled: true, lane: "platform" } },
+ });
+ const fallbackAgent = await insertAgent(fallbackProject.id, "Legacy Probe");
+ await collectCanaries(db, new FakeGitHub(), async () => undefined);
+ expect(
+ (await db.select().from(runs).where(eq(runs.projectId, fallbackProject.id)).limit(1))[0],
+ ).toMatchObject({ agentDefId: fallbackAgent.id, mode: "architect", status: "queued" });
+
+ const requiredProject = await insertProject({
+ watchtower: { canary: { enabled: true, lane: "platform" } },
+ });
+ const dualRoleAgent = await insertAgent(requiredProject.id, "Legacy Dual Role Probe");
+ await db
+ .update(agentDefs)
+ .set({ triggers: [{ type: "command", handle: "/builder" }] })
+ .where(eq(agentDefs.id, dualRoleAgent.id));
+ await db
+ .update(projects)
+ .set({ builderPlanPolicy: "required" })
+ .where(eq(projects.id, requiredProject.id));
+ const dispatched: Record[] = [];
+ await collectCanaries(db, new FakeGitHub(), async (queue, data) => {
+ dispatched.push({ queue, ...data });
+ });
+ expect(await db.select().from(runs).where(eq(runs.projectId, requiredProject.id))).toEqual([]);
+ expect(dispatched).toEqual([]);
+ expect(
+ (
+ await db
+ .select()
+ .from(platformIssues)
+ .where(
+ and(
+ eq(platformIssues.projectId, requiredProject.id),
+ eq(platformIssues.kind, "canary_failure"),
+ ),
+ )
+ .limit(1)
+ )[0],
+ ).toMatchObject({ state: "open", title: "Canary agent blocked by Builder plan policy" });
+ });
+
it("rolls up analytics idempotently and serves rollup-backed endpoints", async () => {
const project = await insertProject();
const agent = await insertAgent(project.id, "Builder");