Skip to main content
Proposed (STA-1724). Amends 0098 (decision 3: the approval step’s contract), 0397 (decision 5: a declared stage also fails closed with the open gate) and 0207 (decision 1, for approved calls only: the step-up window is measured at the request; decision 9 below), and extends 0398 (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.