Skip to content

Capability grants & the variable formula

A permission like device.access.approve written as a bare string is abstract — the token device is not bound to the real DeviceBinding entity, approve does not name a real operation, and nothing emits when it “happens.” A grant of it would authorize nothing concrete.

Axowl fixes this by treating a grantable action as a variable formula with two layers:

  1. Representation — a uniform bag of (slot, string-value) pairs. The action, the resource, and every parameter are all slots.
  2. Definition (“substance”) — a catalog entry that binds an (action, resource) pair to a real entity type, a real operation, and the event it emits. This is the validation gate that stops a bag from being a free string.

A scope formula is:

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

Defined once in Axowl.Client/Common/ScopeGrammar.cs (parse/serialize) and Axowl.Auth/Services/ScopePredicateCompiler.cs (evaluation). Six operators — and the operator encodes the variable’s kind, so there is no separate “type” field:

OperatorMeaningVariable kind
=allow, exact matchcategorical / entity-reference
!=deny (block when it matches)negative filter
<= < >= >numeric thresholdordered (number)
* (or absent)wildcardtop / unbounded

Axowl.Client/Common/ScopeGrammar.cs:55 (DetectOperator) matches 2-char operators before 1-char so <= never splits into < + =. Axowl.Auth/Services/ScopePredicateCompiler.cs:64 (Compile) transpiles a formula to a DuckDB WHERE clause — so a formula is not just stored, it is compiled and evaluated over the audit Parquet:

sap.invoice.approve:amount>10000
→ EventType = 'sap.invoice.approve'
AND CAST(json_extract_string(BusinessData,'$.amount') AS DOUBLE) > 10000
okta.app.assigned:risk>=3,region=KR
→ EventType = 'okta.app.assigned'
AND CAST(...'$.risk' AS DOUBLE) >= 3
AND json_extract_string(...'$.region') = 'KR'

The value is always a string (amount=10000 stores "10000"); the operator tells the comparator how to read it. This matches the runtime store: ConnectedIdRole.AssignedVariables is Dictionary<string,string> and the snapshot scope is a string.

A capability is a uniform bag of (slot, string-value) — action and resource are themselves slots:

Slotkind (entity / domain)value
actionverb spaceapprove
resourceDeviceBinding<deviceId> or *
paramOsClasswindows
paramAmount10000
controluses (lifecycle)1 (one-time) or * (permanent)

This is exactly the resolvedScope string SnapshotService already builds (InjectVariables, Axowl.Auth/Services/SnapshotService.cs:205), not invented — it is the same shape used by attribute-based access control and Cedar context. [2026-07-08] The vocabulary is now derived from the database schema (EntitlementLibrary), and subsumption is evaluated by EntitlementSubsumption (arbitrary-depth FK paths, layer-inviolable, constraint ceiling). The prior hand-named GrantableActionCatalog / CapabilitySubsumption are retired.

Representation : (slot,value) bag — uniform, value always string
Definition (substance): (action,resource) → { entity type, operation, event }
validated against the catalog before anything runs

The definition layer is the guard against the original abstraction bug. A bag is only real if its (action, resource) resolves to a real entity and a valid action. [2026-07-08] This is now guaranteed by derivation: EntitlementLibrary.Build(dbContext.Model) produces the library from the schema itself (entities × FK containment × declared facets × kind-valid verbs), so a scope cannot name a non-existent resource — the vocabulary is the schema. A LinkedPermissionScope is a scope from this derived library, not a free string.

Permanence and expiry are not a separate mode flag — they are reserved variables in the same bag. Three reserved control keys govern a grant’s lifecycle, with fixed grammar (distinct from free domain variables like amount / region):

KeyMeaningFormatDefault (omitted)Examples
usesuse countinteger or ** (unlimited / permanent)uses=1 (one-time), uses=5, uses=*
ttlrelative lifetime<n><unit>* (no expiry)ttl=90d, ttl=12h, ttl=30m
expabsolute expiryYYYY-MM-DD* (no expiry)exp=2026-12-31

ttl time units are fixed — no year / week / month:

s = second m = minute h = hour d = day

A year is written in days (ttl=365d), never 1y. For ceiling comparison every ttl normalizes to seconds (so 12h and 1d compare numerically); d is the largest writable unit.

  • The default is permanent — omit every control variable (all *). This is what the shipped Standard (managed) grants use (e.g. device trust persists until explicitly revoked, not by a timer).
  • But the engine supports uses / ttl / exp fully — including ExpiryWorker enforcement, and this is never removed. Time-based expiry (ttl / exp) is a paid add-on: the shipped Standard (managed) grants default to permanent; customers who need wall-clock expiry buy/enable the add-on permission and set ttl / exp on their own grants. So “Standard doesn’t use it” ≠ “the feature is absent” — the engine always carries it; only the default catalog omits it.
  • uses of 0 or negative is rejected at validation.
  • When both ttl and exp are present, the earlier expiry wins.

Lifecycle bounds come from policy, not a grantor “budget.” A grantor does not carry a personal uses / ttl allowance to subsume against — they are set within the action’s allowed range (and org policy). The one exception is re-delegation: if the grantor’s own grant was itself bounded, they cannot re-delegate beyond it.

Owner / admin granting → sets uses/ttl within the action's policy (no personal cap)
Re-delegation from a bounded grant → cannot exceed the grantor's own (uses ≤ own, ttl ≤ own)

Composite sealed resolvedScope examples:

device.access.approve:device=DB_7f3a9c,os_class=windows # permanent (default — no control vars)
expense.approve:amount<=10000 # permanent
expense.approve:amount<=10000,uses=1 # one-time (optional limit)

Enforcing expiry — count self-triggers, time needs a sweep

Section titled “Enforcing expiry — count self-triggers, time needs a sweep”

The lifecycle variables differ in how they fire, and that drives cost:

  • uses self-triggers. It changes only when the capability is used — each sealed use decrements it; at 0 the next use is denied. No clock, no background job, nothing to “remember.” Anchored to the seal chain → most tamper-resistant and cheapest.
  • ttl / exp need an active trigger. Wall-clock time passes with no action, so a lazy check at access time (ExpiresAt > now, already in PermitEndpoints) is not enough — an already-live session keeps running until its next permit request. An ExpiryWorker (periodic sweep, same pattern as RollupWorker / TamperAlertWorker) fires the consequence at the deadline: revoke the permission, terminate live sessions, emit auth.perm.expired (event already cataloged).

Keep both layers: the lazy check is access-time defense (survives a downed worker); the worker is the active cut-off that kills live sessions and emits the event the moment the deadline passes.

Cost — honest split:

  • Run — cheap, with an index on ExpiresAt (a partial index WHERE IsActive keeps each sweep an index range-scan over a near-empty result). Without it, every tick is a full scan — expensive at scale.
  • Build — moderate: the worker shell is trivial; idempotent firing is the real work. Fire exactly once despite the expired row matching every sweep, and despite crashes / overlapping runs:
    • claim via compare-and-swapUPDATE … SET IsActive=false WHERE Id=@id AND IsActive=true; only the sweep that flips it (1 row affected) proceeds.
    • dedupe-keyed event (expire:{permissionId}, via the existing IIdempotentCommand path) so a crash-retry never double-emits.

This is exactly why uses is preferred as the default expiry: it carries none of this — no worker, no index, no idempotency dance.

The delegation ceiling — the deterministic spine ✅ Built

Section titled “The delegation ceiling — the deterministic spine ✅ Built”

The core safety rule — you cannot grant more than you hold — is plain, deterministic arithmetic over the bag. No LLM:

For every slot in the request, the grantor’s value must subsume the requested value.

Slot kindSubsumption rule
wildcard *top — subsumes anything
numeric (<= etc.)requested grantor’s bound
categorical (=)requested grantor’s set, or exact
entity-referencerequested entity within grantor’s authority metric
!= (deny)grantor’s denies propagate to grantee
Grantor (supervisor): expense.approve:amount=50000
Request: expense.approve:amount=10000 → 10000 ≤ 50000 → allow
Request: expense.approve:amount=80000 → 80000 > 50000 → deny (over ceiling)

This is the literal “approve up to the supervisor’s limit” requirement, falling straight out of numeric subsumption. The runtime already enforces a scope-subset check for any actor in Axowl.Auth/EndPoints/PermitEndpoints.cs:71 (the HashSet.Contains + wildcard match before issuing a SignedPermit).

Finding A — entity-reference slots need an authority metric

Section titled “Finding A — entity-reference slots need an authority metric”

An entity-reference value (role=<roleId>, member=<memberId>) is still a string, but “within authority” is not one rule — each reference kind declares its own authority metric on the slot (the value stays a string; only the metric lives on the slot):

entity-ref”within authority” means
rolerole.level ≤ grantor.level (ordered)
memberthe member is in the grantor’s reporting subtree
app / devicethe entity is owned by the grantor’s org
scope (a permission)the grantor subsumes that capability (recursive)
Grantor = Manager(level 3). Request: assignRole role=Admin(level 4)
→ entity-ref + metric=level: 4 ≤ 3 → deny ("can't assign above your own level")

Finding B — the seal row = nearest ancestor that is both capable and senior enough

Section titled “Finding B — the seal row = nearest ancestor that is both capable and senior enough”

Two rules decide who approves, and they must be reconciled (they can otherwise point at different people):

  • Capability (the ceiling) — the approver must subsume the request (hold ≥ what is asked).
  • RiskLevel (the seniority floor) — the action carries a minimum tier (supervisor / owner / multi-owner); the approver must meet it.

The approval line walks up the reporting tree to the nearest ancestor who satisfies both — capable and at/above the RiskLevel floor:

  • A supervisor may hold the capability but be below the floor (e.g. action is Critical = owner-tier) → the line keeps going up until both are met.
  • If no further ancestor exists, the top box is marked delegated final authority — provided that ancestor still clears the floor.

So RiskLevel is the minimum seniority, the ceiling is can-they-authorize; the approver is the closest ancestor clearing both.

Materialization — derived from uses, not a separate toggle

Section titled “Materialization — derived from uses, not a separate toggle”

The check, ceiling, and seal are identical for every grant; only the materialization tail differs, and it is read straight off uses — there is no separate one-time/continuous switch:

usesEffectRBAC
uses=1the sealed ReportingInstance is the single-use credentialno change; no auth.perm.delegated
uses≥2 or *approved values → AssignedVariablesSnapshotServiceConnectedIdPermissionpersistent; emits auth.perm.delegated

Continuous reuses the existing snapshot pipeline (SnapshotService.BuildPermission, :180) — nothing new to build for materialization.

Where the LLM earns its keep ✅ Built (overlay)

Section titled “Where the LLM earns its keep ✅ Built (overlay)”

The deterministic spine handles single bounded grants. The LLM is a targeted overlay, not on the hot path of every grant. There are two input paths with opposite needs:

PathInputLLM?
Constrained — Standard Grant Action Listmember picks from a ceiling-bounded list; number/entity pickersNo — deterministic check is faster, cheaper, fully auditable
Free-form — admin authors integration scopeshuman composes arbitrary formula bundlesYes — translation + combinatorial conflict detection

GeminiScopeTranslatorService.TranslateAsync (Axowl.Auth/Services/GeminiScopeTranslatorService.cs:82) turns “approve invoices up to 10,000 in KR” into sap.invoice.approve:amount<=10000,region=KR. The system prompt carries the grammar, per-module resource hints (SAP FI/CO/MM…), phrasing rules that map natural-language comparison words to operators (“at most” → <=, “under” → <, “at least” → >=), and a recommended RiskLevel 0–4. Structured JSON output, temperature 0.2.

AnalyzeConflictsAsync (:263) runs before any write — the /analyze endpoint precedes /grant in Axowl.Auth/EndPoints/Integration/IntegrationPermissionGrantEndpoints.cs. It inspects the union of existing ∪ new formulas for problems a per-slot ceiling cannot see:

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

This is where the LLM is worth its cost: cross-capability reasoning over a bundle. A single bounded pick does not need it.

Three efficiency notes from the live integration path (IntegrationsList, IntegrationPermissionGrantEndpoints):

  • The scope-grammar reference must document the numeric operators (<= < >= >), or users reach for the LLM to express a bound they could type by hand.
  • Skip the conflict LLM call when fewer than two scopes interact — a lone scope cannot conflict (at most an intra-scope contradiction, which is a harmless dead scope).
  • RiskLevel tiers (Elevated / Critical / Sovereign) must be enforced — a high-risk grant should route through the approval line, not a flat passkey step-up — or the labels are theater. This is the seam where the Standard Grant model plugs in.

AI governance — future-scoped 🚧 Design (defined, not enforced)

Section titled “AI governance — future-scoped 🚧 Design (defined, not enforced)”

Axowl.Core/Constants/Auth/AiGovernancePolicy.cs defines a 12-article “AI constitution” for the day AI ConnectedIds act as autonomous principals — e.g.:

  • Art 1 — an AI cannot request permits outside its assigned role scope.
  • Art 2 — an AI cannot approve its own trigger (a human ConnectedId must).
  • Art 11RiskLevel ≥ 3 requires a human passkey signature.
  • Art 12 — stop and escalate when it cannot proceed.

Ten cases run through the model (catalog entries: M1 (approveAccess, DeviceBinding), M2 (assignRole, Member), M3 (grantAccess, Application), M4 (delegate, Capability) = Standard Grant; tree S → L → E):

#Edge casePath → outcome
1continuous, wildcard + set…:device=D1,os_class=win,uses=* → M1✅, S∈L✅, *⊇D1, {win,mac}∋winallow, continuous; emits device.lifecycle.activated, reporting.approval.completed, auth.perm.delegated
2numeric boundary equal50000 ≤ 50000allow (inclusive)
3numeric over80000 > 50000deny; auth.perm.scope_escalated
4entity outside authoritymember grantor subtree → deny (tree gate)
5role level orderedAdmin(4) ≤ Manager(3)? → deny (Finding A)
6not in catalog(approve, Application) undefined → deny at definition layer (abstraction guard)
7one-time (uses=1)allow, no ConnectedIdPermission, no auth.perm.delegated
8grantor lacks the actionaction slot not subsumable → deny or route up (Finding B)
9no ancestor above (final authority)mark approver box final authority, line ends, reporting.approval.completed
10wildcard escalationrequest action=*, grantor finite → deny; auth.security.privilege_escalation_attempted

Cases 3, 6, 10 show the model self-defends: the validation gate and subsumption rule emit the escalation events with no extra logic.

ElementStatus
Scope formula grammar + compiler✅ Built
LLM NL→formula + pre-grant conflict analysis✅ Built (admin / free-form path)
SignedPermit runtime gate (scope subset, any actor)✅ Built
Snapshot materialization (SnapshotService)✅ Built
Unified (slot,value) capability bag + definition layerSchema-derived 2026-07-08EntitlementLibrary.Build(dbContext.Model) (74 nodes / 453 scopes); the hand-named GrantableActionCatalog is retired
Deterministic ceiling (subsumption), Standard GrantBuiltEntitlementSubsumption.Subsumes (arbitrary-depth path, layer-inviolable, constraint ceiling, escalation blocked); unit-tested PR-W (19 cases)
uses materialization (one-time vs persistent)BuiltApprovalStampLogic.DecideMaterializeMode; unit-tested PR-Q
ttl/exp enforcement — ExpiryWorker (sweep + idempotent fire, ExpiresAt index)🚧 Design — engine feature, paid add-on (step 4; uses self-triggers, shipped)
Device action substance-definition (device.access.approveDeviceBinding + event)Built — first managed catalog entry (RiskLevel 2, OpKey ActivateBinding)
Tree-routed seal row / final authorityBuiltApprovalLineDeriver (Finding B) + ApprovalLineService wiring; unit-tested PR-P (10 cases). ⚠️ SeniorityTier mapping + op-dispatch first-cut
AI governance enforcement (AI-as-principal)🚧 Design — deferred until AI principals are real

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