Capability grants & the variable formula
The problem this solves
Section titled “The problem this solves”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:
- Representation — a uniform bag of
(slot, string-value)pairs. The action, the resource, and every parameter are all slots. - 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.
The formula grammar ✅ Built
Section titled “The formula grammar ✅ Built”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:
| Operator | Meaning | Variable kind |
|---|---|---|
= | allow, exact match | categorical / entity-reference |
!= | deny (block when it matches) | negative filter |
<= < >= > | numeric threshold | ordered (number) |
* (or absent) | wildcard | top / 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.
The capability model ✅ Built
Section titled “The capability model ✅ Built”A capability is a uniform bag of (slot, string-value) — action and resource are themselves slots:
| Slot | kind (entity / domain) | value |
|---|---|---|
| action | verb space | approve |
| resource | DeviceBinding | <deviceId> or * |
| param | OsClass | windows |
| param | Amount | 10000 |
| control | uses (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.
Two layers
Section titled “Two layers”Representation : (slot,value) bag — uniform, value always stringDefinition (substance): (action,resource) → { entity type, operation, event } validated against the catalog before anything runsThe 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.
Lifecycle control variables 🚧 Design
Section titled “Lifecycle control variables 🚧 Design”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):
| Key | Meaning | Format | Default (omitted) | Examples |
|---|---|---|---|---|
uses | use count | integer or * | * (unlimited / permanent) | uses=1 (one-time), uses=5, uses=* |
ttl | relative lifetime | <n><unit> | * (no expiry) | ttl=90d, ttl=12h, ttl=30m |
exp | absolute expiry | YYYY-MM-DD | * (no expiry) | exp=2026-12-31 |
ttl time units are fixed — no year / week / month:
s = second m = minute h = hour d = dayA 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/expfully — includingExpiryWorkerenforcement, 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 setttl/expon their own grants. So “Standard doesn’t use it” ≠ “the feature is absent” — the engine always carries it; only the default catalog omits it. usesof0or negative is rejected at validation.- When both
ttlandexpare 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 # permanentexpense.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:
usesself-triggers. It changes only when the capability is used — each sealed use decrements it; at0the next use is denied. No clock, no background job, nothing to “remember.” Anchored to the seal chain → most tamper-resistant and cheapest.ttl/expneed an active trigger. Wall-clock time passes with no action, so a lazy check at access time (ExpiresAt > now, already inPermitEndpoints) is not enough — an already-live session keeps running until its next permit request. AnExpiryWorker(periodic sweep, same pattern asRollupWorker/TamperAlertWorker) fires the consequence at the deadline: revoke the permission, terminate live sessions, emitauth.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 indexWHERE IsActivekeeps 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-swap —
UPDATE … 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 existingIIdempotentCommandpath) so a crash-retry never double-emits.
- claim via compare-and-swap —
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 kind | Subsumption rule |
|---|---|
wildcard * | top — subsumes anything |
numeric (<= etc.) | requested ≤ grantor’s bound |
categorical (=) | requested ∈ grantor’s set, or exact |
| entity-reference | requested entity within grantor’s authority metric |
!= (deny) | grantor’s denies propagate to grantee |
Grantor (supervisor): expense.approve:amount=50000Request: expense.approve:amount=10000 → 10000 ≤ 50000 → allowRequest: 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 |
|---|---|
role | role.level ≤ grantor.level (ordered) |
member | the member is in the grantor’s reporting subtree |
app / device | the 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:
uses | Effect | RBAC |
|---|---|---|
uses=1 | the sealed ReportingInstance is the single-use credential | no change; no auth.perm.delegated |
uses≥2 or * | approved values → AssignedVariables → SnapshotService → ConnectedIdPermission | persistent; 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:
| Path | Input | LLM? |
|---|---|---|
| Constrained — Standard Grant Action List | member picks from a ceiling-bounded list; number/entity pickers | No — deterministic check is faster, cheaper, fully auditable |
| Free-form — admin authors integration scopes | human composes arbitrary formula bundles | Yes — translation + combinatorial conflict detection |
NL → formula
Section titled “NL → formula”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.
Pre-grant combinatorial conflict analysis
Section titled “Pre-grant combinatorial conflict analysis”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 modifyimpossible_range/range_overlap—amount>=3000 ∧ amount<2000sod_violation— same group can create and approve the same resourceall_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).
RiskLeveltiers (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 11 —
RiskLevel ≥ 3requires a human passkey signature. - Art 12 — stop and escalate when it cannot proceed.
Worked examples — managed only
Section titled “Worked examples — managed only”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 case | Path → outcome |
|---|---|---|
| 1 | continuous, wildcard + set | …:device=D1,os_class=win,uses=* → M1✅, S∈L✅, *⊇D1, {win,mac}∋win → allow, continuous; emits device.lifecycle.activated, reporting.approval.completed, auth.perm.delegated |
| 2 | numeric boundary equal | 50000 ≤ 50000 → allow (inclusive) |
| 3 | numeric over | 80000 > 50000 → deny; auth.perm.scope_escalated |
| 4 | entity outside authority | member ∉ grantor subtree → deny (tree gate) |
| 5 | role level ordered | Admin(4) ≤ Manager(3)? → deny (Finding A) |
| 6 | not in catalog | (approve, Application) undefined → deny at definition layer (abstraction guard) |
| 7 | one-time (uses=1) | allow, no ConnectedIdPermission, no auth.perm.delegated |
| 8 | grantor lacks the action | action slot not subsumable → deny or route up (Finding B) |
| 9 | no ancestor above (final authority) | mark approver box final authority, line ends, reporting.approval.completed |
| 10 | wildcard escalation | request 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.
Status summary
Section titled “Status summary”| Element | Status |
|---|---|
| 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 layer | ✅ Schema-derived 2026-07-08 — EntitlementLibrary.Build(dbContext.Model) (74 nodes / 453 scopes); the hand-named GrantableActionCatalog is retired |
| Deterministic ceiling (subsumption), Standard Grant | ✅ Built — EntitlementSubsumption.Subsumes (arbitrary-depth path, layer-inviolable, constraint ceiling, escalation blocked); unit-tested PR-W (19 cases) |
uses materialization (one-time vs persistent) | ✅ Built — ApprovalStampLogic.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.approve → DeviceBinding + event) | ✅ Built — first managed catalog entry (RiskLevel 2, OpKey ActivateBinding) |
| Tree-routed seal row / final authority | ✅ Built — ApprovalLineDeriver (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