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): anApprovalGate 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
- Declaration.
Action({ approval })takestrue(as before: a gate must be bound and its rules decide) or{ approvals?, permission, expiresAfter?, when? }.approvalscounts distinct people besides the requester (default 1, four-eyes);permissionis aPermissionsstatic the approver must hold in the request’s scope;expiresAfteris a duration (default72h, at most 30 days; a longer one is refused by gen and byAction()at boot);whennames anApprovalConditionclass. Withoutwhenevery call needs approval. Gen refusesapprovalunless every entry ofactorsisUser,AgentorExternalAgent(an allow-list), and the runtime refuses to park a call whoseonBehalfOfchain does not end at a user. ApprovalConditionis a kind.extends ApprovalConditionwithneeded(input, ctx, settings): Promise<boolean>, inaccess/<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 staticreadsand must itself be a key markedapproval. 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/contractsand@stackra/settingsships its driver; with none bound, a condition that declaresreadsis refusedapproval_settings_unavailable.- Place. The stage keeps its place after validation and before idempotency, rate limit, interceptors, the unit of
work and
handle. Under 0495 it runs insideCallIsolation.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. - One model, a floor. The pipeline computes the declared requirement, asks the gate to
decide(tenant rules, the AI resolver’sconfirmand 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. - The port splits, and the owning service keeps a binding.
ApprovalGatehasdecide(read),park(write a request),find(read one) andsettle(record the outcome, a compare-and-set fromexecuting). At park, the owning service also writes its own binding through theApprovalBindingStoreport: 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 dailyapproval_binding.prunejob deletes (onceexpiresAt+ 24 hours (the execution tail) +AUDIT_HOT_RETENTIONof@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 refusedapproval_mismatch. - Park outside the rollback. The call is refused
approval_required(withapprovalId,approvalsRequired,expiresAt, never input) and the pipeline writes the binding, then parks the request, after the isolation transaction ended, in a newCallIsolation.runfor the same actor and scope. A failed park is logged without its input. - Execute on grant, as the requester. For a declared stage the store publishes
actions.approval.granted.v1(ids only) at the final vote.ApprovalExecutorin the owning service acts only on envelopes from the store’s service, lets no inbox actor reach a call context, reads the binding in aCallIsolation.runfor the event’s tenant as the system actor in that scope (never a platform read, neverBYPASSRLS), builds the call only from the binding (taking status, votes and the snapshot from the store), checksScopeOwnership, moves the storegranted → executingby compare-and-set, opens a separate, freshCallIsolation.runas the binding’s actor, and runs the whole pipeline as the requester withapprovalIdand Idempotency-Keyapproval:<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 settlesfailed; a request stillexecuting24h after its grant becomesunknown, shown to operators. An identity outage at execution is retryable and never settlesfailed. Reject and withdraw act only onpending. The approver’s authority is never lent (0360 decision 9). A request parked only by tenant rules keeps resubmission. - Approvers, by person. Approving is a user action only; an agent never approves; approve, reject and withdraw
are
tool: falsewith 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 severalusr_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 whenIdentityResolver.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.approverRefusaland settings’ four-eyes approval (today’s and the 0496 rewrite’s) both use these two functions; nothing else decides self-approval. - 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)
StepUpCheckat execution is evaluated against the binding’srequestedAtand applies to that one call only, never to its nested calls or a resumed agent run; (c) for an action that declaresstepUp, 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 refusedstep_up_requiredwhen the requester’s sessions were revoked or factors reset afterrequestedAt, read from identity. - The floor. A declared stage and the 0360 floor are one request. AI modes (allow by default, always allow,
bypass) may drop only the
confirmof the person an agent acts for; nothing lowers the declared approvals or the floor’s named approver. - 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
ApprovalGateimplementer changes (today onlyOpenApprovalGateDriverand 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.resumemust accept an executed outcome for a request that ran on grant.- Settings keep their revision model and use the shared
actorChainOverlapsandactorsSharePerson, 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.