Version: 1.0 — June 28, 2026
Status: Design (approved for implementation planning)
Scope: The human-in-the-loop gate primitive for the leaf-session backend — the last unbuilt
archetype. A single logical leaf session pauses mid-pipeline awaiting an external human decision,
parks in durable state with the pod scaled to zero, and resumes with the decision injected,
preserving full context across one or more gates before reaching a verdict. This is the canonical
async + scale-to-zero + durable-resume case (archetypes §7.6,
charter §8.3).
Realizes: Archetypes & Requirements §7.6 (gate primitive; gate-while-idle) + Capability Charter G8 ("promote post-MVP: human-gate"). Single-tenant, key-in-env.
Builds on (reuse, no redesign): the leaf-session contract + runLeaf (MVP/PR #10), gate-7 durable
resume (PR #11), async leaf completion (POST /runs {async:true} + leaf-queue + KEDA
ScaledJob, async design), M2/M3 sandbox, M4 Knative
scale-to-zero, M5 checkpoint/resume, M6 model selection.
Defers (per charter): auto-approve mode (§1, non-precluding); harness-side timeout/TTL (§5);
multi-tenancy & per-user identity (Z1), credential plane (Z3/Z5), event-driven gates (Archetype C).
One-line finding. The gate is not new machinery — it is a structured-output terminal plus a decision-seeded continuation, both of which the substrate already does. A leaf reaching a gate ends a turn well-formed and parks (its session log is durable, the pod scales to zero); an external approver writes a decision file and re-invokes the same
sessionId;runLeafresumes from the log, applies the decision, and continues with full context. No always-on component, no Pi internals dependency, no change to any existing path.
A leaf session calls a
request_approvaltool, pauses with a structured summary on the orchestrator's volume, and the service scales to zero while parked; an external approver writes a decision (approve / reject / abort) and re-invokes the samesessionId; the session resumes from durable state with full prior context, applies the decision, and runs to a verdict — surviving multiple gates and mid-park crashes, idempotently.
- A
request_approvalstructured-input tool (job-mode), parallel tosubmit_verdict. - A durable gate-request custom entry + a monotonic
gateIdper session. - An
awaiting_approvalgate marker on the volume (parallel to the done-marker). - A
decisionRefdecision-file contract (approve / reject / abort + optional feedback). - A resume state machine in
runLeaf: pending-gate detection,gateId-keyed idempotency, decision application, continuation-prompt seeding (approve/reject), abort. - Async + sync integration: a
pausedLeafResult/marker that acks the queue (parked work), extendedclassifyOutcome. - Unit tests + a gated live gate smoke (
leaf-gate-smoke.sh).
- Auto-approve mode (§7.6 "auto-mode gated by
safety preconditions"). The tool and envelope are shaped so an
autoApprovepolicy can be added later (a flag that makesrequest_approvalreturnapprovedinline without parking), but building the safety-precondition surface now is unneeded for the first slice. Non-precluding, not built. - Harness-side timeout / TTL on a parked gate — the orchestrator owns deadlines (§5, Q4).
- Multi-tenancy, per-user identity (Z1), credential plane (Z3/Z5), event-driven gates (C).
- Any change to the synchronous
POST /runs, async enqueue, cron-dispatch,leaf-queue, the KEDAScaledJob, orsubmit_verdict— all unchanged. A leaf that never callsrequest_approvalbehaves exactly as today.
| # | Decision | Choice | Rationale |
|---|---|---|---|
| B1 | Who decides where a gate happens | Agent-declared via a tool; decision + resume external (charter-aligned "Option C") | Builds the novel single-session pause/resume primitive while decision authority and re-invocation stay external (G1/G2). |
| B2 | How the agent continues after a decision | Continuation prompt (not tool-result injection) | Turn ends well-formed (no dangling tool call); reuses the proven seed-prompt + M5 resume path; survives mid-park crashes; no Pi-internals dependency. |
| B3 | Decision transport + resume trigger | decisionRef file on the volume; resume = re-invoke POST /runs by sessionId (sync or async:true) |
Symmetric with inputsRef/resultRef; keeps decision content off the HTTP channel (G3); no harness-side watcher (preserves scale-to-zero + G1/G2). |
| B4 | Timeout of a parked gate | None in the harness; park indefinitely | Charter §8.3 ("sleep indefinitely"); a timer/sweeper would reintroduce an always-on component. Deadlines are the orchestrator's concern (re-invoke with abort). |
| B5 | Decision actions | approve | reject | abort |
Matches archetype B's approve / reject→loop / abort. |
request_approval({
summary: string, // what was done / decided so far (the human reads this)
proposed_action: string // what the agent intends to do next, pending sign-off
})
On call the harness:
- Validates args (both non-empty strings); on mismatch → tool error back into the session (the
agent can retry, exactly like
submit_verdict). - Appends a durable gate-request custom entry:
{ gateId, summary, proposed_action }, wheregateId= the count of prior gate-request entries in this session (0, 1, 2, …). - Returns a benign synchronous result: "Approval requested; the session will pause and resume
with the human decision." — so the assistant turn ends well-formed (no dangling
tool_use; this is what makes resume robust, B2).
The tool sets a capture flag (gateRequested, with the new gateId) that runLeaf inspects after
the loop (§3). The agent is expected to stop after calling it; if it continues, the turn still ends
normally and runLeaf parks at the loop boundary.
Written atomically (temp + rename) to a derived gateRef (default <resultRef>.gate, override
via envelope.gateRef), on the orchestrator-owned volume:
{
"status": "awaiting_approval",
"sessionId": "<sid>",
"gateId": 0,
"gate": { "summary": "…", "proposed_action": "…" },
"ts": "<iso8601>"
}The gateRef lives in the orchestrator's directory (charter G3) — the harness is not on the
approval critical path. The marker advertises the current gateId the approver must answer.
The external approver writes this file (on the volume) and then re-invokes (§2.5):
{ "gateId": 0, "action": "approve" | "reject" | "abort", "feedback": "<optional>" }gateIdmust echo the marker'sgateId; a decision whosegateIddoes not match the pending gate is ignored (§3, §5 — prevents a stale decision answering a later gate).feedbackis used byreject(and may accompanyapprove); ignored byabort.
LeafEnvelope gains two optional fields; defaults preserve today's behavior exactly:
gateRef?: string— override the derived gate-marker path (default<resultRef>.gate).decisionRef?: string— present on a resume invocation; the harness reads the decision here. Absent on the first (gating) invocation.
No other field changes. async, doneMarkerRef, tenant, model, maxTurns, etc. are untouched.
Resume is an ordinary re-invocation of POST /runs with the same sessionId plus
decisionRef (and async honored as usual):
POST /runs { sessionId: "<same>", inputsRef, resultRef, decisionRef, workspaceRef?, model?, async? }
→ sync: 200 { status: "done" | "paused" | "aborted" | "failed", … }
→ async: 202 { status: "accepted", sessionId, resultRef, doneMarker }
No new endpoint, no watcher. The orchestrator/human is the caller (G1/G2).
LeafResultgains:{ status: "paused"; gateRef: string; gateId: number }{ status: "aborted" }
- Marker
statusenums extend: the gate marker addsawaiting_approval; the terminal done-marker addsaborted(alongsidedone | failed). The two are distinct files (§4).
runLeaf writes the gate marker. In both sync and async modes, whenever a leaf parks,
runLeaf itself writes the awaiting_approval gate marker to gateRef — because the summary it
carries comes from the session log, which only runLeaf has (the queue runner sees only the
LeafResult). Sync path: the call returns promptly 200 { status: "paused", gateRef, gateId, sessionId } (gate marker already on the volume); the caller is not blocked and the pod scales to
zero. Async path: the leaf-job's runLeaf writes the gate marker, then classifyOutcome acks
the entry (§4).
A front-end runs on every invocation (fresh or resume), before the agent loop:
1. open session
prior = store.read(sid)
resuming = prior.length > 0
sm = resuming ? openFromCheckpoint(sid) : create(sid)
2. verdict fast-path (UNCHANGED, M5)
if a verdict custom entry exists → recover it → return { status: "done" }
3. pending-gate detection
pendingGate = the latest gate-request entry whose gateId has NO matching gate-decision entry
(null if none)
4. branch — compute `seedPrompt` (the turn to drive) or short-circuit:
(a) pendingGate AND decisionRef provided AND decision.gateId == pendingGate.gateId:
if NO gate-decision entry exists yet for this gateId → append a durable gate-decision
entry { gateId, action, feedback } (idempotent: appended at most once per gateId)
action == "abort" → return { status: "aborted" } (no agent run)
action == "approve"|"reject"→ seedPrompt = continuationPrompt(action, feedback)
(b) pendingGate AND (no decisionRef OR decision.gateId != pendingGate.gateId):
duplicate / early / stale resume → set capture.gate from pendingGate →
return { status: "paused", gateRef, gateId } (NO agent run — idempotent no-op)
(c) no pendingGate:
lastDecision = the most recent gate-decision entry (null if none)
seedPrompt = lastDecision ? continuationPrompt(lastDecision.action, lastDecision.feedback)
: buildLeafPrompt(item) // fresh start (UNCHANGED)
5. run the agent loop: session.prompt(seedPrompt) // re-seeds each invocation, exactly like the
// existing verdict-resume re-prompts buildLeafPrompt
6. after the loop (inspect capture flags)
submit_verdict called → write `done` marker, return { status: "done", resultRef }
request_approval called → write `awaiting_approval` gate marker (the new gateId), return
{ status: "paused", gateRef, gateId }
neither → return { status: "failed", reason: "no_verdict" }
gateId is the idempotency spine. A session may pass several gates; the marker advertises the
current gateId and the decision file must echo it. A resume applies a decision only when its
gateId matches the pending gate, so:
- a stale/replayed decision (answering an already-consumed gate) is ignored (branch (b));
- a double-resume with the same decision re-seeds the same continuation (branch (a) →
re-derive → (c)/run), which is idempotent: the gate-decision entry is appended at most once per
gateId, and re-running the post-decision turn overwrites to the same verdict — exactly the idempotency the existing initial-prompt resume already relies on.
This is the gate analogue of the verdict fast-path: durable custom entries make replay safe.
The harness drives every turn — fresh or resumed — by calling session.prompt(seedPrompt), the same
mechanism job-mode uses to seed its initial prompt (openFromCheckpoint loads the prior context but
does not by itself run a turn). On a gate resume the seedPrompt is the continuation prompt
derived from the decision ("Human decision: APPROVED. <feedback?> Proceed." /
"Human decision: REJECTED: <feedback>. Revise; you may request approval again."). Because the prior
turns (including the request_approval exchange) are already in the durable log and replayed into
context, the agent continues coherently. No tool_use/tool_result surgery; the transcript stays
well-formed across the park.
Crash-mid-resume safety. The gate-decision entry is appended (once) before the agent loop and
flushed through BufferedRedisBackend. If the pod crashes after the decision is recorded but before
a verdict, the reclaim/re-invoke finds the gate already decided (no pending gate), takes branch (c),
re-derives the same continuation prompt from the durable gate-decision entry, and re-runs — the
decision is never recorded twice and the re-run is idempotent (overwrites to the same verdict). The
gate-decision entry is the idempotency guard that prevents a second decision from being recorded
for a consumed gateId.
Two marker kinds, two writers. The async substrate already has a terminal done-marker at
<resultRef>.status written by the queue runner (via classifyOutcome.marker), absent in sync
mode (the HTTP response conveys the terminal status). The gate adds a distinct non-terminal gate
marker at gateRef (<resultRef>.gate) that carries the session-derived summary — so runLeaf
writes it directly, in both sync and async modes (the queue runner only has the LeafResult, not
the summary). classifyOutcome therefore returns no terminal marker for paused (runLeaf
already wrote the gate marker); it only decides the ack:
runLeaf outcome |
gate marker (gateRef, by runLeaf) |
terminal marker (<resultRef>.status, by runner/async) |
queue action | rationale |
|---|---|---|---|---|
done |
— | done |
XACK | success (unchanged) |
paused |
awaiting_approval (written by runLeaf) |
— (none; not terminal) | XACK | parked; resume is a new invocation, not a redelivery |
aborted |
— | aborted |
XACK | terminal by human decision |
failed: bad_inputs | no_verdict | invalid_verdict |
— | failed |
XACK | deterministic (unchanged) |
failed: error |
— | none | no ack → reclaim | transient (unchanged) |
| process crash | — | none | no ack → reclaim → gate-7 resume | unchanged |
So classifyOutcome's new branches are: paused → { ack: true, marker: null }; aborted →
{ ack: true, marker: { status: "aborted" } }. The resume invocation is an ordinary sync POST or
async enqueue with the same sessionId + decisionRef. On the async path it XADDs a fresh entry
→ KEDA spawns a leaf-job → runLeaf resumes from the log, applies the decision, continues.
Nothing in the WorkQueue, ScaledJob, heartbeat, or dead-letter machinery changes.
Status endpoint visibility.
GET /runs/statusis extended so that, when no terminal marker is present, it also checks the sibling gate marker and reportsawaiting_approval(withgateId) instead of defaulting toqueued. The primary completion signal stays the orchestrator polling the gate / terminal markers on its own volume (G3); the endpoint is the secondary convenience path (async §3.4).
Marker overwrite across gates. A second gate in the same session overwrites the
awaiting_approvalgate marker with the newgateId. The orchestrator detects a new gate by the changedgateId(and, if it wants history, can snapshot markers — its concern, G3).
- Crash while parked, before the gate marker is written: the gate-request entry is already
durable, so on reclaim/re-invoke
runLeafdetects the pending gate and (nodecisionRefyet) takes branch (b) — writes the missingawaiting_approvalmarker without re-running the agent and returnspaused. ThegateIdis unchanged (derived from the durable entry). (Sync caller sees a connection drop → re-invokes → same result. Async: entry unacked → reclaimed.) - Crash mid-resume (decision recorded, no verdict yet): the gate-decision entry is durable → reclaim/re-invoke finds no pending gate (branch (c)), re-derives the same continuation prompt from that entry, and re-runs idempotently to verdict. The decision is never recorded twice.
- Double / conflicting decision (approve-then-reject): the first matching-
gateIddecision is recorded durably and wins; later decisions for a consumedgateIdare ignored. If two decision files race before any resume reads one, last-writer-on-the-file wins — documented; the orchestrator owns not racing itself (it is the single approving authority, G1/G2). - Stale decision applied to a new gate: prevented by
gateIdmatching (§3). decisionRefmissing/garbled on resume: treated as "no decision" → branch (b) → stayspaused(no agent run). Safe no-op; the approver re-writes and re-invokes.- Timeout: none in the harness (B4). A parked session waits indefinitely; the orchestrator
enforces deadlines by re-invoking with
abort. (Non-precluding future: an envelopegateTTL+ a KEDA-cron sweeper that writesabortedmarkers — not built; see §7.) - Re-entrancy preserved: the gate adds no caller-identity assumption, so a child leaf could itself gate. Non-precluding, untested (consistent with MVP §2.5).
Effectively-once outcome: at-least-once delivery × idempotent runLeaf (durable gate-request /
gate-decision / verdict entries + overwrite) = the same terminal state regardless of redelivery or
double-resume.
| # | Unit | Responsibility | Lives in | New / reuse |
|---|---|---|---|---|
| 1 | request_approval tool |
validate → append gate-request entry → benign result; set capture flag | harness/src/request-approval-tool.ts |
⭐ new |
| 2 | gate types + validation | GateRequest, Decision, validateDecision, gateId derivation |
harness/src/gate.ts |
⭐ new |
| 3 | gate marker I/O | atomic write/read of the awaiting_approval gate marker; deriveGateRef (<resultRef>.gate) |
harness/src/gate-marker.ts (mirrors done-marker.ts) |
⭐ new |
| 4 | resume state machine | pending-gate detection, decision application, continuation seeding, abort, gate-marker write on park | extend harness/src/run-leaf.ts |
⭐ new front-end; loop reused |
| 5 | LeafResult + envelope additions |
paused/aborted; gateRef/decisionRef |
extend harness/src/run-leaf.ts |
⭐ new fields |
| 6 | classifyOutcome |
paused→{ack:true, marker:null} (gate marker already written by runLeaf); aborted→{ack:true, marker:aborted} |
extend harness/src/classify-outcome.ts |
⭐ new branches |
| 7 | server handlers | serialize paused/aborted on sync 200; decisionRef/gateRef through isLeafEnvelope; status endpoint reports awaiting_approval from the gate marker |
packages/knative-server/src/server.ts |
♻️ minimal |
| 8 | leaf-job runner | unchanged loop; new outcomes ack via classifyOutcome |
harness/src/leaf-job-runner.ts |
♻️ unchanged |
| 9 | session durability / resume | gate entries through BufferedRedisBackend; openFromCheckpoint |
M1/M5 | ♻️ reuse |
| 10 | live gate smoke | GATE_LIVE_SMOKE=1 end-to-end on Kind |
deploy/knative/leaf-gate-smoke.sh |
⭐ new |
| 11 | fixture gating prompt | a prompt that requests approval once before verdict | test fixtures | ⭐ new (small) |
| Unit | Coverage |
|---|---|
request_approval tool |
valid args → gate-request entry with correct gateId + benign result; invalid (empty summary/proposed_action) → tool error, no entry. |
| gate types | validateDecision accepts approve/reject/abort + optional feedback; rejects bad action / missing gateId. |
| gate marker I/O | atomic temp+rename for the awaiting_approval gate marker; deriveGateRef default (<resultRef>.gate) + override. |
| resume state machine (injected store / loop / clock) | pending-gate detection; approve→durable gate-decision entry + continuation seed + run + gate-marker on a follow-on gate; reject→feedback seed + run; abort→aborted (terminal), no agent run; gateId mismatch→ignored (stays paused); duplicate decision→no-op continuation (no second seed); no decisionRef→paused; fresh→initial seed. |
classifyOutcome |
paused→{ack:true, marker:null}; aborted→{ack:true, marker:aborted}; existing rows unchanged (regression). |
| multi-gate sequence | gate#0 → approve → gate#1 → approve → verdict, single sessionId, correct gateId progression. |
| server | paused/aborted serialize correctly on sync 200; decisionRef passes through isLeafEnvelope; async-omitted path unchanged (regression). |
Redis-backed assertions are gated like existing redis-backend tests (skip when no REDIS_URL).
On the Kind sh-knative cluster (async path already deployed: KEDA + ScaledJob, Redis, sandbox-0,
/work PVC, ksvc). A fixture prompt deterministically requests approval once before its verdict.
The controller runs this live gate directly — never a subagent.
- Pause: dispatch a gated leaf →
awaiting_approvalmarker appears on/workwithgateId:0- summary; no
result_ref; session parked.
- summary; no
- Scale-to-zero while parked: with the gate pending and no other work, leaf-job pods reach zero (KEDA acked the parked entry).
- Resume-approve: write
decisionRef {gateId:0, approve}→ re-invoke samesessionId→ leaf resumes, runs to verdict →donemarker + correct verdict. - Resume-reject-then-approve: reject with feedback → agent revises, gates again (
gateId:1) → approve → completes with verdict. - Abort: write
{abort}→abortedmarker, no verdict, entry acked. - Idempotent resume: re-invoke with an already-consumed decision (and a stale
gateId) → no duplicate continuation; terminal state stable.
setup-kind.sh needs nothing new (no new cluster prereq — gate reuses the async path). Verbose
kubectl/build output redirects to /tmp/kagenti/human-gate/ logs, analyzed in subagents
(CLAUDE.md context budget).
- Auto-approve mode (the safety-precondition surface) — shaped-for, not built (§1).
- Harness-side timeout / TTL + any sweeper — orchestrator owns deadlines (§5).
- Tool-result injection resume style (B2 chose continuation-prompt).
- Harness-side decision watcher / auto-resume — resume is an external re-invocation (B3).
- Multi-tenancy, per-user identity (Z1), credential plane (Z3/Z5), event-driven gates (C).
- Any change to sync
POST /runs, async enqueue, cron-dispatch,leaf-queue, the KEDAScaledJob,submit_verdict, or the heartbeat/dead-letter machinery — all unchanged. - A new container image — the gate reuses the harness image and existing entrypoints.
Substrate-agnostic seam: the gate contract (gate tool → awaiting_approval marker → external
decisionRef → re-invoke-by-sessionId → continuation) depends only on runLeaf's durable session
log + the marker convention, not on KEDA or Knative. A KEDA-less or always-on deployment drives
the identical contract.
- Everything the async path already needs (KEDA,
leaf-queue/group, theleaf-work/workPVC,sandbox-0,llm-credentials, the Knative service). No new prerequisite. - The new
deploy/knative/leaf-gate-smoke.shis a test script, not a deployed component.
- Archetypes & Requirements — §3 (Archetype B), §7.6 (human-in-the-loop: gate primitive, auto-mode, gate-while-idle).
- Capability Charter — G1/G2 (harness invoked, not orchestrator), G3 (orchestrator owns the store), G8 (promote human-gate post-MVP), §8.3 (human-gate vs scale-to-zero).
- MVP Thin Slice — §2.4 idempotency (new
sessionId= fresh run), §2.5 re-entrancy, thesubmit_verdictpattern this mirrors. - Async Leaf Completion — the queue / done-marker / KEDA /
classifyOutcomesubstrate this extends; §3.5 idempotency & delivery semantics. - Scheduled Leaf Dispatch — sibling reuse of the async contract; idempotency-by-
sessionId. - Milestone Registry — Phase-1/Phase-2 numbering.
Assisted-By: Claude (Anthropic AI) noreply@anthropic.com