Capability grants & the variable formula
The problem this solves
Section titled “The problem this solves”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.
The formula grammar
Section titled “The formula grammar”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:
| Operator | Meaning | Variable kind |
|---|---|---|
= | allow, exact match (or one of a set: region={KR,JP}) | categorical / entity reference |
!= | deny (block when it matches) | negative filter |
<= < >= > | numeric threshold | ordered (number) |
* (or absent) | wildcard | unbounded |
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 = KRThe capability model
Section titled “The capability model”A capability is the scope plus its constraint bag:
| Part | Example | Notes |
|---|---|---|
| resource path + action | org.app_group.delete | names a resource in the platform’s data model and an action valid for it |
| constraint | creator=self | row-level limit; self resolves to the caller |
| constraint | amount<=10000 | numeric bound |
| lifecycle control | uses=1, ttl=3600 | see 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:
| Form | Covers |
|---|---|
org.app_group.* | org.app_group and everything under it, every action |
org.app_group.*.write | the same as .* (write includes read) |
org.app_group.*.read | only read and list actions under that path |
view.* | every page scope |
How a request becomes a resolved scope
Section titled “How a request becomes a resolved 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.deleteRequest body: { "group": "A1", "ttl": 3600 }Resolved scope: org.app_group.delete:group=A1,ttl=3600That 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.
Lifecycle control variables
Section titled “Lifecycle control variables”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.
| Key | Meaning | Format | When omitted | Examples |
|---|---|---|---|---|
uses | use count | positive integer or * | unlimited | uses=1 (one-time), uses=5, uses=* |
ttl | relative lifetime | positive integer, seconds from the moment of approval | no expiry | ttl=3600 (one hour), ttl=7776000 (90 days) |
exp | absolute expiry | Unix time in seconds, or an ISO 8601 timestamp with Z or an explicit offset | no expiry | exp=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.
usesof0, a negative number or a non-integer; attlthat is not a positive whole number of seconds; anexpwithout a time zone; auses,ttlorexpin 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
ttlandexpare present, the earlier expiry wins. - A form that grants several scopes has one use budget: every scope in the bundle must carry the same
usesvalue, or the approval is refused.
What each control does
Section titled “What each control does”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:
| Situation | Response |
|---|---|
| header missing | 428 APPROVAL_INVOCATION_REQUIRED |
| the same invocation ID was already recorded | 409 APPROVAL_INVOCATION_ALREADY_RECORDED |
| no matching approval with uses left (or it expired) | 403 ROOST_PERMISSION_REQUIRED |
| a use was reserved | the 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 and the ceiling
Section titled “Lifecycle and the ceiling”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 delegation ceiling
Section titled “The delegation ceiling”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 holds | The 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!=value | carry 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<=50000Request: expense.approve:amount=10000 → inside the bound → allowedRequest: expense.approve:amount=50000 → on the bound → allowed (inclusive)Request: expense.approve:amount<=30000 → a narrower bound → allowedRequest: expense.approve:amount=80000 → over the bound → refusedRequest: expense.approve:amount>=10000 → open upward → refusedRequest: expense.approve → no amount at all → refused
Approver holds: shipment.approve:region!=KRRequest: shipment.approve:region=JP → not a denied value → allowedRequest: shipment.approve:region!=KR → the deny is carried → allowedRequest: shipment.approve:region=KR → a denied value → refusedRequest: shipment.approve → the deny is dropped → refusedOwner-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.
The approval line
Section titled “The approval line”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 mode | Approvers |
|---|---|
single | the nearest qualifying ancestor |
quorum | the nearest K qualifying ancestors (all of them when K is not set) |
chain / parallel | every qualifying ancestor |
Seniority tiers:
| Member | Tier |
|---|---|
| Owner | 4 |
| Board member | 3 |
| Admin, and any other ancestor in the reporting tree | 2 |
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.
What the requester receives
Section titled “What the requester receives”When the last required approver stamps, the resolved scopes are granted to the requester, each tagged with the approval that produced it:
| Request | Result |
|---|---|
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.
Where the LLM earns its keep
Section titled “Where the LLM earns its keep”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.
| Path | Input | LLM? |
|---|---|---|
| Constrained — approval forms | a member requests a form whose scopes are already defined | No — the deterministic check is faster, cheaper, fully auditable |
| Free-form — integration permissions | an Owner or Admin composes arbitrary formula bundles for a group | Yes — translation and combinatorial conflict detection |
All three endpoints below require an Owner or Admin of the organization.
Natural language → formula
Section titled “Natural language → formula”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.
Pre-grant conflict analysis
Section titled “Pre-grant conflict analysis”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 modifyimpossible_range/range_overlap—amount>=3000together withamount<2000sod_violation— the same group can create and approve the same resourceall_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.
Granting
Section titled “Granting”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.
Worked examples
Section titled “Worked examples”| # | Case | Outcome |
|---|---|---|
| 1 | approver holds org.app_group.*, request org.app_group.delete:group=A1 | allowed — wildcard covers the path; requester receives org.app_group.delete:group=A1 |
| 2 | numeric boundary: holds amount<=50000, request amount=50000 | allowed (inclusive) |
| 3 | numeric over: holds amount<=50000, request amount=80000 | refused |
| 4 | set membership: holds region={KR,JP}, request region=US | refused |
| 5 | read-only holder: holds org.app_group.*.read, request org.app_group.delete | refused — a read wildcard never covers a write action |
| 6 | namespace: holds view.*, request any resource scope | refused — page scopes never cover resource scopes |
| 7 | finite 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) |
| 8 | temporary approver: permission expires in 7 days, request has no ttl / exp | refused — the grant would outlive the approver’s own permission |
| 9 | nearest capable manager is below the risk floor | the line continues up to the first ancestor who clears both |
| 10 | request raised by an agent on a risk-level-0 form | needs a human approver’s stamp; never self-approved |
| 11 | open-ended bound: holds amount<=50000, request amount>=10000 | refused — the request’s operator leaves the range open upward |
| 12 | dropped deny: holds region!=KR, request carries no region | refused — the deny must be carried by the request |
| 13 | Owner-only scope: an Admin holding org.*, request org.org.billing_manage | refused — 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