# Pre-flight Decision Gate — design

Audience: AI coding agents first. Contract-level: pins decisions + seams, not bodies.

## Problem

A `run-plan` workflow that hits a point needing human sign-off **HALTs mid-run** — `run-plan.js` is a Workflow controller and cannot call `AskUserQuestion` (main-thread only). So an irreversible op (`DROP TABLE`), an ambiguous fork, a missing secret, or any project-defined "human-in-the-loop" point stops the run and waits. User wants the opposite: **surface every foreseeable human decision up front as one fully-informed sheet, capture answers as durable pre-authorizations, then run end-to-end with no blocker in the middle.**

## Goal

Before a workflow launches, in the main thread: discover every decision that would otherwise HALT, present a consolidated decision sheet, record the answers into the plan JSONL, then launch. The engine runs uninterrupted for every *pre-authorized* decision, and **fail-closed HALTs** on any decision still unresolved at execution — regardless of launch path (interactive, headless, cron, resume).

**Non-goal — what this does NOT remove (state plainly to the user; "no blockers in the middle" oversells it otherwise):**
- This removes mid-run halts for **foreseeable decisions only** — those authored by the planner or surfaced by the launch scan, i.e. ones with a `gated` record.
- It does **NOT** remove **failure-driven correctness halts**: merge conflict (`run-plan.js:450`), integration gate0 red (`514`), integration review flag (`458`), `MAX_FIX`/`MAX_REVIEW` exhaustion. These are the *more common* mid-run stop and genuinely need a human (resolve the conflict, fix the code). Out of scope — a decision sheet cannot pre-answer "the code will fail to compile when combined."
- A decision **truly unforeseen** at launch has **no** `gated` record and no `requires_decision` link — so the engine cannot HALT on it (it has no record of it). It either runs unchecked or trips an existing correctness gate. The author+scan coverage is what shrinks this set; it cannot reach zero.

## Unified model — ONE representation of "needs a human" (no drift)

Today three things can mean "blocked on a human"; this feature collapses them to one lifecycle so nothing forks the format (the same discipline that makes `fix-rot` reuse `brainstorm`'s schema):

| Concept | Mechanism (after this change) | Who unblocks |
|---|---|---|
| Blocked on another **task** | `task.deps: [ids]` | agent (the dep task completes) |
| Blocked on a **per-task human decision** | `task.requires_decision: <gated.id>` | pre-flight gate (user answers) |
| A **run-level human decision** (landing/policy) | `gated` with `binds_meta: <field>`, NO task link | pre-flight gate → writes the meta field |
| The decision itself | a `gated` record with a **lifecycle** | — |

**Two binding kinds — both surface on the same sheet, enforcement differs:**
- **Per-task** — `gated` linked by `task.requires_decision`; enforced by the engine per task (HALT if unresolved). Use for irreversible ops, param-forks, missing task inputs.
- **Run-level** — `gated` with `binds_meta` naming an existing `meta` field (`base_branch`, `exec_mode`, or another land-mode field the strategies already read), NO task link — landing/policy decisions (push-vs-PR, base branch, auto-merge) have no task to hang on. At pre-flight the answer is written into that meta field; existing logic consumes it unchanged. Enforcement: a still-OPEN run-level decision ⇒ HALT at load (the meta field is unresolved), so a headless launch can't run with an un-decided landing policy.

- **No new record type.** The existing `gated` record is enriched (below). A new `decision` type would be the third synonym the user explicitly warned against — rejected.
- **`task.requires_decision` replaces free-text user-blockers.** A task gated on a person no longer carries a prose `blocker` describing the human; it links a `gated.id`. `task.deps` stays strictly task→task. This removes the overlap between `deps`, `blocker`, and `gated`.
- A task's BLOCKED-on-user state is **derived, not hand-maintained**: a task is human-blocked iff its `requires_decision` points at a `gated` record whose `status` is `OPEN`. The engine computes it.

### Enriched `gated` record (additive — absent fields ⇒ today's behavior)

```jsonl
{"type":"gated","id":"g1","category":"irreversible","needs":"<what the human must decide>","why":"<why it needs a human>","blast_radius":"<what is affected / why irreversible>","options":["proceed","abort"],"default":null,"status":"OPEN","answer":null,"resolved_by":null,"source":"author"}
```

- `id` — stable key; `task.requires_decision` references it.
- `category` — open enum; baseline values `irreversible | fork | input | policy | architecture`; projects MAY add their own (per-project policy below). Unknown category is allowed (it is descriptive, not control).
- `needs`, `why`, `blast_radius` — the fully-informed context shown on the sheet. `blast_radius` MANDATORY for `category:"irreversible"`.
- `options` — closed choice list. Go/no-go MUST be exactly `["proceed","abort"]` — `"abort"` is the SOLE stop sentinel the engine honors (every other answer PROCEEDS); a non-`abort` stop word is rejected at load (fail-closed). Omit ⇒ proceed/abort. For a **fork**, the options are the branch values (proceed-semantics, injected as input) — forks are exempt from the subset check.
- `status` — `OPEN | RESOLVED`. Absent ⇒ `OPEN` (back-compat: a legacy `gated` with no status is treated OPEN and surfaces on the sheet).
- `answer` — the chosen option string, written at pre-flight. Null while OPEN.
- `resolved_by` — `"user"` (interactive) — audit trail.
- `source` — `"author"` (planner wrote it) | `"scan"` (backstop discovered it). Diagnostic only.
- `binds_meta` — OPTIONAL. Names a `meta` field this decision resolves into (run-level binding). Present ⇒ run-level (no task links it, answer written to `meta.<field>`); absent ⇒ per-task (linked via `task.requires_decision`). A `gated` is exactly one kind.

### Fork binding — two kinds, neither needs branch-skip (single-adapter test applied)

A fork is resolved by the *answer string*, never by toggling pre-authored divergent branches. The single-adapter test kills the branch-skip mechanism: there is no real plan that needs two structurally-different task graphs both pre-authored and one discarded at launch — that case is always one of the two below.

- **Param-only fork** — the answer changes a value the task consumes (which library, a name, a config), the **task graph is unchanged**. Model: ONE task that `requires_decision` the `gated`; the resolved `answer` is injected as input to that task's implementer prompt. No branch tasks, no skip, no enum change. The task runs once its decision is RESOLVED (and `answer !== "abort"`).
- **Graph-changing fork** — the answer would produce *different tasks/deps*. The graph must be frozen before launch for the scheduler to work, so this is a **re-plan**, resolved at **plan time** (the planner asks during `plan`), never deferred to the gate.

**Cut (YAGNI):** `requires_decision_equals`, pre-authored dual branches, and a "skipped" task state. They would force a cross-cutting `status_enum` change (a `CANCELED` value read by `fix-rot`/`resume-plan`/`run-plan.js`, plus convergence/`done()`/dependents redefinition) to buy a fork shape neither real case produces. Forks stay fully covered by param-injection + plan-time resolution; `status_enum` is untouched.

## Per-project human-in-the-loop policy — concrete seam

Coverage is **per-project** (user: "different projects have different rules"). The policy source is named, not hand-waved:

- **Primary seam:** a `## Pre-flight gate policy` section in the **project's `CLAUDE.md`** (the file every project already uses for per-project agent policy). It lists, in prose bullets, what requires a human in that project (e.g. "any migration touching `payments.*`", "any change under `infra/terraform/`"). Both the planner (authoring `gated` records) and the scan backstop **read this section** to decide what to flag.
- **Absent ⇒ baseline.** No such section ⇒ the built-in baseline categories apply: `irreversible | fork | input | policy | architecture`.
- The baseline is the floor; the project section is **additive** (it can broaden, never silence an irreversible op — an irreversible destructive op is always gated).

## Components

### C1 — schema (`brainstorm/SKILL.md` "Compact-Proof Session File")
Document the enriched `gated` record (lifecycle fields), `task.requires_decision`, and the unified model table. State that `deps` is task→task only and user-blockers are expressed via `requires_decision`. Additive; back-compat note (absent `status` ⇒ OPEN). agent-doc-authoring register.

### C2 — decision enumeration at plan time (`plan/SKILL.md`, `brainstorm/SKILL.md`)
A planner pass: for every task, ask "would this need a human (baseline categories + the project's `## Pre-flight gate policy`)?" If yes, author a `gated` record and link it from the task via `requires_decision`. Resolve **open forks here** (never defer to the gate). This is the primary, deterministic source of decisions. agent-doc-authoring register.

### C3 — pre-flight gate (`run-plan/SKILL.md` launch path — main thread)
Insert a step between the existing "Pre-flight (cheap, MUST pass)" file-existence check and the template-and-launch step:

```
Pre-flight decision gate (main thread, before Workflow launch):
1. Native Read the JSONL. Collect all `gated` records with status OPEN.
2. Scan backstop — RUN ONLY IF the task graph changed since the last scan (idempotency
   below). Spawn ONE agent (sonnet). Input = the task list (descriptions + each task's
   `files[]` hints) + the project's `## Pre-flight gate policy` (or baseline if absent).
   NOTE: no diffs exist pre-execution — the scan reasons over what task DESCRIPTIONS imply,
   so pre-launch catchability is bounded by description fidelity (see Residual). It returns
   decision points NOT already covered by an OPEN/RESOLVED gated id. Bias: over-flag (a false
   positive costs one "yes"; a false negative runs an irreversible op unreviewed). Append
   discovered ones as `gated` ... "source":"scan","status":"OPEN".
3. If any OPEN decisions: present ONE consolidated AskUserQuestion sheet — each decision
   shows needs / why / blast_radius / options, recommended option first. Multi-select across
   decisions in as few calls as the tool allows.
4. Write each answer back to its `gated` line in place: status="RESOLVED", answer=<choice>,
   resolved_by="user". (Native Edit per line — JSONL is line-addressable by id.) For a `binds_meta`
   decision, ALSO write the answer into the named `meta` field (run-level binding).
5. Stamp the idempotency marker: meta.preflight = {"task_graph_hash": <hash of all task ids+descs+requires_decision>}.
6. Only then proceed to template-and-launch.
```

**Idempotency / resume (MUST — protects the headless-clean-resume guarantee):** an LLM scan is non-deterministic, so re-running it every launch could surface a NEW OPEN decision on resume → re-ask (interactive) or HALT (cron/headless, no human). Defeat that: step 2 runs the scan ONLY when `meta.preflight.task_graph_hash` is absent or differs from the current task graph hash. On an unchanged-graph resume, the scan is skipped entirely and the gate only re-collects still-OPEN *authored* decisions (normally none → gate is a true no-op). This keeps resume idempotent and headless re-runs clean.

The launcher sheet is **UX, not the safety boundary** (C4 is). resume-plan already routes every multi-wave plan to this launcher → single entry point, no duplication. agent-doc-authoring register.

### C4 — engine enforcement (`run-plan.js`) — the trust boundary
Fail-closed, independent of launch path (this is what protects a headless/cron/direct launch):
- Before executing any task: if `task.requires_decision` names a `gated` whose `status !== "RESOLVED"` ⇒ **HALT** (`stage:"Preflight"`, why names the gated id + needs). An unknown/missing gated id ⇒ HALT (never run an unresolvable task).
- A RESOLVED decision with `answer === "abort"` ⇒ HALT the run (user chose to stop).
- Otherwise the task runs; its resolved `answer` is available to the implementer prompt as input (param-only fork injection).
- All-resolved plan ⇒ the engine check is **vacuous** ⇒ headless re-runs proceed clean. Pre-authorizations persist in the JSONL, so a re-run never re-asks.

**No-drift:** express the check as ONE shared predicate `decisionResolved(t)` alongside the existing `depsReady`, called by BOTH `runSequential` and `runDagParallel` at the point each already validates a task before lease (`run-plan.js:472`/`493`) — never two parallel edits in two loops.

**Load validation (fits existing load-time HALTs):** at load, HALT on a corrupt or unresolved decision graph — a `gated` whose `answer` is set but `∉ options`; a duplicate `gated.id`; a `requires_decision` pointing at no `gated`; or a **run-level (`binds_meta`) `gated` still OPEN** (a headless launch must not run with an un-decided landing policy). A hand-edited/garbled answer must not run.

## Data flow

```
plan time:   planner → gated{OPEN} + task.requires_decision   (C2, authored)
launch:      launcher reads OPEN gated  →  scan backstop adds more IF graph changed (C3)
             → consolidated AskUserQuestion sheet → write answers RESOLVED to JSONL
             → stamp meta.preflight.task_graph_hash → template + Workflow launch
run time:    run-plan.js, per task: decisionResolved(t)? → run (inject answer) / HALT  (C4, fail-closed)
resume:      graph unchanged → scan skipped, all RESOLVED → gate no-op → uninterrupted
```

## Error handling

- OPEN decision reaches the engine (gate skipped / new since launch) ⇒ HALT, fail-closed. Never silent-proceed.
- Scan backstop dies / returns nothing ⇒ proceed on authored decisions only (degrade, do not block) — but log that the backstop did not run, so a missing scan is visible.
- Legacy plan (no `gated` lifecycle fields, no `requires_decision`) ⇒ behaves exactly as today; gate finds nothing, engine check is vacuous. Pure additive.
- Conflicting/duplicate gated ids ⇒ HALT at load (invalid plan), consistent with existing load-validation HALTs.

## Test strategy (`run-plan.js` engine checks — extend the scheduler harness)

Both branches of every fail-closed line + negative proof, mirroring the scheduler test discipline:
- task with `requires_decision` → OPEN gated ⇒ HALT before lease (both schedulers).
- task with `requires_decision` → RESOLVED ⇒ runs normally, answer injected.
- param-fork: RESOLVED answer reaches the implementer prompt as input.
- answer="abort" ⇒ run HALTs.
- unknown/missing gated id referenced ⇒ HALT.
- load: `answer ∉ options`, or duplicate `gated.id` ⇒ HALT at load.
- all-resolved plan ⇒ engine check vacuous, full run completes (idempotent-resume proof).
- legacy plan (no decision fields) ⇒ unchanged behavior (regression guard).

The launcher gate (C3) and planner pass (C2) are prose procedures (main-thread, AskUserQuestion-driven) — no unit-testable deterministic core, consistent with not extracting a JS lib for them.

## Architecture Decisions

- **Reject a `decision` record type** — enrich `gated` instead. Deletion test: a separate type adds a third synonym for "needs a human" with no behavior the enriched `gated` lacks → collapses into `gated`.
- **Reject a dedicated `preflight-gate` skill + lib (Approach 2)** — single caller (resume-plan already routes to the run-plan launcher), and the gate is main-thread prose with no deterministic core to unit-test (unlike lease/commit). Single-adapter → inline in the launcher; put the one testable invariant in `run-plan.js`.
- **Enforcement in the engine, not the launcher** — the `AskUserQuestion` sheet is UX; the fail-closed `run-plan.js` check is the trust boundary that holds for headless/cron/direct launches.
- **Open forks resolved at plan time, not the gate** — the gate injects an answer into an existing task; it cannot author new tasks.
- **Reject branch-skip / `requires_decision_equals` / a "skipped" status (single-adapter test)** — no real plan needs two structurally-different graphs both pre-authored with one discarded at launch; every fork is param-injection (one task) or a plan-time re-plan. Keeping it would force a cross-cutting `status_enum`/convergence change for zero produced shape. Cut → `status_enum` untouched.
- **Scan gated behind a task-graph hash** — an LLM scan every launch would break idempotent resume; re-scan only when the graph changes.
- **Run-level decisions bind to `meta`, not a task** — landing/policy answers (push-vs-PR, base branch, auto-merge) have no task to hang on; `binds_meta` writes them into existing meta fields the strategies already read, with load-time HALT if left OPEN. Keeps the "Policy / landing" coverage category from silently falling out of per-task enforcement.
