Waysafe

Policy Schema Reference

The engine enforces the IMPLEMENTED fields below today, each cited against the exact function and reason code that enforces it.

Fields marked SPECIFIED are defined in the schema but not yet enforced; RESERVED names exist in code (an enum member, a reason code) only to prevent a future collision, with no behavior behind them. Every status was verified directly against packages/core — not inferred from the PRD, not assumed from the field existing in a type. Unimplemented and reserved fields are described here in the future tense — never as something a mandate can rely on today.

Reason codes are additive only and are never renamed. Every code named below, including the reserved one, is a permanent commitment once it ships. Amounts are integer minor units throughout, the same as everywhere else in this SDK.

Amount

FieldTypeStatusReason codeSemantics
per_transaction_maxinteger minor units, optionalIMPLEMENTEDDENY_TRANSACTION_LIMIT_EXCEEDEDSingle-transaction ceiling. See Invariant 1 for what omitting it means.
cumulative_limits[].max_amount (window: day | week | month)integer minor unitsIMPLEMENTEDDENY_CUMULATIVE_LIMIT_EXCEEDEDCumulative spend ceiling within one fixed calendar window. evaluateLimits().
cumulative_limits[].max_amount (window: mandate)integer minor unitsIMPLEMENTEDDENY_CUMULATIVE_LIMIT_EXCEEDEDLifetime ceiling — "mandate" is a fourth window value: the entire life of the mandate, not a period that resets.
per-merchant amount cap—not in schema—cumulative_limits apply mandate-wide only; nothing scopes a limit to one merchant.
per-category amount cap—not in schema—categories.allow/deny gate identity, never amount. No amount field exists under categories.

Period

Worth stating plainly because it’s easy to conflate: every window Waysafe actually computes is a fixed calendar window — a calendar day, a Monday-start calendar week, a calendar month, each in the policy’s own timezone. None of them is a rolling window (the trailing N days from now, sliding forward every second). “No more than $500 in any rolling 30 days” and “no more than $500 per calendar month” are different guarantees — the first resets continuously, the second resets on the 1st regardless of when in the prior month spending happened. Only the second is expressible today.

FieldTypeStatusReason codeSemantics
cumulative_limits[].windowenum: day | week | month | mandateIMPLEMENTED(scopes DENY_CUMULATIVE_LIMIT_EXCEEDED / DENY_VELOCITY_LIMIT_EXCEEDED)A fixed calendar window in accounting.timezone — day boundary, Monday-start week, calendar month. Not a rolling window.
rolling N-day window—not in schema—No such concept anywhere in the schema or SpendSnapshot, which computes exactly day/week/month/mandate and nothing else. "Trailing 30 days" cannot be expressed — approximating it with "month" is not the same guarantee and the compiler should not be trusted to conflate them.

Counterparty

FieldTypeStatusReason codeSemantics
merchants.allow[] (scheme: domain)array of { scheme, value, label? }IMPLEMENTEDSTEP_UP_MERCHANT_UNVERIFIED / DENY_MERCHANT_NOT_ALLOWLISTED / STEP_UP_MERCHANT_NOT_ALLOWLISTEDDomain allowlist. Satisfies ALLOW only at VERIFIED trust (directory-corroborated) — a domain the agent merely asserted caps at STEP_UP regardless.
merchants.allow[]/deny[] (scheme: psp_account)arrayIMPLEMENTEDsame as domain, plus DENY_MERCHANT_BLOCKED for deny[]VERIFIED only when a payment rail's own callback supplied the value; the same value asserted by the agent on the request caps at ASSERTED, same ceiling as a bare name.
merchants.allow[]/deny[] (scheme: network_mid)arrayIMPLEMENTEDsame as psp_accountCard-network merchant ID. Same rule as psp_account: the rail supplying it verifies, the agent asserting it does not.
merchants.allow[]/deny[] (scheme: onchain_address)arrayIMPLEMENTEDsame as psp_accountStablecoin payout address. Same rule again: supplied by the rail it verifies, asserted by the agent it caps at ASSERTED.
merchants.allow[] (scheme: name)arrayIMPLEMENTEDSTEP_UP_MERCHANT_UNVERIFIEDCan never itself produce ALLOW: a name can never be VERIFIED (no corroboration source exists for free text), so a name-only allow entry always caps the outcome at STEP_UP.
categories.deny_mcc[]array of 4-digit stringsIMPLEMENTEDDENY_CATEGORY_BLOCKEDMCC is categorical, not identity (merchant.ts) — denylist only. There is no MCC allowlist; an MCC can never itself satisfy an allowlist entry.
country—not in schema—No country scheme, no country field, anywhere in MerchantScheme or MerchantAssertion.

Time

FieldTypeStatusReason codeSemantics
expires_atISO-8601 timestamp, requiredIMPLEMENTEDDENY_MANDATE_EXPIREDAbsolute expiry instant, not a duration. A natural-language "90 days" is resolved to an absolute timestamp at compile time — the field itself never stores a TTL. Required: no mandate is open-ended.
time_window.start_time / end_time"HH:MM", optionalIMPLEMENTEDDENY_OUTSIDE_TIME_WINDOWTime-of-day window in accounting.timezone. start > end wraps past midnight (e.g. 22:00–06:00).
time_window.days_of_weekarray of 0–6 (0 = Sunday), optionalIMPLEMENTEDDENY_OUTSIDE_TIME_WINDOWRestricts which days of the week the mandate permits anything at all.

Velocity

FieldTypeStatusReason codeSemantics
cumulative_limits[].max_countpositive integer, optionalIMPLEMENTEDDENY_VELOCITY_LIMIT_EXCEEDEDMax transaction count within the same fixed window (day/week/month/mandate) the amount limit uses.
minimum interval between transactions—not in schema—No field expresses a minimum gap between two transactions.
burst ceiling (rolling window)—not in schema—Blocked by the same gap as Period's rolling window — max_count only ever scopes to a fixed calendar window, never a rolling one.

Instrument

FieldTypeStatusReason codeSemantics
currencyenum, single value, requiredIMPLEMENTEDDENY_CURRENCY_NOT_PERMITTEDExactly one currency per mandate, not a permitted set. SUPPORTED_CURRENCIES (money.ts) is USD-only today, so this can currently only ever reject a non-USD request — the enum itself has no second member to permit.
permitted rails—not in schema—Not a policy concept. Which rail executes is chosen by the caller of execute() and by which adapters are registered on the deployment — outside the policy entirely.
specific cards or wallets—not in schema—Instrument (rail, external_ref) is a separate domain entity, never referenced by Policy.

Evidence

The minimum merchant trust required for an ALLOW is a rule the engine enforces on every decision. It is not, today, a field a mandate can set.

FieldTypeStatusReason codeSemantics
(no field — hardcoded)n/aIMPLEMENTEDSTEP_UP_MERCHANT_UNVERIFIED (cap) / ALLOW_WITHIN_MANDATE (when met)Not configurable. VERIFIED trust is required for an ALLOW through an identity scheme, on every mandate, with no exceptions. There is no policy field that raises or lowers this bar.

Escalation

FieldTypeStatusReason codeSemantics
step_up.above_amountinteger minor units, optionalIMPLEMENTEDSTEP_UP_AMOUNT_THRESHOLDSingle-transaction step-up threshold.
step_up.above_cumulative{ window, amount }, optionalIMPLEMENTEDSTEP_UP_CUMULATIVE_THRESHOLDCumulative step-up threshold, same fixed-window semantics as Period.
step_up.ttl_secondspositive integer, default 900IMPLEMENTED(governs the expiry-to-DENY path)How long a pending step-up stays open before it expires. See Invariant on expiry below.
merchants.unlisted = STEP_UPenum valueIMPLEMENTEDSTEP_UP_MERCHANT_NOT_ALLOWLISTEDEscalates instead of denying an unlisted merchant.
categories.unlisted = STEP_UPenum valueIMPLEMENTEDSTEP_UP_CATEGORY_NOT_ALLOWLISTEDEscalates instead of denying an unlisted category.
escalation.approversstring[] (mandate ids), default []IMPLEMENTEDDENY_STEP_UP_SELF_APPROVAL / DENY_MANDATE_NOT_AN_APPROVERWho may resolve a step-up on this mandate that needs higher authority. Absent or empty means unresolvable, and it expires to DENY. See Approver Mandates below, which describes the gap this closed.
an approver mandate escalating further—IMPLEMENTEDDENY_APPROVER_WOULD_ESCALATESingle-level by design, enforced as a refusal: an approver's own STEP_UP declines the resolution rather than chaining to a further approver.
escalation.approvers forming a cycle—IMPLEMENTEDDENY_APPROVER_CYCLEEnforced at mandate-creation time, not resolve time: a mandate naming itself, or two mandates naming each other, is rejected. Cycles of three or more are not caught here; see Approver Mandates below for the bounded-risk reasoning.
mandate re-authentication requirementreason code exists; no policy fieldRESERVEDSTEP_UP_MANDATE_REQUIRES_REAUTHA real, permanent name in reason-codes.ts. Never emitted anywhere in evaluate() or the authorization service today — checked directly, not inferred.

Invariants

The security properties a reviewer checks — not a summary of the tables above, a separate set of claims about how any policy, present or future, must compose.

1. Absence fails closed. An unset field never widens scope. Omitting per_transaction_max does not mean unlimited spend is intended — every mandate still has expires_at, a currency, and an unlisted disposition on both merchants and categories, each independently capable of stopping an action. A missing ceiling is a ceiling nobody set, not a ceiling of infinity.

2. Composition is most-restrictive-wins. Every dimension in evaluate() can only ever add a reason at its own tier or above; none can downgrade a decision another dimension already forced upward. Precedence is DENY > STEP_UP > ALLOW. Adding a rule to a policy can never widen what it already permitted. And a decision carries every reason code that applies at its winning tier, not just the first one evaluated — a request that’s both over the per-transaction cap and from an unlisted merchant returns both DENY_TRANSACTION_LIMIT_EXCEEDED and DENY_MERCHANT_NOT_ALLOWLISTED, so a receipt never hides a second real reason behind whichever check happened to run first.

3. Policy change requires mandate-creation authority. An approval authorizes one action. It can never mutate a policy — not the mandate it was decided under, not any other. Widening a limit, adding a merchant, or extending an expiry is a new mandate version, created with the same authority (and, today, the same WebAuthn ceremony) that created the first one — never a side effect of approving a transaction.

Approver Mandates

Shipped and tested. Resolving a step-up that needs higher authority requires a different, named approver mandate’s own credential. It runs the same engine, writes a real ledger entry, and returns real reason codes. This closes the gap described at the end of this section. Two things stay specified and unbuilt: delegation more than one level deep, and the needs-evidence class, (a) below. Each is called out where it applies.

A step-up is not a pause waiting for a human. It is a second authorization, evaluated by the same evaluate() engine against a different mandate — the approver’s — producing the same decision shape, the same evidence entries, and the same reason codes as any other authorization.

The principal signs the approver’s authority once, at enrollment, using the same passkey ceremony that activates any mandate. escalation.approvers is set at mandate creation, alongside every other policy field, by the same signature. (No separate “update this mandate” ceremony exists yet — changing an existing mandate’s approver set means creating a new mandate, same as changing any other field today.) After that, approvals run at machine speed with no human present. The human is in the authority path, not the transaction path.

Because that one signature is what the whole model rests on, enrolling a second passkey for a principal that already has one requires a fresh prior authentication with an existing credential. That produces a single-use, expiring grant bound to that specific principal and credential. An adversarial review found that an authentication challenge could previously be answered with a registrationresponse, which enrolled an attacker’s own passkey against a victim principal. A later review found a second route to the same place: the signature a principal gives to confirm a policy could be redeemed as a passkey enrolment grant. Every challenge now records what it was issued for, and each ceremony accepts only its own purpose. A mismatch returns 400 challenge_purpose_mismatch.

An approver is any actor holding an approver mandate: a treasury service, a manager, a controller system, or the principal themselves. The human-in-the-loop, async-approval case most people picture first is the degenerate form of this model, not a separate mechanism from it — a human approving on their phone and a treasury service approving programmatically both resolve a step-up the identical way, through the identical engine.

An approver mandate is bounded by the same policy schema documented above — amount ceilings, merchant scope, time windows, velocity. An approver cannot approve outside its own mandate; it is a mandate, evaluated the same way any other is. And it is not free to approve: an approval writes a real, permanent ledger entry against the approver’s own mandate (never released), so its cumulative and velocity limits actually accumulate from the approvals it grants, not only from its own authorize() calls. A per-period cumulative cap on an approver mandate is a real ceiling on how much it can approve in that period, not just on how much it can spend directly.

An approval costs budget on both mandates, for two different reasons. The approver’s entry above is the ceiling on how much it may approve. The spending mandate also takes a hold at approval time, so the approved amount counts against the cap the principal actually signed. An adversarial review found that it did not: with reserve_on_step_up: false the spending mandate’s ledger was untouched by an approval, so an unlimited number of payments could be approved against its cap. A second review found a further case: several approvals that take no hold were each invisible to the others, so three $90 requests against a $100 limit could all be approved. An approval now re-checks the spending mandate’s own limit at the moment it is granted. The approver’s charge is a separate check. It is never a substitute for the spender’s.

Approval re-validates under the lock. Three things can lapse between a step-up being raised and an approver resolving it: the step-up’s own TTL, the spending mandate being revoked, and that mandate’s policy expires_at. All three are re-read inside the same row lock that takes the money, and any of them refuses the approval with its own code — DENY_STEP_UP_EXPIRED, DENY_MANDATE_REVOKED, DENY_MANDATE_EXPIRED. Before that review, all three were approved and executable; a revoked mandate’s pending step-up still went through.

Authority is single-level: an approver may authorize transactions but may not mint another approver, and cannot escalate a step-up further. If the approver’s own evaluate() also returns STEP_UP for the action, the resolution declines with DENY_APPROVER_WOULD_ESCALATE — it does not chain to a second approver. A delegation-depth field is not currently reserved anywhere in packages/core, checked directly rather than assumed, so this stays documented as a gap rather than a reservation that doesn’t exist. The intent stands regardless of the naming: a delegation-depth field belongs in a future schema from the start, so chains can be added later without a migration.

Cycles. Rules 1 and 2 alone don’t stop mutual approval — mandate A naming B as approver and B naming A back would let each resolve the other’s step-ups with neither rule firing. A mandate naming itself (the degenerate 1-cycle) or two mandates naming each other (an exact mutual pair) is rejected at mandate creation, not at resolve time — DENY_APPROVER_CYCLE — checked by looking at whether the candidate approver’s own current policy already names the mandate being created back. Cycles of three or more (A names B, B names C, C names A) are not caught here — that would need a full graph walk across every mandate in an organization on every creation, which this build doesn’t do. That gap is accepted, not overlooked, because it’s bounded by the ledger rule just above: evaluate() in a longer cycle still runs against each approver’s own policy, and each approval still costs real, permanent budget on that approver’s own mandate — a cycle can’t be used to escalate authority without limit, only up to whatever the weakest link’s own signed cumulative cap allows. The threat model names this as a bounded risk rather than leaving it undiscussed.

Approval authorizes one action. It never mutates policy on either mandate. No raised cap, no extended window, no new approver. Approving a $9,000 purchase does not raise the mandate’s per-transaction ceiling for the next one. (It does write the real ledger entry described above; that’s ledger, not policy.)

Single-use and idempotent. A step-up resolves at most once. A second resolve attempt on an already-resolved (or already-expired) step-up — from the same approver, a different approver, or anyone else — replays the recorded outcome and its reason codes; it never re-runs evaluate(). Two approvers resolving the same step-up simultaneously, or an approval racing the TTL sweep, settle atomically: first writer wins, every other caller gets the recorded outcome back, never an error and never a second decision.

Step-up classes resolve differently, and the distinction matters:

(a) Needs-evidence (e.g. STEP_UP_MERCHANT_UNVERIFIED) would resolve the moment a better-attested source supplies the identifier — a rail’s own callback corroborating a domain, for instance — with no approver, no human, at machine speed. This class of automatic resolution is SPECIFIED, not built: no code today re-evaluates a pending step-up when new evidence arrives, only when a real approver mandate explicitly resolves it. What is real today is that the same underlying trust rule already runs on every fresh evaluation — a rail-attested merchant on a new request resolves to VERIFIED the same way it always does; nothing here re-checks a specific pending authorization automatically.

(b) Needs-higher-authority resolves via an approver mandate — real, shipped, described above and in the sequence below.

(c) Unresolvable is a DENY at evaluation time, not a step-up that sits waiting to expire. A hard ceiling denies outright rather than escalating something that was never going to be approvable.

Expiry and fail direction: an unresolved step-up expires to DENY. step_up.ttl_seconds and the sweep that applies it are implemented today, see Escalation above, and hold the same way whether the step-up is ever offered to an approver or not. An expiry releases any hold it was carrying. It does not yet write its own entry to the signed record, which is a known gap named in the threat model.

The full sequence:

authorize()  ->  STEP_UP, status PENDING_STEP_UP
resolveStepUp(authorizationId, approver)
  -> approver's evaluate() ALLOW  ->  status STEP_UP_APPROVED
  -> approver's evaluate() DENY/STEP_UP  ->  status STEP_UP_DECLINED
asExecutable(decision)  ->  non-null exactly when STEP_UP_APPROVED (same as a fresh ALLOW)
execute(executable, params)  ->  status EXECUTED

STEP_UP_APPROVED is executable the identical way AUTHORIZED is. asExecutable() has treated the two the same way since before approver mandates existed. Approver mandates changed only what can produce STEP_UP_APPROVED in the first place.

A closed gap, stated plainly: an agent credential could once resolve its own step-up. The same agent key that produced a STEP_UP decision could call the resolution endpoint on it immediately, with no human, no second credential, and no code path that refused it. For anyone holding that one key, STEP_UPcollapsed to the same outcome as ALLOW. It is closed by requiring the resolving credential to belong to a different, named approver mandate. It is rejected with DENY_STEP_UP_SELF_APPROVAL if it’s the same mandate, checked first, ahead of every other rule. An org-level credential — which has no agent identity at all — was never a valid resolver either, and still isn’t: it can never match a real approver mandate’s own agent key. Proven, not just described: the adversarial suite (apps/api/src/authorization/service.test.ts, server.test.ts) covers self-approval, a mandate not on the approvers list, an approver whose own policy caps below the amount, an approver at its own cumulative ceiling, TTL expiry racing resolution, the 1- and 2-cycle rejections at creation, a real runtime 3-cycle bounded by the ledger rule, and both concurrency races — plus a real, non-simulated Postgres regression test for a nested-transaction hazard the first live run against a real database surfaced.