RFC: One durable primitive for questions an agent asks a human
Problem
ZeroClaw already contains a correct implementation of "an agent asked a human something and must survive until they answer". It is the SOP approval gate, and nothing else in the codebase uses it.
SOP gates are durable: runs and their gate events persist to SQLite (sop_runs, sop_events, sop_claims, sop_proposals), restore_runs() rehydrates them at boot, and the park notice is deliberately re-sent after restore, with delivery documented as at-least-once. Timeout escalates rather than denies. Every answer path — the agent tool, the CLI, a gateway WebSocket frame, POST /admin/sop/approve, the timeout tick — funnels through one chokepoint, resolve_gate(engine, run_id, decision, principal). Discord renders them as stateless buttons that still work after a restart.
Every other question an agent asks a human has none of that:
- Tool approvals are an in-memory one-shot parked in the process. Each channel holds its own
Arc<Mutex<HashMap<token, Sender>>>, built empty at construction — Discord, Lark, Matrix, Telegram and the rest — and the gateway'sPendingApprovalsis minted per WebSocket connection. A daemon restart, or merely a reconnect, destroys every pending approval. - Timeout always denies. Not escalate, not re-ask.
ws_approval.rsreturnsChannelApprovalResponse::DenywithApprovalSource::TimedOut, and the model is told the call was refused. - The answer must come back on the same channel object, and usually the same room. There is no HTTP path for a tool approval, and
email_channel.rscontains norequest_approvalimplementation at all, so every gated tool auto-denies there via the trait default. - The human is misled on recovery. After a restart their answer finds no entry and they are told the request was "already resolved". It was not resolved; it was lost, and a denial was recorded some time earlier.
ask_userhas no correlation at all. It spawns its own listener and takes the first message that arrives on the channel, with no id, no sender filter and no thread filter. Even in a healthy process the wrong person's unrelated message can answer an operator question.- The approval audit log has no production reader.
ApprovalManagerappends decisions to aVec; the only callers ofaudit_log()are inapproval/mod.rs's#[cfg(test)]module and inagent/safety_net.rs, which is itself a test module pinning turn-engine behaviour. It is allocated, written to, and dropped at process exit. - Nothing records that a question was asked.
GateEventKind::Requestedis declared insop/approval/ledger.rsand appears nowhere else but its ownas_strarm — it is never constructed. Even for SOP gates the ledger records resolutions, escalations and timeouts, never the ask, so "what is outstanding?" can only be inferred from run status.
Proposal
Extract the durable-question machinery out of SOP and re-implement tool approvals and free-form asks on top of it.
One primitive with:
- A durable record per outstanding question — id, asking agent, question kind, payload, route, created-at, deadline, state — in the same append-only shape
sop_eventsalready uses (seq, run_id, ts, kind, actor, reason, payload). - Boot replay with at-least-once re-notification, exactly as
restore_runs()plus the park-request replay already does. - One resolve chokepoint reachable from every surface, so an answer is valid wherever it arrives rather than only on the channel that asked.
- A policy-selected timeout outcome — escalate, re-ask, or deny — instead of today's hard-coded deny.
- An explicit "asked" event, so "what is outstanding?" and "what did you ask me while I was away?" are answerable. This is the currently-unconstructed
Requestedvariant, made real.
SOP gates keep their own policy layer (quorum, required group, routes) and become the first consumer rather than the only implementation.
Design sketch
Affected surfaces: zeroclaw-runtime approval and SOP modules, every channel that renders an approval prompt, the gateway WebSocket and admin API, ask_user, escalate_to_human.
Proposed phases:
- Phase 1, the store and the chokepoint. Lift the durable record, boot replay and resolve path out of SOP into a general module, with SOP re-pointed at it and its behaviour unchanged. No user-visible change; the test that matters is that SOP gates still behave identically.
- Phase 2, tool approvals. Re-implement the channel approval registries on the durable store. Pending approvals survive restart; the misleading "already resolved" becomes an honest re-ask or an explicit expiry. Timeout policy becomes configurable with today's deny as the default, so nothing changes until an operator opts in.
- Phase 3, free-form asks. Give
ask_usera correlation id so an answer is matched rather than raced, removing the competing-listener behaviour. - Phase 4, the query surface. Expose outstanding and recently-expired questions through the admin API, which is what makes an unanswered question visible instead of silent.
This pairs naturally with delivery receipts for outbound messages but does not depend on them: this proposal is about the question's durability, that one is about whether the notification arrived.
Alternatives considered
- Do nothing. Approvals keep evaporating on restart, and the operator keeps being told their answer was already handled.
- Persist each channel's registry separately. Several stores with several lifecycles, and it still leaves the same-channel-only answer constraint and the timeout semantics untouched.
- Route everything through SOP as it stands. Forces unrelated concepts through SOP's run and step model, making a simple tool approval carry a workflow.
- Only fix
ask_user's correlation. Cheap and worth doing regardless, but it leaves durability and the single-channel constraint in place.
Non-goals
- Changing what an approval means, or the security semantics of who may answer. Authorization stays where it is.
- Moving SOP's policy layer, quorum or route configuration into the general primitive.
- Guaranteeing a human answers. This makes the question durable and its state visible; it does not make anyone reply.
- Reworking elicitation over ACP, which is a live request-response on a connection by design.
Risks and mitigations
- A durable approval can be answered late. A question that outlives its context is a real hazard: a tool call approved an hour after the turn ended must not execute. Mitigated by binding each record to the turn or run that created it and refusing resolution once that context is gone, with an explicit expiry event rather than silence. This is the part to review hardest.
- Behaviour change on timeout. Default to today's deny so the change is opt-in, and make escalation something an operator chooses.
- Answer-from-anywhere widens the authorization surface. The resolve path must apply the same responder checks the channels apply today, and should land with adversarial tests rather than only happy-path ones.
- Store growth and personal data. Bounded retention, and actor or recipient values digested rather than stored raw, per the privacy contract.
- Rollback. Phase 1 is a refactor with no behaviour change and reverts cleanly. Later phases sit behind the timeout-policy default and can be reverted per phase.
Breaking change?
Potentially - needs investigation
Decision and revisit surface
This issue for the shape, then a proposal PR for phase 1, which should be judged purely on "SOP behaviour is unchanged". Phases 2 onward are worth re-deciding after phase 1 lands, since together with delivery receipts they determine how much of the silent-failure surface is actually left.
Data hygiene checks
- I removed personal/sensitive data from examples, payloads, and logs.
Source: zeroclaw-labs/zeroclaw