Files
openclaw/docs/refactor/operator-approvals.md
Peter Steinberger 5b56a1f664 feat(gateway): land remaining durable-approvals stack and repair main gates (#104837)
* feat(gateway): propagate approvals to ancestor sessions with replay

Squash-rebased #103921 segment onto the native-clients tip on current main.
Session-scoped approval events publish sanitized pending/terminal
transitions to opted-in session audiences, with authoritative pending
replay on stream subscribe and durable-expiry reconciliation through the
owning manager. SessionApprovalEvent/Replay wire types export from the
approvals owner module; Swift models regenerated.

(cherry picked from commit 2d1dcf9747044710111d0c730fc46ba6013a165c)
(cherry picked from commit ccf88efd56b513d07599ffcee997bf0cddaf9adc)
(cherry picked from commit 8eb33f59f00ec9ee4625259b76bd7ae60d2cd480)
(cherry picked from commit c297cbc93c8401e9c79c20f242245a194f5dd236)
(cherry picked from commit 93d68f28601ff37d863da4db009504993a22533a)
(cherry picked from commit d285ccd8b2f212bb57e814107e7709c896e96b7d)
(cherry picked from commit 5c536982231402b1b58d1683794ea9948b32d077)
(cherry picked from commit 1646e5b6dc4dd20de54e10f110a5909be18d7d50)
(cherry picked from commit 35cf1b705b247fb282f4d56ffe74bd2fb238221f)
(cherry picked from commit 4f8ef3699d)
(cherry picked from commit 74fbdcc99d5fff062d67760a51dba4ee9e84479b)
(cherry picked from commit 222b285502681d2be14b1819798e9ee5555f431e)
(cherry picked from commit b9e744951f676b600e7c5322bfd04b0e99797c07)
(cherry picked from commit 9e904efde5)
(cherry picked from commit 678f2384fa)
(cherry picked from commit 72842e5cf6)
(cherry picked from commit be74c25e80e84a1f065053766f23efb84822e811)
(cherry picked from commit 93ba8c4b09)
(cherry picked from commit 8f23761372)

* feat(gateway): fail-closed plugin and tool approval gates

Squash-rebased #103932 segment onto the ancestor-propagation tip on
current main. Plugin node.invoke approvals claim a one-shot allow-once
decision before handing execution authority to the policy, so observation
or retry cannot replay a consumed approval; sibling tool gates bind
approval ids to their originating reviewer identity.

(cherry picked from commit 122df0d75281f572c012b6484ed5daf085d1d577)
(cherry picked from commit f23d8ac240a8dcf2a42c4daaae962263975a0ae8)
(cherry picked from commit 8632fb6436a224dac7a9a9ef0216884530de2c24)
(cherry picked from commit d9fe2dd5c53665e5732f5f3da5ed1907a686ade8)
(cherry picked from commit bb139f2c8aa36ddad70413e1ef79ff491a6f2ecd)
(cherry picked from commit 9b3e056b68ea515c7d4baef269289b6670570480)
(cherry picked from commit b11e66b59a7af1f3b08712de06864aed64d349e4)
(cherry picked from commit a12916ee592d03dfa35e86a2aaede4476a5d4e5d)
(cherry picked from commit d699de840fc8346892b9ae244fe3c3e8c4413032)
(cherry picked from commit 9b7e5a9608)
(cherry picked from commit 4b507593f1b2f10b4496984c84a2803b853e5c5e)
(cherry picked from commit 56408a186733c4eeb5aa76bd5907cde7b0cd3d5b)
(cherry picked from commit a4051d6d8353d39d6fa0b5d69c36e06c3cd3e796)
(cherry picked from commit 76829805a7)
(cherry picked from commit a8b493f934)
(cherry picked from commit aceb990597)
(cherry picked from commit a29b0e75482470c53a9fb4fe3f204e14cf71868f)
(cherry picked from commit 2e3be08653)
(cherry picked from commit c9ae3d7202)

* fix: main-gate repairs for the durable-approvals stack

- android: capture createdAtMs on the pending exec-approval write at
  registration; canonical readback after a refresh that already replaced
  the visible rows was dropping the approval instead of surfacing the
  still-pending reconciliation message (#104913 merged without this)
- android: generous CI timeout ceilings in GatewayExecApprovalRuntimeTest
- agents: hermetic model-discovery test via the plugins/provider-runtime
  boundary (lazy plugin-runtime resolution hangs vitest workers since #104770)
- cli: usage-cost settle-budget test asserts the budget bound on every call
  instead of pinning the poll count (fast hosts fit a second poll in 50ms)
- protocol coverage: allowlist session.approval for ios/android (native
  review rides exec.approval push/nudge delivery)
- docs map, native i18n inventory, plugin-sdk api baseline regenerated
2026-07-12 10:51:38 +01:00

38 KiB

summary, read_when, title
summary read_when title
Design for durable, deep-linkable approvals across Control UI, native apps, channels, and parent sessions
Changing exec or plugin approval lifecycle, storage, protocol, or authorization
Adding approval links or native approval controls to a channel
Projecting child-session approvals into parent or orchestrator views
Multi-surface operator approvals

Multi-surface operator approvals

This design tracks #103505. It replaces process-local approval authority with one Gateway-owned, SQLite-backed lifecycle. Every Gateway-owned exec or plugin/tool approval gets one stable ID, one authenticated Control UI route, atomic first-answer-wins resolution, and operator-only projections to its source and ancestor session streams.

Inline actions and deep links coexist. There is no approval-mode toggle.

Goals

  • One durable approval object for exec and plugin/tool gates.
  • Stable ${controlUiBasePath}/approve/{approvalId} route.
  • Resolution from any authorized Control UI, native app, or channel surface.
  • Atomic first-answer-wins behavior across concurrent surfaces.
  • Idempotent identical retries; conflicting late answers cannot overwrite the winner.
  • Timeout, malformed trusted verdicts, missing routes, cancellation, and restart fail closed.
  • Requested and terminal events reach the source session and all relevant parent/orchestrator owners.
  • Channels receive typed approval and navigation actions; transport callback data remains channel-private.
  • Existing exec/plugin Gateway methods remain compatible while their implementation converges on one service.

Non-goals

  • Persisting or resuming the blocked tool execution itself across Gateway restart.
  • Making an approval ID or URL a bearer credential.
  • Appending approval prompts to model-visible transcripts or waking parent agents.
  • Moving approval policy, product commands, or reviewer authorization into channel plugins.
  • Cloning approval state per channel, device, or ancestor.
  • Redesigning exec allowlists, plugin policy composition, or allow-always persistence except where required to make terminal outcomes unambiguous.
  • Making a gatewayless embedded TUI remotely reachable in the first increment. It remains local-only and must fail closed when no reviewer exists.

Pre-rollout baseline and evidence map

This table records the implementation state when #103505 was opened. The rollout sections below track the durable registry, typed actions, deep-link page, and native-client increments built on top of that baseline.

Surface Baseline entry point and owner Baseline behavior and gap
Agent exec src/agents/bash-tools.exec-approval-request.ts, src/agents/bash-tools.exec-host-shared.ts Two-phase exec.approval.* registration prevents an early /approve race, but timeout can still become allow through askFallback.
Plugin tool gate src/agents/agent-tools.before-tool-call.ts Requests plugin.approval.*; timeoutBehavior: "allow" can approve a timed-out gate. Embedded mode has separate process-local authority in src/infra/embedded-plugin-approval-broker.ts.
Plugin node gate src/gateway/node-invoke-plugin-policy.ts Creates and broadcasts directly through the plugin manager, duplicating part of the server-method lifecycle.
Gateway authority src/gateway/server-aux-handlers.ts, src/gateway/exec-approval-manager.ts, src/gateway/server-methods/approval-shared.ts Separate exec and plugin managers use process-local maps. Terminal entries survive for 15 seconds. First-answer-wins holds only inside one process.
Gateway protocol packages/gateway-protocol/src/schema/exec-approvals.ts, packages/gateway-protocol/src/schema/plugin-approvals.ts, src/gateway/methods/core-descriptors.ts Exec has pending-only get; plugin has no get; no kind-agnostic terminal lookup exists for a deep link.
Delivery src/infra/exec-approval-channel-runtime.ts, src/infra/approval-native-runtime.ts, src/infra/approval-handler-runtime.ts Supports origin routing, approver DMs, pending replay, native handlers, and in-process terminal cleanup. A separate follow-up adds durable terminal reconciliation.
Portable actions src/interactive/payload.ts, src/plugin-sdk/interactive-runtime.ts, src/plugin-sdk/approval-reply-runtime.ts Approval buttons are command actions containing /approve ...; URL and Web App targets are untyped button fields.
Telegram extensions/telegram/src/approval-handler.runtime.ts, extensions/telegram/src/button-types.ts The renderer parses command text to recognize approval semantics before producing private callback data.
Control UI ui/src/app/exec-approval.ts, ui/src/app/overlays.ts, ui/src/components/exec-approval.ts Approval UI is a global modal. ui/src/app-route-paths.ts and ui/src/app-routes.ts use exact routes and rewrite unknown paths to Chat.
Session ownership src/agents/subagent-registry.types.ts, src/agents/subagent-registry-read.ts, src/config/sessions/types.ts Controller, requester, explicit parent, and legacy spawn ownership exist, but approval events are not projected to those session streams.
Shared state src/state/openclaw-state-schema.sql, src/state/openclaw-state-db.ts Existing immediate transactions and Kysely conditional updates support durable compare-and-set in state/openclaw.sqlite.

Representative current tests include src/gateway/exec-approval-manager.test.ts, src/gateway/server-methods/approval-shared.test.ts, src/agents/bash-tools.exec-gateway-approval.e2e.test.ts, extensions/telegram/src/approval-handler.runtime.test.ts, and ui/src/e2e/approval-flow.e2e.test.ts.

The plugin SDK remains the only channel/plugin boundary. Approval runtime and presentation changes must be exported through the existing src/plugin-sdk/approval-*.ts and src/plugin-sdk/interactive-runtime.ts subpaths; plugin production code must not import Gateway internals.

Prior art

Omnigent provides useful UX and failure semantics:

  • approval.py parks ASK, applies per-policy timeouts, and treats only an exact accept as approval.
  • sessions.py contains the server-side native harness gate and ancestor request/resolution projection.
  • ApprovePage.tsx provides the standalone mobile approval page.

Do not copy its storage claim uncritically. Current active pending state is process-local in _elicitation_registry.py, and the unused pending table is removed by e3b1f2a4c9d7_drop_pending_tool_calls_table.py. OpenClaw deliberately goes further: SQLite is authoritative and every terminal transition is a database compare-and-set.

Architecture and ownership

The Gateway owns the lifecycle:

  1. An agent, plugin hook, or node policy supplies a kind-specific request and process-local execution binding.
  2. The Gateway validates it and builds a sanitized reviewer projection.
  3. The approval service computes a source/owner audience, inserts the canonical row, then registers the in-process waiter.
  4. After durable insert, the Gateway publishes existing approval events, session projections, channel notifications, and native push.
  5. Every surface resolves through the same service.
  6. The service commits one terminal transition, wakes the runtime waiter, and publishes terminal projections.
  7. A failed event delivery never rolls back the committed decision; clients recover through approval.get or list replay.

Ownership boundaries:

  • src/gateway/: approval service, authorization, RPC adapters, URL construction, waiter lifecycle, and event publication.
  • src/state/: shared schema and generated Kysely types.
  • src/infra/: sanitized approval view models and portable presentation construction.
  • src/agents/: request, wait, and apply the returned verdict; no persistence.
  • src/channels/ and extensions/*: render typed actions, authorize channel users, encode private callbacks, and update delivered controls.
  • src/plugin-sdk/: public approval and presentation contracts only.
  • ui/: standalone page and existing queue/modal clients.

The in-process waiter is a notification mechanism, not authority. Registration inserts the row and installs the waiter synchronously before publishing the request, so a resolver cannot interleave between those steps. Every later resolver commits through SQLite before settling that waiter.

Persistent record

Add one operator_approvals table to the shared state database.

Column Purpose
approval_id Globally unique canonical ID. Keep existing exec IDs and plugin: IDs for protocol compatibility, but never infer kind from the prefix.
resolution_ref Unique full SHA-256 base64url locator for transport callbacks that cannot carry the canonical ID. It is not authorization or a public URL ID.
kind Closed exec | plugin discriminator.
status Closed pending | allowed | denied | expired | cancelled state.
presentation_json Validated, kind-tagged reviewer projection. Raw runtime requests, command bindings, and callback payloads remain process-local.
source_agent_id, source_session_key Source identity and session projection anchor. Session key is durable; rotating session UUID is not.
audience_session_keys_json Ordered, de-duplicated JSON array produced by the bounded breadth-first ownership walk. Requested and terminal events use this same snapshot.
requested_by_device_id, requested_by_client_id Durable requester/audit metadata. Connection ID stays in memory and is not a cross-surface principal.
reviewer_device_ids_json Optional explicitly targeted reviewer devices supplied only by the trusted approval runtime.
runtime_epoch Process epoch that owns the parked execution; used to cancel orphaned rows after restart.
created_at_ms, expires_at_ms, updated_at_ms Authoritative timing.
decision Explicit user decision when one exists.
terminal_reason Closed reason such as user, timeout, malformed-verdict, no-route, run-aborted, or gateway-restart.
resolved_at_ms, resolver_kind, resolver_id Winner and audit identity retained server-side. Reviewer projections omit raw resolver identifiers.
consumed_at_ms, consumed_by Separate replay guard for allow-once; consuming must not erase the recorded decision.

Required indexes:

  • unique (resolution_ref); inserts also reject cross-column approval_id/resolution_ref ambiguity
  • (status, expires_at_ms)
  • (source_session_key, created_at_ms DESC)
  • (resolved_at_ms) for retention pruning

Audience arrays are small and bounded. Session-filtered replay first selects visible pending rows through Kysely, then decodes and filters the bounded audience arrays in application code; it does not use string matching or raw SQL JSON queries.

Retain terminal rows for 30 days, aligned with metadata audit retention in src/audit/audit-event-store.ts. Pruning is fixed maintenance policy, not a new config surface. The database is private local control-plane state, but reviewer APIs must never expose the full stored request or runtime binding.

State machine and compare-and-set

Only these transitions are valid:

  • pending -> allowed: explicit allow-once or allow-always.
  • pending -> denied: explicit deny, trusted malformed terminal verdict, or no delivery route.
  • pending -> expired: authoritative deadline reached.
  • pending -> cancelled: run abort, graceful shutdown, or restart orphan recovery.

Every non-allowed terminal state has effective verdict deny.

Resolution uses one immediate SQLite transaction and a Kysely conditional update equivalent to:

UPDATE operator_approvals
SET status = ?, decision = ?, terminal_reason = ?, resolved_at_ms = ?
WHERE approval_id = ?
  AND status = 'pending'
  AND expires_at_ms > ?;

If the update affects no row, the same transaction reads the record:

  • Missing or unauthorized: return not found; do not reveal existence.
  • Still pending but deadline reached: compare-and-set it to expired, then return that terminal row.
  • Same recorded decision: return idempotent success with the recorded winner.
  • Different decision: the unified API returns applied: false with the recorded winner; legacy adapters retain APPROVAL_ALREADY_RESOLVED where required by their shipped contract.
  • Any terminal state: never mutate it.

now == expires_at_ms is expired. Gateway time is authoritative.

allow-once execution uses a second CAS over consumed_at_ms IS NULL, bound to the existing exact command/system-run context. The approval row remains an audit record after consumption.

Malformed HTTP/RPC input that cannot be authenticated or identify an approval is rejected without mutation and can never approve. A malformed terminal verdict received from a trusted harness/waiter for a known approval transitions to denied.

Gateway API

Add kind-agnostic reviewer methods:

Method Contract
approval.get { id } Returns a visible pending or retained terminal projection.
approval.resolve { id, kind, decision } Accepts the canonical ID or fixed-size transport reference, then runs authorization, kind and allowed-decision validation, deadline reconciliation, and terminal CAS. The response always carries the canonical ID.

After a successful CAS, return the committed projection immediately. Legacy events, channel forwarders, and push terminalizers are best-effort follow-ups; a slow or failed surface must not delay or roll back the winning response.

Kind-specific request validation remains in exec.approval.request and plugin.approval.request. Existing exec.approval.get/list/waitDecision/resolve and plugin.approval.list/waitDecision/resolve become protocol-boundary adapters to the canonical service because they are shipped Gateway API. Internal callers migrate to the service in the same change.

A reviewer projection is a tagged union:

type OperatorApproval = {
  id: string;
  status: OperatorApprovalStatus;
  presentation:
    | { kind: "exec"; commandText: string /* safe exec preview */ }
    | { kind: "plugin"; title: string; description: string /* safe plugin preview */ };
  // common lifecycle fields
};

The stable path is derived, not persisted. approval.get returns urlPath; surfaces that know an approved public origin may also receive an absolute url. Reviewer snapshots omit source and audience session keys. The Gateway keeps those routing keys server-side for the separate session.approval projection.

Events and portable actions

PR 1 preserves the shipped event names, payloads, and existing record-level recipient filters:

  • exec.approval.requested
  • exec.approval.resolved
  • plugin.approval.requested
  • plugin.approval.resolved

Those legacy events can contain the full runtime request, so they must not be fanned out to every approval-scoped client. PR 5 adds tagged lifecycle fields (status, sourceSessionKey, urlPath, terminal metadata, and a presentation-level kind) through the sanitized lifecycle projection instead of widening legacy event delivery.

Add an approval-scoped session.approval projection event. Publish the canonical event once with the persisted audience keys; exact-session subscribers receive the same event for each matching key:

  • sessionKey: stream receiving the projection.
  • sourceSessionKey: child/source that raised the gate.
  • phase: pending \| terminal, discriminated against the approval status.
  • one safe OperatorApproval projection.

Clients opt in with sessions.messages.subscribe { key, agentId?, includeApprovals: true }. The successful response adds an approvalReplay containing up to 1,000 current pending approvals for that exact stream key that the subscribing client is also record-authorized to review. truncated: false makes the filtered replay authoritative and reconnecting clients replace their local pending set with it; truncated: true is an overload signal and clients must keep unseen local entries until canonical lookup or later lifecycle events settle them. A later durable timeout discovered during replay emits terminal tombstones only to subscribed, record-authorized audiences before the new snapshot is returned. operator.admin may opt in directly; narrower clients require both a paired device identity and operator.approvals. Session subscription alone never grants approval visibility.

Register the event under operator.approvals in src/gateway/server-broadcast.ts. The projection is observational: it never appends transcript rows, emits sessions.changed, or wakes an agent.

Extend MessagePresentationAction in src/interactive/payload.ts:

type MessagePresentationAction =
  | { type: "command"; command: string }
  | { type: "callback"; value: string }
  | {
      type: "approval";
      approvalId: string;
      approvalKind: "exec" | "plugin";
      decision: ExecApprovalDecision;
    }
  | { type: "url"; url: string }
  | { type: "web-app"; url: string };

Core builds typed decision actions and a separate Review link when an approved absolute Control UI origin is available. Channels encode an approval action into their own callback format and send resolution to the canonical service. A callback uses the exact canonical ID when it fits; otherwise it uses the row's unique full-digest resolution_ref. The reference is only a compact lookup key: normal Gateway authentication, record authorization, explicit kind, allowed-decision validation, deadline reconciliation, and first-answer CAS still apply. Channels must not truncate IDs, resolve hash prefixes, parse /approve text, or infer kind from an ID prefix.

Keep button.url, button.webApp, and command-backed approval controls as deprecated plugin SDK compatibility inputs. Normalize them at the SDK boundary; migrate every bundled internal caller in the same PR. /approve {id} {decision} remains a text fallback and CLI/chat command, not the button semantic contract.

Control UI

The route is ${basePath}/approve/{approvalId}. The ID is the only path parameter; source session identity comes from the record.

Because the current router has exact static routes and rewrites unknown paths to Chat, detect this deep link in ui/src/app/bootstrap.ts before normal route normalization. Reuse normal Gateway/auth setup, but render a standalone approval page outside the sidebar shell and global modal.

The document is owned by the Gateway that served its URL. Its initial connection ignores the full app's persisted remote-Gateway selection without changing or copying that selection's settings; only auth stays session-scoped to the serving Gateway. Trusted native auth or a separately confirmed gatewayUrl override may retarget it. The core reserves the one-segment /approve namespace ahead of plugin HTTP routes and static-extension detection, including IDs that end in .json or .js; when Control UI serving is disabled, the reserved route fails closed with 404. Keep the page in the main Control UI bundle so a failed lazy chunk cannot strand a security decision on a spinner.

Page states:

  • loading
  • authentication required
  • pending
  • resolving
  • approved or denied here
  • resolved elsewhere
  • expired
  • cancelled
  • forbidden/not found
  • connection error with retry

The page calls Gateway RPC, not a second unauthenticated REST API. A browser refresh re-reads durable state. It never places Gateway credentials in the URL, query, or fragment.

Authorization and privacy

The URL is a locator, not authority. Resolution requires:

  1. authenticated Gateway connection;
  2. operator.approvals or operator.admin;
  3. record-level reviewer authorization.

Record-level rules:

  • operator.admin may review.
  • reviewer_device_ids is authoritative when present. Only a listed paired operator.approvals device may review; the requesting device has no implicit access unless it is also listed.
  • Without an explicit reviewer list, the requesting paired operator.approvals device may review its own record.
  • Genuinely legacy records with no requester or reviewer binding retain broad paired-device visibility so upgrades do not strand already-pending work.
  • Device-less internal runtimes may resolve, but not read, through the scoped approval-runtime connection. That authority comes only from the server-authenticated runtime token; public approval.resolve fields cannot mint it.
  • Live requester connection ownership remains valid for legacy adapters; it is never inferred from a matching client name.
  • Audience membership changes presentation only. It never widens authorization.

approval.get exposes only the sanitized reviewer projection and omits internal source/audience routing keys. The PR 5 session.approval event carries its one destination sessionKey plus sourceSessionKey after the Gateway applies the persisted audience snapshot server-side. Existing exec/plugin events keep their historical payload and restricted recipients until consumers migrate. The executable request, command binding, and continuation remain only in the process-local waiter. The durable row contains the safe presentation plus lifecycle, routing, and audit metadata; it never stores raw environment values, credentials, auth headers, or channel callback data.

Audience projection

Compute the audience once before insert and persist the ordered snapshot. Ownership is a graph, not always a single parent chain: a child may have both a current controller and an original requester, and those owners can lead to different roots.

Use a deterministic breadth-first walk:

  1. Seed the queue with the source session key.
  2. For each dequeued key, read the latest subagent registry row and enqueue both distinct ownership edges in fixed order: controllerSessionKey, then requesterSessionKey.
  3. When a usable registry row exists, do not also follow session-entry lineage that may be stale after steering. Otherwise enqueue the single current fallback edge parentSessionKey ?? spawnedBy.
  4. Normalize and de-duplicate on enqueue so the first, shortest path wins.
  5. Stop at 64 unique keys; this audience-size cap also bounds traversal depth.

The registry source is src/agents/subagent-registry-read.ts; ownership fields are defined in src/agents/subagent-registry.types.ts. Session fallback fields are defined in src/config/sessions/types.ts.

Requested and terminal projections use the same persisted audience even if focus/controller ownership changes while the approval is pending. This guarantees terminal cleanup for every audience session stream that received the request projection. Resolution always targets the source approval ID; audience sessions never receive cloned approval state. Forwarded channel-message cleanup remains the separate delivery-locator follow-up below.

Do not write transcript messages, inject system prompts, start owner turns, or emit sessions.changed solely for an approval.

Delivered-surface convergence

Native approval handlers already retain their delivered message entries long enough to replace or retire active controls. Generic forwarded approval messages currently discard the MessageReceipt, so a decision on another surface can leave their old controls looking pending. A separate follow-up closes that gap with an operator_approval_deliveries child table in the shared state database.

Each row stores the approval ID, a unique delivery ID, channel/account/exact route, a bounded JSON-validated channel-private message locator, delivery timestamps, and terminalization state. It never stores callback data, decision tokens, or raw approval requests. The channel owns locator encoding and message mutation; core owns canonical status, target selection, retry policy, and fallback terminal text.

Delivery registration and terminal resolution race safely:

  1. After a pending send returns its receipt, insert the delivery locator and read the parent approval status in one transaction.
  2. If the parent is already terminal, schedule immediate terminalization instead of leaving the late delivery pending.
  3. Every committed terminal transition separately schedules all unfinalized delivery rows; droppable broadcasts are not the trigger.
  4. A channel terminalizer reports replaced, retired, or unsupported. Replaced suppresses a duplicate terminal message; retired sends the existing terminal follow-up; unsupported or failure falls back without rolling back the approval CAS.
  5. Startup retries terminal approvals with unfinished deliveries, making cleanup resilient to Gateway restart.

This transport lifecycle is an optional delivery adapter hook, not a renderer or model-facing message action. QQ C2C/group messages currently have no edit, delete, or keyboard-clear API; that adapter remains unsupported and can only show canonical truth after a later click until the transport gains a mutation API.

Restart, timeout, and route semantics

SQLite persistence does not imply execution resumption. Command/tool bindings remain in memory because they can contain security-sensitive runtime facts and are not a resumable job contract.

On Gateway startup:

  • generate a new runtime epoch;
  • atomically transition pending rows from older epochs to cancelled with reason gateway-restart;
  • retain rows so their URLs explain what happened;
  • never execute a later approval against a missing runtime binding.

Timers are wake-up optimizations. Deadline authority is stored expires_at_ms; reads, waits, and resolves all run expiry reconciliation.

Final strict behavior:

  • timeout -> expired, deny;
  • no route -> denied, deny;
  • run abort -> cancelled, deny;
  • malformed trusted verdict -> denied, deny;
  • only an allowed explicit allow decision -> allowed.

Current shipped exec behavior still conflicts with this contract:

  • src/agents/bash-tools.exec-host-shared.ts may apply askFallback.
  • docs/tools/exec-approvals.md and docs/cli/approvals.md document that surface.

Plugin approvals now fail closed on timeout and malformed verdicts; the legacy timeoutBehavior field remains accepted but ignored. The exec strict-semantics follow-up must update code, types, docs, tests, and changelog together, with explicit owner/security review. askFallback may continue to describe pre-gate policy selection during migration, but it must not turn a created pending record's timeout into approval.

Compatibility plan

  • Additive Gateway protocol; no protocol version bump.
  • Preserve existing exec/plugin methods and events at the external boundary.
  • Keep existing IDs, including plugin: prefixes, but stop using prefixes as type information.
  • Keep /approve text command behavior.
  • Keep legacy button URL/Web App fields and command actions as plugin SDK compatibility input; new core output is typed.
  • Migrate all bundled channels and internal callers in the same typed-action change.
  • Add a changelog entry for the new URL/page and for the later timeout behavior change.
  • Do not add an elicitation-mode setting.

Rollout

PR 1: durable lifecycle

  • This design note.
  • Shared SQLite schema, Kysely generation, store, and 30-day pruning.
  • Gateway approval service, runtime waiter bridge, and restart orphan handling.
  • Unified approval.get/resolve.
  • Exec/plugin method adapters.
  • First-answer-wins, idempotency, expiry, authorization, and consumption tests.
  • No UI or channel behavior change yet.

PR 2: typed actions and channel callbacks

  • Typed approval, URL, and Web App actions.
  • Core presentation builders and plugin SDK exports.
  • Transport-private callback encoding with explicit owner kind.
  • Durable fixed-size callback references for canonical IDs beyond transport limits.
  • Bundled channel migration away from command-text and approval-ID inference.
  • Canonical first-answer truth on the clicked surface and best-effort active-native terminal updates; durable channel-message terminalization remains a follow-up.
  • SDK and bundled-channel tests.
  • Standalone authenticated approval page and base-path-aware startup routing.
  • Serving-Gateway binding without mutating the operator's saved remote selection.
  • Core-owned approval HTTP namespace, including asset-like IDs.
  • Gateway-authored URL payload and pending-state polling until lifecycle events ship.
  • Mobile-width, reconnect, competing-answer, reload, and mounted-path proof.

PR 4: native clients

  • iOS and Android review surfaces use kind-aware approval.get/resolve; watchOS relays reviewer-safe prompts and decisions through the paired iPhone.
  • Watch offers the exec decisions supported by its compact relay contract: allow once and deny.
  • Canonical first-answer terminal truth replaces local attempted-decision state.
  • Lost or ambiguous resolve acknowledgements freeze controls until canonical readback.
  • Previous shipped Gateway v4 instances retain exec review through a narrow legacy-method fallback; retained cross-surface terminal state requires the unified methods.
  • Reviewer warnings and owner context remain visible across iPhone, Watch, and Android.
  • Native unit, build, and platform proof.

PR 5: ancestor lifecycle propagation

  • session.approval pending/terminal delivery from the audience snapshot persisted in PR 1.
  • Exact-session subscription, reconnect replay, and terminal tombstones without transcript mutation or agent wake.
  • Lifecycle callbacks run after durable insert/CAS and never become approval authority.
  • Nested-subagent and reconnect proof.

PR 6: fail-closed behavior

  • Migrate node-invoke-plugin-policy.ts and the embedded plugin broker away from duplicate authority.
  • Strict timeout, malformed, no-route, binding, and allow-once consumption semantics.
  • Deprecate shipped permissive timeout settings without honoring them after an ask is pending.
  • Multi-surface contention and failure-injection proof.

Follow-up: durable remote-message cleanup

  • Persist forwarded-delivery locators and terminalize every delivered channel message after restart.
  • Keep this transport lifecycle separate from canonical approval authority and typed presentation actions.

Tests

Required focused coverage:

  • SQLite reopen preserves pending and terminal projections.
  • Two concurrent resolvers produce exactly one CAS winner.
  • Same-decision retry succeeds idempotently; conflicting retry returns the recorded winner.
  • Resolve at or after deadline cannot approve.
  • allow-once is consumable exactly once without erasing terminal audit state.
  • Startup cancels older runtime epochs.
  • Unauthorized lookup and resolution do not reveal record existence.
  • Explicit reviewer allowlist and general paired operator.approvals behavior.
  • Exec and plugin legacy methods share the same store.
  • Gateway request/list/get/resolve schemas and additive event payloads.
  • Typed-action normalization, fallback rendering, SDK exports, and bundled channel switches.
  • Telegram callback encoding contains transport-private data and no command-string inference.
  • Direct child, branched controller/requester owners, nested owners, reassignment, session-field fallback, cycle, and audience-size cap.
  • Requested and terminal audience arrays are identical.
  • Owner projections cause no transcript mutation or agent wake.
  • Control UI route works at / and a configured base path; refresh shows pending or terminal truth.
  • Simultaneous Control UI and Telegram answers show one winner and "resolved elsewhere" on the loser.
  • Native approval identifiers and Gateway owner identifiers preserve exact UTF-8 bytes across routing and reconciliation.
  • Native RPC-family negotiation pins one canonical or legacy family per admitted Gateway route and never silently downgrades after use.
  • Lost native resolve acknowledgements freeze actions until canonical readback; failed readback cannot fabricate a winner or acknowledge a Watch refresh.
  • Watch snapshot request correlation is accepted only for the exact paired Gateway owner and a completed canonical iPhone readback.
  • User-path proof through Testbox/Crabbox, including a mobile-width approval page, Telegram action cleanup, and one pending/resolve/late-loser round trip across Android, iPhone, and Watch.

Observability

Emit structured, content-free transition logs with approval ID, kind, source session key, status, reason, and latency. Never log the preview or raw binding.

Track:

  • requested count by kind;
  • terminal count by kind/status/reason;
  • pending gauge;
  • request-to-terminal latency;
  • resolution race outcomes: winner, idempotent retry, conflict, expired;
  • delivery route count and no-route denials;
  • startup-orphan cancellations;
  • audience size.

A committed transition is success even if later event delivery fails. Lifecycle subscribers recover through PR 5 replay and canonical lookup. Durable channel-message terminalization remains the separate follow-up above.

Open decisions

  1. Externally reachable Control UI origin. Every snapshot carries the stable relative urlPath. An absolute URL may be advertised only from a cached Tailscale Serve/Funnel location after Gateway exposure succeeds; allowedOrigins, request Host headers, gateway.remote.url, and display-only loopback/LAN candidates are not canonical origins. Telegram can use its authenticated Mini App wrapper to retain the approval path through bootstrap. Arbitrary reverse proxies remain relative-only until a separately reviewed explicit public-URL contract exists. Never let a channel guess the origin.
  2. Exec strict timeout compatibility cutover. Plugin approval timeouts now fail closed and timeoutBehavior is deprecated. The remaining shipped askFallback contract needs explicit owner/security review, changelog, docs, and a migration/deprecation decision before it stops authorizing execution after a pending ask times out.
  3. Gatewayless embedded mode. Recommended: keep it local-only initially, then make it a client of the canonical service when a Gateway exists. Do not advertise a deep link that no server can resolve.