Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions apps/docs/docs/concepts/inbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,11 @@ Facility implements a typed, auditable HITL model:
cancelled / expired, then executed or execution-failed. Who decided, when,
and why is never reconstructable-only — it's recorded.
- **Plan acceptance** is executable: approving a platform-lane plan creates
and queues the builder run linked to the architect run. In the repo lane,
the human invokes `/builder` in GitHub, which is the recorded Gate 1 action.
and queues the builder run linked to the architect run. The current repo-lane
workflow executes outside this control-plane boundary and is therefore not
compatible with `builderPlanPolicy=required`; required projects must route
both Builder commands to the platform lane. A later signed repo-lane handshake
can extend the same invariant without treating a slash command as a proposal.

## Design intent

Expand Down
91 changes: 91 additions & 0 deletions apps/docs/docs/concepts/projects-and-governance.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,94 @@ never silently overwritten. The fingerprint advances only when the PR merges.
Every project records its system version. Org policy can pin projects,
preview diffs between versions, and roll forward on your schedule — the whole
method is data, not folklore.

## Required Architect plans for Builder

Projects can set `builderPlanPolicy` to `optional` or `required`. The default is
`optional`, including for projects created before this setting existed, so an
upgrade does not silently change their dispatch behavior.

Within the Facility platform lane, `required` makes Gate 1 a runtime invariant. A Builder can only be created by
the internal executor for an approved `plan_acceptance` proposal with trusted
freshness evidence. Run-now,
Story Build, GitHub slash commands, MCP tools, resumes, schedules, and inbound
integrations cannot create an unlinked Builder run. There is no break-glass
route.

Facility accepts `required` only when every connected repository has a recent
`ok` fingerprint from its default branch and routes both `builder` and
`codex-builder` to `platform`. Connecting another repository requires switching
back to `optional`, connecting and verifying its platform configuration, then
re-enabling the gate. The generated repo-lane workflow does not yet call this
policy and must not remain enabled for Builder on a required project.
If the default-branch manifest later removes either platform lane, Facility
marks the repository fingerprint drifted and rejects the synchronized config;
restore and verify the governed files before dispatching again.
Managed pushes mark the fingerprint pending before asynchronous verification,
and approval rechecks the cached lane plus a recent `ok` fingerprint. Approval
also reads the repository's live default-branch SHA and the live GitHub issue
revision; a cached mirror or caller-supplied value is never accepted as
freshness evidence.

Policy activation, run admission, repository mutation, and Builder-relevant
agent mutations share a per-project transaction lock. Admission also persists
an immutable canonical `builder` or `codex-builder` run mode; later edits to an
agent name or command trigger cannot erase the role the run was admitted as.
Enabling `required` conservatively refuses to proceed while any older run is
non-terminal. This covers legacy rows created before immutable admission and
prevents a run that observed `optional` from crossing the activation boundary.

Denials use stable API and audit codes:

- `builder_plan_required` when no acceptance was supplied;
- `builder_plan_expired`, `builder_plan_rejected`, or
`builder_plan_already_consumed` for proposal lifecycle failures;
- `builder_plan_stale` when the recorded base or issue revision changed;
- `builder_plan_freshness_unavailable` when Facility cannot prove that those
revisions are still current; and
- `builder_plan_context_invalid` for malformed or non-canonical provenance.

The acceptance records the exact plan SHA-256, approving human principal and time,
Architect receipt, repository, and issue. Base-commit provenance must come from
the workspace/base tracking contract, and issue freshness must come from a live,
canonical digest of the exact Architect issue scope rather than the mutable
mirror timestamp. If either trusted provider is unavailable, `required` denies
Builder with `builder_plan_freshness_unavailable`; it never falls back to caller
input.
For a required project, an API key or agent cannot supply the approval event;
the approving principal must be a Facility user or the authenticated GitHub
human who issued the canonical `/builder` command, and must be distinct from
the proposal opener.

Architect records the checked-out default-branch SHA and a versioned canonical
digest of the issue title, body, state, author, URL, labels, and material
comments. Facility excludes its own progress/publication comments and exact
approval-only Builder comments from that digest. At approval, on worker receipt,
and once more after the worker atomically claims the run, Facility re-reads the
live branch and issue. A mismatch returns `builder_plan_stale`; an unavailable
read returns `builder_plan_freshness_unavailable`, before credentials or a
sandbox are released. The runner also clones with the approved SHA as its
expected head, so a later default-branch movement aborts before the model runs.

Proposals created before this coherent deployment are not backfilled. Run
Architect again, approve the newly published proposal, and do not edit or reuse
an old plan. The approved plan body and its SHA-256 remain immutable even though
GitHub cannot atomically lock an issue: an issue edit after the worker's final
freshness read cannot be excluded atomically, but it cannot rewrite the scope
that Builder receives.

Architect plan publication is a durable, retryable delivery. A scheduled
reconciler resumes terminal Architect runs whose plan comment was not recorded,
uses a stable publication marker to discover a comment created during an
ambiguous response, and closes or suppresses stale publications when their
proposal is no longer open. GitHub comment creation offers no idempotency key,
so delivery remains technically at-least-once; the stable marker, bounded retry,
and reconciler make duplicate publication observable and recoverable.

Two operational follow-ups remain. A generic inbound event
denied by the gate stays unprocessed, and replaying the same delivery ID does
not automatically execute it again; an operator must create a fresh delivery
after correcting the source workflow. Also, the web UI disables Builder trigger
controls while `required` is active, including editing an existing Builder
schedule. Remove or change that schedule before activation (or temporarily
return the project to `optional` through the governed project settings flow).
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ export default async function AgentDetailPage({
params: Promise<{ projectId: string; agentId: string }>;
}) {
const { projectId, agentId } = await params;
const [agents, status, catalog, me, profiles] = await Promise.all([
const [agents, status, catalog, me, profiles, project] = await Promise.all([
api.projectAgents(projectId),
api.agentsStatus(projectId),
api.catalog(),
api.me(),
api.sandboxProfiles(),
api.project(projectId),
]);

if (!agents.ok) return agents.offline ? <Offline /> : <ErrorNotice message={agents.message} />;
Expand All @@ -42,6 +43,7 @@ export default async function AgentDetailPage({
myPermissions={me.ok ? me.data.permissions : []}
sandboxProfiles={profiles.ok ? profiles.data.map((p) => ({ id: p.id, name: p.name })) : []}
recentRuns={runs.ok ? runs.data : []}
builderPlanPolicy={project.ok ? project.data.builderPlanPolicy : "required"}
/>
</>
);
Expand Down
6 changes: 5 additions & 1 deletion apps/web/app/(app)/projects/[projectId]/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,11 @@ export default async function ProjectSettingsPage({

<section className="flex flex-col gap-4">
<Eyebrow>gates</Eyebrow>
<GatesEditor projectId={projectId} settings={settings} />
<GatesEditor
projectId={projectId}
settings={settings}
builderPlanPolicy={p.builderPlanPolicy ?? "optional"}
/>
</section>

<Divider />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,14 @@ export default async function StoryPage({
...(storyType ? { storyType } : {}),
};

const [detail, inbox, outcomes, activity, pipeline, me] = await Promise.all([
const [detail, inbox, outcomes, activity, pipeline, me, project] = await Promise.all([
api.story(projectId, number, query),
api.inboxAll(),
api.outcomes(`?state=all&projectId=${projectId}&limit=200`),
api.storyGithubActivity(projectId, number, query),
api.pipeline(projectId),
api.me(),
api.project(projectId),
]);

if (!detail.ok) {
Expand Down Expand Up @@ -139,6 +140,7 @@ export default async function StoryPage({
projectId={projectId}
issueNumber={story.number}
repoId={story.repoId}
builderPlanRequired={!project.ok || project.data.builderPlanPolicy === "required"}
/>
) : null}
</div>
Expand Down
9 changes: 8 additions & 1 deletion apps/web/app/(app)/projects/[projectId]/stories/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ export default async function ProjectStoriesPage({
searchParams: Promise<{ stage?: string; status?: string }>;
}) {
const [{ projectId }, { stage, status }] = await Promise.all([params, searchParams]);
const [pipelineResult, me] = await Promise.all([api.pipeline(projectId), api.me()]);
const [pipelineResult, me, project] = await Promise.all([
api.pipeline(projectId),
api.me(),
api.project(projectId),
]);

if (!pipelineResult.ok && pipelineResult.offline) return <Offline />;

Expand Down Expand Up @@ -189,6 +193,9 @@ export default async function ProjectStoriesPage({
projectId={projectId}
story={story}
canTrigger={canTrigger}
builderPlanRequired={
!project.ok || project.data.builderPlanPolicy === "required"
}
/>
))}
</div>
Expand Down
44 changes: 38 additions & 6 deletions apps/web/components/agents/agent-detail.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { isBuilderMode } from "@facility/run-objective";
import { isBuilderAgent } from "@facility/run-objective";
import {
Button,
Divider,
Expand Down Expand Up @@ -44,6 +44,7 @@ type Props = {
myPermissions: string[];
sandboxProfiles: Array<{ id: string; name: string }>;
recentRuns: Run[];
builderPlanPolicy: "optional" | "required";
};

type ScheduleTriggerShape = {
Expand Down Expand Up @@ -84,7 +85,7 @@ function runCost(run: Run): number | null {

/** The flagship surface: one agent, fully understandable and operable. */
export function AgentDetail(props: Props) {
const { projectId, agent, status } = props;
const { projectId, agent, status, builderPlanPolicy } = props;
const router = useRouter();
const [busy, setBusy] = useState<string | null>(null);
const [note, setNote] = useState<{ scope: string; text: string } | null>(null);
Expand All @@ -108,7 +109,7 @@ export function AgentDetail(props: Props) {

async function runNow() {
const trigger: Record<string, unknown> = { 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();
Expand All @@ -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 (
<div className="mx-auto flex w-full max-w-[1600px] flex-col gap-10">
Expand Down Expand Up @@ -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"}
</Button>
{builderRequiresPlan ? (
<span className="font-mono text-[10px] text-(--human)">plan approval required</span>
) : null}
<Button
size="sm"
variant="outline"
Expand Down Expand Up @@ -472,8 +483,19 @@ function ContractSection({ item, act, busy, note }: SectionProps) {
);
}

function TriggersSection({ projectId, agent, status, catalog, act, busy, note }: SectionProps) {
function TriggersSection({
projectId,
agent,
status,
catalog,
act,
busy,
note,
builderPlanPolicy,
}: SectionProps) {
const existing = scheduleOf(agent);
const builderRequiresPlan =
builderPlanPolicy === "required" && isBuilderAgent(agent.name, agent.triggers);
const [editing, setEditing] = useState(false);
const [nextSchedule, setNextSchedule] = useState<{ cron: string } | null>(
existing ? { cron: existing.cron } : null,
Expand Down Expand Up @@ -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"}
</Button>
) : null}
{builderRequiresPlan ? (
<span className="font-mono text-[10px] text-(--human)">
disabled by required plan gate
</span>
) : null}
</div>
{editing ? (
<div className="flex flex-col gap-3">
Expand Down
9 changes: 9 additions & 0 deletions apps/web/components/inbox/proposal-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ export function ProposalCard({ proposal, focused }: { proposal: Proposal; focuse
headings, lists, and code spans) — render it, don't dump the source. */}
<Markdown source={proposal.contextMd} />

{proposal.executionError ? (
<p className="border border-(--bad) bg-(--bad-subtle) p-3 font-mono text-[11px] text-(--bad)">
{proposal.actionType === "plan_acceptance"
? "Builder dispatch blocked"
: "Execution failed"}
: {proposal.executionError}
</p>
) : null}

<details className="group">
<summary className="cursor-pointer select-none text-[12px] font-medium text-(--dim) hover:text-(--mut)">
payload
Expand Down
16 changes: 16 additions & 0 deletions apps/web/components/issues/issue-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null);
Expand Down Expand Up @@ -90,6 +92,13 @@ export function IssueRow({
);
}
if (story.stageState === "ready_to_build" && canTrigger && story.storyType === "issue") {
if (builderPlanRequired) {
return (
<ButtonLink size="sm" href={storyHref(projectId, story)}>
Review Gate 1
</ButtonLink>
);
}
return (
<Button
size="sm"
Expand All @@ -103,6 +112,13 @@ export function IssueRow({
);
}
if (story.stageState === "failed" && failedAgent && canTrigger) {
if (failedAgent === "builder" && builderPlanRequired) {
return (
<ButtonLink size="sm" variant="danger" href={storyHref(projectId, story)}>
Review Gate 1
</ButtonLink>
);
}
return (
<Button
size="sm"
Expand Down
Loading
Loading