Skip to content
AXOWLDevelopers
Quickstart

Capability grants & the variable formula

A permission written as a bare string says nothing about how much of an action it allows. “May approve expenses” and “may approve expenses up to 10,000 in KR” are different capabilities, and a grant has to be able to tell them apart — and to prove that nobody handed out more than they hold.

Axowl treats a grantable action as a variable formula: a scope that names a real resource and action, followed by a bag of (key, operator, value) constraints. The same formula is what an approval form requests, what an approver is checked against, and what ends up stored as the requester’s permission.

A scope formula is:

resource.path.action[:key{op}value,key{op}value,...]

There are six operators, and the operator encodes the variable’s kind — there is no separate “type” field:

OperatorMeaningVariable kind
=allow, exact match (or one of a set: region={KR,JP})categorical / entity reference
!=deny (block when it matches)negative filter
<= < >= >numeric thresholdordered (number)
* (or absent)wildcardunbounded

Two-character operators are matched before one-character ones, so <= is never read as < followed by =. The value is always a string (amount=10000 stores "10000"); the operator tells the comparator how to read it.

The same grammar is used for event predicates. A three-segment formula (namespace.resource.action) compiles to a filter over the audit event stream — the scope part matches the event type and each constraint is evaluated against the event’s business data:

sap.invoice.approve:amount>10000
→ events of type sap.invoice.approve whose amount is greater than 10000
okta.app.assigned:risk>=3,region=KR
→ events of type okta.app.assigned with risk ≥ 3 and region = KR

A capability is the scope plus its constraint bag:

PartExampleNotes
resource path + actionorg.app_group.deletenames a resource in the platform’s data model and an action valid for it
constraintcreator=selfrow-level limit; self resolves to the caller
constraintamount<=10000numeric bound
lifecycle controluses=1, ttl=3600see Lifecycle control variables

Platform scopes are derived from the platform’s data model — resources, how they contain each other, and the actions that are valid for each kind of resource — so a platform scope always names something real. The Action Library lists them together with the organization’s custom permissions.

Scopes come in two namespaces that never cover each other: page scopes (view.…) and resource scopes (everything else). Holding a page scope never implies a resource scope, and the reverse.

Wildcards are written on the resource path:

FormCovers
org.app_group.*org.app_group and everything under it, every action
org.app_group.*.writethe same as .* (write includes read)
org.app_group.*.readonly read and list actions under that path
view.*every page scope

An approval form is linked to one or more scopes. When someone submits the form, the top-level fields of the request body that hold a string, a number or a boolean are appended to each scope as key=value constraints (the human-readable body text and the op payload are not constraints). In a value, the characters ,, :, { and } are replaced with _ before it is appended, so one field always makes exactly one constraint and a value changed this way matches no target — except exp, where an ISO 8601 timestamp with a time zone is carried as Unix seconds. A key that the form’s scope already fixes is not overridden by the request. Body fields are always added with =; a bound (amount<=30000) or a deny (region!=KR) on the request side comes from the form’s own scope.

Form scope: org.app_group.delete
Request body: { "group": "A1", "ttl": 3600 }
Resolved scope: org.app_group.delete:group=A1,ttl=3600

That resolved scope — narrowed to exactly what was requested — is what the approval line is derived from, what each approver is checked against, and what the requester receives.

Permanence and expiry are not a separate mode flag — they are reserved keys in the same bag. Three keys govern a grant’s lifecycle; all three take the = operator only and may appear at most once.

KeyMeaningFormatWhen omittedExamples
usesuse countpositive integer or *unlimiteduses=1 (one-time), uses=5, uses=*
ttlrelative lifetimepositive integer, seconds from the moment of approvalno expiryttl=3600 (one hour), ttl=7776000 (90 days)
expabsolute expiryUnix time in seconds, or an ISO 8601 timestamp with Z or an explicit offsetno expiryexp=1798675200, exp=2026-12-31T00:00:00Z
  • The default is permanent. A grant with no control variables lasts until it is revoked.
  • Invalid values are refused, never treated as unlimited. uses of 0, a negative number or a non-integer; a ttl that is not a positive whole number of seconds; an exp without a time zone; a uses, ttl or exp in the request body that could not be carried into the resolved scope; and any expiry that is already in the past at the moment of approval all cause the approval stamp to be refused.
  • When both ttl and exp are present, the earlier expiry wins.
  • A form that grants several scopes has one use budget: every scope in the bundle must carry the same uses value, or the approval is refused.

ttl / exp — the permission stops matching. The resolved expiry is stored on the granted permission. Every permission check and every permit request compares it with the current time, so an expired grant no longer authorizes anything from that moment on.

A finite uses — no standing permission. An approval with a finite use count — uses=1, uses=5, any number — does not add a permission to the requester. The sealed, approved request itself is the authorization, and it is good for that many uses and no more. A finite count is accepted in two cases:

  • The request carries an operation to run (op): that operation is executed once when the line completes, and nothing else is granted.
  • The scope is a Roost management operation: the approval is recorded as a use budget of that many uses. The budget is the only authority the approval gives: ordinary permission checks do not see it, and only the Roost management API draws on it.

On any other scope a finite uses has nothing to count it, so it is refused: submitting the request returns 400 with an error that starts with USES_NOT_SUPPORTED, and an approver cannot stamp such a request either. Ask for a time limit (ttl / exp) instead.

Only uses=*, or no uses at all, produces a standing permission.

Finite counts on use-counted APIs. The use budget is consumed by the Roost management API, and only there. A call that relies on a finite-use approval must send a unique UUID in the X-Approval-Invocation header:

SituationResponse
header missing428 APPROVAL_INVOCATION_REQUIRED
the same invocation ID was already recorded409 APPROVAL_INVOCATION_ALREADY_RECORDED
no matching approval with uses left (or it expired)403 ROOST_PERMISSION_REQUIRED
a use was reservedthe call proceeds; the response carries X-Approval-Receipt

A use is reserved atomically before the operation runs and is not refunded — a call that fails, or whose outcome is unknown, still consumes its use.

Lifecycle controls are compared separately from the other constraints:

  • An approver whose own permission is temporary can only approve a request that expires no later than their own permission does. They cannot turn a temporary entitlement into a longer or permanent one.
  • A permission that is itself limited to a finite number of uses does not make its holder an approver.

The core safety rule — you cannot grant more than you hold — is plain, deterministic comparison of two formulas. No LLM is involved:

An approver may approve a request only if one of the permissions they hold subsumes the requested scope.

Path. A wildcard permission covers its prefix and everything beneath it (subject to the read / write tier above). A concrete permission covers only the identical path. Page scopes and resource scopes never cover each other.

Constraints. Each constraint on the approver’s permission must be respected by the request:

Approver holdsThe request must
no constraint on a key (or *)— anything is allowed
key=value or key={a,b}carry the same key with =, and every requested value must be in the approver’s set
key<=n, <, >=, >carry the key, either as key=value with a number inside the approver’s bound, or as a bound in the same direction that is no wider (amount<=30000 under amount<=50000). A bound in the opposite direction, or a !=, leaves the range open and is refused
key!=valuecarry the same key, either as key=… with none of the denied values, or as key!=… that names every value the approver is denied

A request that leaves out a key the approver’s permission constrains — with =, a numeric operator or != — is refused: an unbounded request is wider than a bounded holding, and a deny that is not carried over would be lost.

Approver holds: expense.approve:amount<=50000
Request: expense.approve:amount=10000 → inside the bound → allowed
Request: expense.approve:amount=50000 → on the bound → allowed (inclusive)
Request: expense.approve:amount<=30000 → a narrower bound → allowed
Request: expense.approve:amount=80000 → over the bound → refused
Request: expense.approve:amount>=10000 → open upward → refused
Request: expense.approve → no amount at all → refused
Approver holds: shipment.approve:region!=KR
Request: shipment.approve:region=JP → not a denied value → allowed
Request: shipment.approve:region!=KR → the deny is carried → allowed
Request: shipment.approve:region=KR → a denied value → refused
Request: shipment.approve → the deny is dropped → refused

Owner-only scopes. The billing scopes (view.billing, org.org.billing_manage) and the Roost scopes (view.roost, org.roost_…, org.setting.roost_…) cannot be approved on the strength of a broad wildcard. A request for one of them — or for a wildcard that would include one, such as org.*, view.* or org.org.* — can be approved only by the Owner, or by an approver whose own permission names that Owner-only scope explicitly. An Admin’s org.* is not enough.

The ceiling is checked twice: when the approval line is derived, and again at the moment each approver stamps. An approver whose permission was reduced or expired after the request was submitted cannot approve it.

Who approves is decided by two rules together:

  • Capability (the ceiling) — the approver must hold a permission that subsumes the request.
  • Risk level (the seniority floor) — the form carries a risk level from 0 to 4, and the approver’s tier must meet it.

Unless the form defines its own approval line, the line walks up the requester’s reporting tree (up to five levels, skipping inactive or suspended members) and takes the nearest ancestors who satisfy both rules:

Line modeApprovers
singlethe nearest qualifying ancestor
quorumthe nearest K qualifying ancestors (all of them when K is not set)
chain / parallelevery qualifying ancestor

Seniority tiers:

MemberTier
Owner4
Board member3
Admin, and any other ancestor in the reporting tree2

If no ancestor is both capable and senior enough, the outcome depends on what the form grants. A form that grants scopes creates no line and the request stays in submitted — it is never routed to someone who could only reject it. A form that grants nothing (a plain document approval) goes to the highest ancestor in the reporting line who meets the risk floor, as the sole final approver; if nobody above the requester meets the floor, no line is created and the request stays in submitted.

Risk level 0–1 needs no approver — but only the organization’s Owner, its legal representative, or the account owner can complete their own request this way, and the grant passes the same checks as a stamped approval: valid lifecycle controls, no separation-of-duties conflict, and an Owner-only scope only when the requester is the Owner. A request that carries an op is never completed this way. In every other case the request stays in submitted. A request raised by a non-human identity (an agent or service account) is always treated as risk level 2 or higher, so it can never be approved without a human stamp.

When the last required approver stamps, the resolved scopes are granted to the requester, each tagged with the approval that produced it:

RequestResult
no uses, or uses=*a standing permission for each resolved scope, with the expiry from ttl / exp if present
a finite uses (uses=1, uses=5, …)no standing permission — the request’s op runs once, or, for a Roost management operation, a use budget of that many uses is recorded (see What each control does)

Because the request’s own values are part of the resolved scope, the requester receives exactly what was approved (for example …delete:group=A1), not the form’s wider scope. The completion is recorded as a reporting.approval.completed event. Removing a scope from a form, or archiving the form, also switches off the permissions that were granted through it and cancels any use budget that is left.

The deterministic ceiling handles every grant. The LLM is a targeted overlay for one situation: an administrator composing free-form scope bundles for an integration.

PathInputLLM?
Constrained — approval formsa member requests a form whose scopes are already definedNo — the deterministic check is faster, cheaper, fully auditable
Free-form — integration permissionsan Owner or Admin composes arbitrary formula bundles for a groupYes — translation and combinatorial conflict detection

All three endpoints below require an Owner or Admin of the organization.

POST /api/org/{slug}/integrations/{moduleKey}/ai/translate-scope turns “approve invoices up to 10,000 in KR” into sap.invoice.approve:amount<=10000,region=KR. The model is given the grammar, per-module resource hints, and phrasing rules that map comparison words to operators (“at most” → <=, “under” → <, “at least” → >=). It also returns a recommended risk level.

POST /api/org/{slug}/integrations/{moduleKey}/permissions/analyze runs before anything is written. It inspects the union of the group’s existing scopes and the new ones for problems a per-scope ceiling cannot see:

  • threshold_inversion — can approve a 2,500 PO they cannot modify
  • impossible_range / range_overlap — amount>=3000 together with amount<2000
  • sod_violation — the same group can create and approve the same resource
  • all_groups_with_filter, broad_destructive

Each finding carries a severity, a title, a detail and the scopes involved. When fewer than two scopes are in play (new plus existing), nothing can conflict and the endpoint returns an empty list without calling the model. If the model call fails, the endpoint returns 502.

POST /api/org/{slug}/integrations/{moduleKey}/permissions/grant attaches the scopes to the group’s role. It requires a passkey assertion that is bound to this module and group — a seal made for one group cannot be replayed for another.

#CaseOutcome
1approver holds org.app_group.*, request org.app_group.delete:group=A1allowed — wildcard covers the path; requester receives org.app_group.delete:group=A1
2numeric boundary: holds amount<=50000, request amount=50000allowed (inclusive)
3numeric over: holds amount<=50000, request amount=80000refused
4set membership: holds region={KR,JP}, request region=USrefused
5read-only holder: holds org.app_group.*.read, request org.app_group.deleterefused — a read wildcard never covers a write action
6namespace: holds view.*, request any resource scoperefused — page scopes never cover resource scopes
7finite use count: request carries uses=1 (or uses=3)with an op, or on a Roost management operation: approved without a standing permission — the op runs once, or a budget of that many uses is recorded. On any other scope: refused at submission (USES_NOT_SUPPORTED)
8temporary approver: permission expires in 7 days, request has no ttl / exprefused — the grant would outlive the approver’s own permission
9nearest capable manager is below the risk floorthe line continues up to the first ancestor who clears both
10request raised by an agent on a risk-level-0 formneeds a human approver’s stamp; never self-approved
11open-ended bound: holds amount<=50000, request amount>=10000refused — the request’s operator leaves the range open upward
12dropped deny: holds region!=KR, request carries no regionrefused — the deny must be carried by the request
13Owner-only scope: an Admin holding org.*, request org.org.billing_managerefused — only the Owner, or an approver who holds that scope explicitly, can approve it

Related: Action Library (the catalog) · Roles & permissions · Proof of decision · Reporting (sealed approval) · Permission scopes