Skip to content

The approval-escalation path

When an action needs a human, it must not fail flat and it must not slip through. It must pause, route up to you, wait, and — only on your explicit approval — execute exactly once and resume. That round-trip is the approval-escalation path.

It is a full pause → route-up → approve → resume → fail-closed engine, and it is the single escalation route for both executors: a Commander verb denied on a box and a workflow tool call marked needs-approval travel the same path.

The pending-escalation lifecycle

flowchart TD
    D[Action gated: needs approval<br/>or capability deny] --> E[enqueue PendingEscalation<br/>bound to principal · verb · target · params_hash]
    E --> P[Proposal surfaces up<br/>Hub → Facet → you]
    P --> N[Push to your phone]
    N --> DEC{You decide}
    DEC -->|approve| ND[never-self-approve check<br/>decider ≠ asker]
    ND --> M[mint one-shot grant<br/>only from a decided row]
    M --> MB[match-before-burn<br/>on params_hash]
    MB --> X[execute once → resume]
    DEC -->|deny| R[Rejection → asker]
    E -.->|1h TTL elapses| W[watchdog: expire_and_deliver]
    W --> FC[expired + Rejection<br/>FAIL CLOSED]

Enqueue

On a denial that should escalate, the engine records a pending escalation bound to the exact tuple (principal, verb, target, params_hash), with status = pending and a 1-hour TTL. The params_hash — a hash over the verb, target, and params — is the anti-forge binding: the approval is nailed to this action.

Route up + notify

The pending escalation surfaces upward as a proposal on the message bus, through the Hub to Facet, and a push notification goes to your phone — you are not required to poll a queue to discover that something is waiting.

Decide

You approve or deny. Approval flows through a single guarded operation that, in one transaction:

  • enforces never-self-approve — the decider must be distinct from the asker principal (a WHERE-clause guard plus a pre-check), so the model that proposed an action can never be the one that approves it;
  • checks the escalation is still unexpired;
  • is single-consume — a guarded conditional update means an approval can be acted on exactly once, with no double-spend race.

Mint, match, burn, execute

On an approved row:

  • a one-shot grant is minted — and only from an operator-decided row; a uniqueness constraint on the approval id blocks minting two grants from one approval;
  • the grant is principal-scoped and single-use, valid for a short window;
  • match-before-burn re-checks the actual action's params_hash against the grant before consuming it — a mismatched action is refused without spending the grant;
  • the action then executes once through the same execution core a normally-gated action uses, and the paused run resumes. An approval message flows back down to the asker.

Fail closed

If you never answer, an always-on watchdog expires the pending escalation at the TTL and delivers a rejection — the action fails closed. A deny, a timeout, or a DB fault all resolve to "did not happen," never to "happened without approval."

One-shot elevation, not a standing grant

A key property for the per-target model: approving a capability-denied action mints a one-shot elevation on that exact (principal, verb, box, params_hash). The action runs once on box X and the principal gains no standing per-box grant. You are authorizing a single act, not widening a permission. The one-shot elevation composes independently on top of the standing capability model — it never mutates the principal's standing authority.

What is reused vs. what is new

The escalation engine — the queue, the one-shot grant, the actuator that executes on approve, match-before-burn, single-consume, never-self-approve, TTL-fail-closed, and the audit — already existed and was security-reviewed before this path was wired. The work to make escalation universal was mostly wiring: converting call sites that previously returned a flat 403 into ones that enqueue an escalation, and adding the operator-facing legs (a typed escalation surface, the Facet render, the push notification). Reusing the proven engine — rather than building a second one — is itself a security decision: there is one approval mechanism to audit, not two that might disagree.

Identity dependency

Never-self-approve is only fully effective once each agent has its own identity. In the intended topology this holds naturally — you approve as a human identity through Facet, and the proposing agent is a distinct agent principal, so asker ≠ decider by construction. Under a shared operator key, the backstop still fails closed (it will refuse rather than allow), but it can also block a legitimate decision — which is why per-agent identity is sequenced alongside the escalation work.