> ## Documentation Index
> Fetch the complete documentation index at: https://docs.stackra.figentra.com/llms.txt
> Use this file to discover all available pages before exploring further.

# 0509 — The approval stage: a declared floor of distinct approvers, checked in core, executed as the requester

> Proposed · 2026-10-31

> [!NOTE]
>
> Proposed (STA-1724). Amends [0098](https://docs.stackra.figentra.com/adr/0098-actions-are-the-unit.md) (decision 3: the approval
> step's contract),
> [0397](https://docs.stackra.figentra.com/adr/0397-actions-remainder-effect-codes-tool-default-reserve-then-commit-audit-in-the-unit-of-work-and-approval-fails-closed.md)
> (decision 5: a declared stage also fails closed with the open gate) and
> [0207](https://docs.stackra.figentra.com/adr/0207-authentication-assurance-and-machine-identities.md) (decision 1, for approved calls
> only: the step-up window is measured at the request; decision 9 below), and extends
> [0398](https://docs.stackra.figentra.com/adr/0398-ai-action-permission-matrix-record-scope-pack-floor-propose-mode-and-the-approver-rule.md)
> (decision 6: the approver rule is applied in core). Builds on 0495 (Proposed, `CallIsolation`).
> Spec: `.agents/specs/sta-1724-approval-stage`. Security-tenancy review of 2026-11-01 folded in.

## Context

The action pipeline already has an approval step (0098 decision 3): an `ApprovalGate` driver decides from tenant
rules, parks a request and refuses with `approval_required`, and a resubmission naming a granted id runs. Nothing in
code can say "this action always needs a second person above a threshold", so billing (figentra/billing#2) wrote its
own pending state, approver check and expiry for coupons, and settings has its own four-eyes revision check that
compares author and approver ids only. The step also trusts the driver to compare a granted request with the call.
Under 0495 the step runs inside the call's isolation transaction, so a request parked there is rolled back with the
refusal. The alternatives weighed were a second, separate approval mechanism for declared approvals (two models), a
202-style pending output instead of a refusal (changes every declared action's output type), and moving the store into
a core package (no OS dependency, but a second owner of approvals next to OS tasks-approvals). The security-tenancy
review found that executing from the store's row alone would let a writer of that table run any declared action as
anyone, so the owning service keeps its own binding; a per-service MAC key was the alternative, not taken.

## Decision

1. **Declaration.** `Action({ approval })` takes `true` (as before: a gate must be bound and its rules decide) or
   `{ approvals?, permission, expiresAfter?, when? }`. `approvals` counts distinct people besides the requester
   (default 1, four-eyes); `permission` is a `Permissions` static the approver must hold in the request's scope;
   `expiresAfter` is a duration (default `72h`, at most 30 days; a longer one is refused by gen and by `Action()` at
   boot); `when` names an `ApprovalCondition` class. Without `when` every call needs approval. Gen refuses `approval`
   unless every entry of `actors` is `User`, `Agent` or `ExternalAgent` (an allow-list), and the runtime refuses to
   park a call whose `onBehalfOf` chain does not end at a user.
2. **`ApprovalCondition` is a kind.** `extends ApprovalCondition` with `needed(input, ctx, settings): Promise<boolean>`, in
   `access/<name>.approval-condition.ts`, wired by gen. A threshold is data the condition reads (a setting, a plan
   value); the comparison is code. A condition that throws fails the call. Every setting it reads is listed in its
   static `reads` and must itself be a key marked `approval`. The condition receives a settings reader limited to
   that list as an argument, which throws on any other key; it receives the limited reader as an argument and takes no
   constructor dependencies, and gen refuses any constructor dependency. The port behind the reader, `ApprovalSettingsSource`, lives in `@stackra/contracts` and `@stackra/settings` ships its driver; with none bound, a condition that declares `reads` is refused `approval_settings_unavailable`.
3. **Place.** The stage keeps its place after validation and before idempotency, rate limit, interceptors, the unit of
   work and `handle`. Under 0495 it runs inside `CallIsolation.run`. Actions only. A park takes a token from the
   action's rate-limit bucket before the binding is inserted, and the store caps pending requests per requester per action.
4. **One model, a floor.** The pipeline computes the declared requirement, asks the gate to `decide` (tenant rules,
   the AI resolver's `confirm` and named approver), and enforces the larger of the two itself. A driver may add
   requirements, never remove one; the open gate cannot pass a declared stage.
5. **The port splits, and the owning service keeps a binding.** `ApprovalGate` has `decide` (read), `park` (write a
   request), `find` (read one) and `settle` (record the outcome, a compare-and-set from `executing`). At park, the
   owning service also writes its own **binding** through the `ApprovalBindingStore` port: approval id, tenant, scope,
   the requester's whole chain, key and version, input hash, the requester's assurance, `requestedAt`, `expiresAt`,
   approvals, permission and the approver assurance level. It is insert-only: the pipeline inserts, and only the
   daily `approval_binding.prune` job deletes (once `expiresAt` + 24 hours (the execution tail) + `AUDIT_HOT_RETENTION` of
   `@stackra/contracts`, ADR 0212's thirteen months, has passed); its actor chain and input hash are in the owning service's PII table. It is a
   tenant-owned table with forced RLS in every owning service, shipped by
   `@stackra/tenancy` (model and migration) because actions sits below tenancy. The comparison of a call with its
   binding and the store's row (identity, scope, key, input hash, snapshot hash, status, window by the binding's clock,
   distinct people) is a pure core function; a store row whose actor, tenant, scope, key or input differs from the
   binding is refused `approval_mismatch`.
6. **Park outside the rollback.** The call is refused `approval_required` (with `approvalId`, `approvalsRequired`,
   `expiresAt`, never input) and the pipeline writes the binding, then parks the request, after the isolation
   transaction ended, in a new `CallIsolation.run` for the same actor and scope. A failed park is logged without its
   input.
7. **Execute on grant, as the requester.** For a declared stage the store publishes `actions.approval.granted.v1` (ids
   only) at the final vote. `ApprovalExecutor` in the owning service acts only on envelopes from the store's service,
   lets no inbox actor reach a call context, reads the binding in a `CallIsolation.run` for the event's tenant as the
   system actor in that scope (never a platform read, never `BYPASSRLS`), builds the call only from the binding (taking status, votes and the
   snapshot from the store), checks `ScopeOwnership`, moves the store `granted → executing` by compare-and-set, opens
   a separate, fresh `CallIsolation.run` as the binding's actor, and runs the whole pipeline as the requester with `approvalId` and Idempotency-Key
   `approval:<id>` (a prefix callers may not use). Permission, preconditions, entitlement and flag are re-checked.
   An in-flight answer (`idempotency_key_reused`, `approval_consumed`) never settles `failed`; a request still
   `executing` 24h after its grant becomes `unknown`, shown to operators. An identity outage at execution is retryable
   and never settles `failed`. Reject and withdraw act only on `pending`.
   The approver's authority is never lent (0360 decision 9). A request parked only by tenant rules keeps resubmission.
8. **Approvers, by person.** Approving is a user action only; an agent never approves; approve, reject and withdraw
   are `tool: false` with a reason, and an operator-access session (ADR 0188) cannot vote. A customer-realm user cannot vote, and each vote records
   the voter's realm. The store refuses a vote below the request's approver assurance level. One vote per person; the
   requester's person and anyone in the requester's chain are refused; votes are refused after expiry; at the final
   vote every voter must be active, still hold the permission and pass the refusals again. The id check is one
   function, `actorChainOverlaps(a, b)` in `@stackra/contracts`. Because one human may hold several `usr_` ids in a
   tenant across realms, the refusal also runs a person check, `actorsSharePerson(identity, a, b)` in
   `@stackra/contracts`: two ids are one person when `IdentityResolver.personOf` (a new port method, identity's to
   build) gives the same key, and until it exists when their verified email or verified phone is equal; when identity
   cannot answer, the vote is refused. `approverRefusal` and settings' four-eyes approval (today's and the 0496
   rewrite's) both use these two functions; nothing else decides self-approval.
9. **Step-up at execution.** The requester's step-up is checked when the call is requested, and the execution runs
   with that stored assurance, under five conditions: (a) the assurance comes from the binding, never from the store
   or the event; (b) `StepUpCheck` at execution is evaluated against the binding's `requestedAt` and applies to that one
   call only, never to its nested calls or a resumed agent run; (c) for an action that declares `stepUp`, and every L3
   or L4 action, each approver steps up at vote time whatever the tenant's approver rule says, at the level the draft and the
   binding record; (d) this amends 0207
   decision 1 for approved calls only: the 10-minute window of an L3 or L4 action is measured at the request, and the
   approvers' fresh step-up stands for the rest; (e) the execution is refused `step_up_required` when the requester's
   sessions were revoked or factors reset after `requestedAt`, read from identity.
10. **The floor.** A declared stage and the 0360 floor are one request. AI modes (allow by default, always allow,
    bypass) may drop only the `confirm` of the person an agent acts for; nothing lowers the declared approvals or the
    floor's named approver.
11. **Store.** The pending requests, votes, approve, reject, withdraw, list and get actions and the expiry sweep belong
    to OS tasks-approvals behind the port, in a tenant-owned table with forced RLS. List and get show a request only to
    a holder of its approver permission in its scope or a member of its requester's chain, through field access. The
    snapshot is deleted at a final state and listed in the service's PII table. Lifecycle events carry ids, key and
    counts, never the input or its hash. Core ships the memory drivers and a conformance suite.

## Consequences

* Billing's coupon actions declare the stage and drop their own approval code; any product gets four-eyes by one
  option.
* Every `ApprovalGate` implementer changes (today only `OpenApprovalGateDriver` and test fakes in core).
* Every service that owns a declared action carries one more tenant table, the binding, through `@stackra/tenancy`.
* The input snapshot of a pending call lives in OS until the request ends; approvers see it through field access.
* A single-operator install cannot complete a declared four-eyes action.
* `AgentRuntime.resume` must accept an executed outcome for a request that ran on grant.
* Settings keep their revision model and use the shared `actorChainOverlaps` and `actorsSharePerson`, so the 0496
  rewrite carries the same checks rather than a fork.
* Identity owes a person link (`IdentityResolver.personOf`, STA-1741) and a "sessions revoked or factors reset since
  T" read. The interim person rule (equal verified email or phone) catches the accidental case, not a deliberate
  second account; until STA-1741 the control is provisioning, a staff account only through an audited admin
  invitation, and customer-realm users never vote.

## Enforcement

planned — STA-1724: `Action()` validation of the option; the pipeline tests that a gate answering "none" cannot pass
a declared call, that the binding and the request survive the rollback under `CallIsolation`, and that a tampered
store row is refused; the `verifyApproval`, `actorChainOverlaps` and `actorsSharePerson` tests; the port conformance
suite in `@stackra/actions/testing` (states, compare-and-set, pending cap); the binding driver's real-Postgres
isolation test in `@stackra/tenancy`; gen refusing `approval` on a query, on an action no user or agent may call, on
any actor kind other than `User`, `Agent` or `ExternalAgent`, on a condition constructor dependency, on a `credential` input field, on an `expiresAfter` above 30 days and on a
condition reading a key not marked `approval`. Nothing checks this rule yet.

## Decided questions

The leader decided L-01 to L-10 of the spec on 2026-11-01 as recommended, with three refinements: a 30-day maximum
expiry (decision 1), one shared chain check with settings (decision 8), and step-up at execution (decision 9). The
security-tenancy review of the same day (BLOCK on the first design) was folded in with the leader's decisions: the
per-service binding table and no MAC key (decision 5), the step-up conditions and the 0207 amendment (decision 9), the
person check across realms (decision 8). The re-review (PASS WITH CHANGES) added the two-run executor (decision 7),
the limited condition reader (decision 2), the actor allow-list (decision 1), the realm rule (decision 8), the
approver level on draft and binding (decision 9) and the insert-only binding (decision 5). The owner accepts this ADR.

## Supersedes and sources

`Supersedes: none`. Amends 0098 (decision 3), 0397 (decision 5) and 0207 (decision 1, for approved calls only:
decision 9 above); extends 0398 (decision 6). The amendment note on 0207 is added when this ADR is accepted. Sources: Linear
STA-1724; figentra/billing#2; the code of `@stackra/actions`, `@stackra/ai` and `@stackra/settings` on main,
2026-10-31; ADR 0495 on `figentra/sta-1699-call-isolation`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.