Policy engine

Declare access. Deny wins. State converges.

Author grant and deny policies as composable building blocks. Desired access is the union of active grants, minus denies, minus governed exemptions — so "everyone in Engineering except interns" is a predicate, not a list you maintain. The engine decides each identity's access from current state, previews every change as a wave before it applies, and continuously reconciles reality back to what you declared.

Set algebra

Desired access is a set, computed.

Grant policies add access; deny policies remove it; a deny wins over every grant it covers. Desired access for an identity is the union of its active grants, minus the union of effective denies, minus the items a governed exemption spares. Because policies compose this way, "everyone in Engineering except interns" is an ordinary predicate rather than a special role variant — and there is deliberately no exact-set ("this and only this") policy type, because an exact set wouldn't compose. The same intent is expressed as a grant plus a deny of the rest.

desired = union(grants) − denies − exemptions

No exact-set roles to proliferate into thousands of near-duplicates. And the decision is deterministic set algebra, not a model's best guess — the same inputs resolve to the same set, run after run.

Convergent evaluation

Decide, apply, converge.

Evaluation is two stages, not one transaction: a read-only decide that computes what should be true, and an apply that commits it and hands the difference to provisioning. The engine continuously reconciles declared intent against current identity state, so a department move, a role change, or a termination is handled implicitly on every re-evaluation — no per-event script to write or maintain.

  1. 01

    Declare.

    A grant policy adds access to every identity matching its condition; a deny prohibits it, and a deny wins over every grant it covers. Both are versioned — a saved policy snapshots into an immutable version, and only a confirmed version is ever evaluated, so a live edit can't silently apply mid-flight.

  2. 02

    Match.

    A predicate targets the population: all identities, picked identities, group or role membership, "has access to" assignments, and attribute filters over resolved attributes, subject fields, and lifecycle state. The same predicate compiles two ways — a population query for wave previews, and a per-identity check at evaluation.

  3. 03

    Decide.

    decideIdentity is read-only. It reads current resolved state and the identity's actual recorded access, computes desired = union of active reasons, minus denies, minus exempted items, and diffs that against what's actually there. It reads recorded access, never downstream provisioning state. Nothing moves in this stage.

  4. 04

    Apply.

    applyDecision commits the ledger mutation and an evaluation record in one transaction. A durable dispatch outbox then drives the diff to provisioning, keyed for idempotency — a retry, a concurrent post-commit kick, and the cron repair pass all replay the identical key, so provisioning de-duplicates and never double-creates an operation.

  5. 05

    Converge.

    Re-deciding a converged identity emits nothing — the engine self-heals by idempotent no-op, so a missed trigger costs latency, not correctness. An eight-pass cron sweep runs roughly every five minutes as a loss-proof backstop, re-deciding any identity not evaluated in the last day.

Plan before apply

Every change is a wave you confirm.

Activating a policy — or staging a semantic edit to an active one — computes an exact preview across every candidate identity: this change grants N, revokes M. Every candidate is individually simulate-decided, the same computation the real run performs, just held for your confirmation first. Confirm, and the wave runs against current state at confirm time; interrupt it mid-rollout and it's recoverable. It's the access equivalent of infrastructure plan and apply — not a live edit or a bulk import that takes effect the instant it's saved.

Lifecycle

Access that tracks where someone is.

Policies target where an identity is in their lifecycle — hired, terminated, and states you define — without anyone hand-maintaining a status flag. The state is derived from current attributes, so the policy follows the person.

Birthright grants.
Author "Engineering gets the GitHub resource" once. It silently grants when someone resolves into Engineering and silently revokes when they transfer out — no per-event script, ever. A move is handled implicitly, on every re-evaluation, not as a workflow you wrote by hand.
Derived lifecycle state.
A tenant-defined lifecycle_state (seeded HIRED and TERMINATED, redefinable) is computed from current attributes by ordered, first-match rules with a fallback. Deterministic and current-state only — policies target "where someone is," not a status flag someone has to remember to set.
Time-limited grants.
A grant effect can carry a TTL that becomes an access-window end on the resulting assignment. A scheduled sweep revokes it when the window closes, so temporary access expires on its own instead of relying on someone to remember to remove it.
The termination default-deny.
An editable, default-on system deny strips a terminated identity's access, with a tenant-configurable carve-out — say, a 30-day email handover — and its own routing. Real, editable policy behavior, not hard-coded offboarding logic buried in the engine.

Revocation

A revoke is one decision. The queue is one place.

Every policy-caused revocation — a grant lapsing or a deny binding — routes through two independent dials: a delay (immediate, or hold for a grace window) and a route (direct, or human review), set as a tenant default with per-policy override. A grant lapse runs a small state machine from active to lapsing; re-bind before the deadline and it cancels with zero operations — the system's own word for the outcome is "nothing ever happened." A binding deny that isn't immediate parks as a reviewable row that keeps existing access while suppressing any new grant until it resolves.

At the policy layer a revoke is a single mode-less action. Whether the target account ends up disabled or deleted is decided downstream, from the specific resource's own provisioning profile — policy decides whether and when to revoke; provisioning decides how it lands.

Parked grant lapses, parked deny reviews, and breaker-held revokes present as one operator queue, regardless of which table stores them. Resolving a parked deny is either a green-light to revoke or an allow that stamps a governed exemption — never a bare dismiss.

Governed exceptions

Exemptions are metadata, not a spreadsheet.

A sanctioned carve-out lives as a governed array entry directly on the access item it spares — naming the deny, an expiry, an approver, and a reason. It's never a free-standing record, so an orphaned exemption is structurally impossible. It matches its deny by ID, version-agnostic, so it rides across policy edits to its own expiry; an expiry sweep strips it and the deny re-applies on the next evaluation. No exception-tracking spreadsheet an auditor finds a hole in eighteen months later.

Separation of duties

The vendor-payment problem as one object.

Declare two conflicting access selectors — "submit vendor" versus "approve payment" — and saving the constraint compiles it into two managed deny policies that ride the same evaluation, waves, routing, review, exemptions, request gate, and breaker as any hand-authored deny. Edit one generated deny in isolation and the call is rejected, pointing you back at the owning constraint, so the pair can't quietly drift apart the way two hand-synchronized rules do. Violations report per holder with a disposition — detected, parked, pending revoke, exempted — and v1 never auto-revokes a converged conflict, because which side to strip is a human judgment.

Request-time gate

Catch it at request time — or force-revoke it after.

When a resource is requested, request-svc calls a read-only policy check. Each covering deny is configured advisory (warns, allows) or blocking (requires an exception approval, itself permission-gated and rejecting self-approval). The gate fails open: if policy-svc is unreachable it never wedges a request, because the convergent engine is the backstop — anything denied that slips through gets force-revoked on the next evaluation, and the fail-open path raises a visible alert rather than failing silently. A cleared exception stamps the exemption that rides the request into provisioning, so the access and its carve-out land together in the same transaction.

Blast radius

A breaker for your own automation.

A data-flood breaker counts distinct identities with an applied destructive revoke in a rolling window and trips at a tenant-tunable threshold — computed, not fixed: 0.5% of your active identities, floored at 5 and capped at 25. It pauses further revoking changes into a held wave while ordinary grants keep flowing; a config-wave breaker does the same for a single wave's totals. Resume re-evaluates the held set from current state rather than replaying a stale decision, so an identity whose state recovered during the hold isn't revoked. Most tooling has no circuit breaker for its own automation — a bad import just runs to completion.

Safe by construction

Pause a policy without nuking anyone.

A convergent engine bolted on without care turns a routine edit into a mass de-provisioning event. Two safeguards make ordinary operator moves boring on purpose.

Turn a policy off, keep the access.
Disabling or deleting a grant policy demotes its live reasons to a manual floor first, rather than cascading a revoke. A routine "pause this while I edit it" never becomes an accidental mass de-provisioning event — and re-enabling re-adopts exactly those rows back to policy ownership, nothing more.
Adopt access that already exists.
When a policy newly covers access that's already there, it can adopt it — retiring the prior manual or observed reason so the policy owns it going forward — or leave it alone. Adoption changes only the reason, not the access, so it can't trip the breaker and is safe to re-run against the same population twice.

Reactions

One-shot automation, from the same building blocks.

Not every response to a lifecycle change is standing access. A reaction fires once on a single watched attribute transition — say lifecycle_state moving HIRED to TERMINATED — and chains up to ten ordered actions built from primitives you already have: run a tenant function, or submit a provisioning operation. Each transition fires exactly once, tracked in a durable execution journal, and a reaction never retroactively fires on state that existed before it was created. Reactions submit provisioning operations; they never decide access — the convergent engine stays the sole arbiter, and a reaction-submitted grant is re-evaluated like any other.

Provenance

Why does this person have this?

Every access item — an account or a single entitlement — carries one reason: a policy binding, a request, a direct admin grant, or sync-observed drift. "Why does this identity have this" walks the ledger back to the exact source policy, version, and evaluation that put it there, and evaluation records distinguish an applied decision from a preview that never happened. Revocation treats access as held by a policy tier and a manual tier; an item is removed only when both are empty — so a manual revoke can't whack-a-mole away policy-managed access, and a policy lapse doesn't strip access an admin deliberately granted, unless the policy is configured to force it.

Honest limits

What it doesn't do yet.

We'd rather you know the edges up front. This is what's authored-but-blocked, deferred, or deliberately out of scope in this release — the convergent engine is the backstop behind most of them.

Revoke-via-approval-request is authored, not live.
The revoke-via-approval-request routing option is saveable as a value but blocked from activation in this release — it needs an extension to the request workflow to fulfill a revoke through an approval chain. Only the direct and human-review routes are live today.
The request gate checks the account, not every entitlement.
The gate checks the requested account item, not the full set of entitlements a request would compute at fulfillment. An entitlement-level blocking deny is caught by the convergent engine after provisioning, not prevented at request time. The engine is the backstop; the gate is a point-in-time check, not an exhaustive simulation.
No standing simulate mode.
There is no continuous dry run that accumulates would-be changes in the background as data drifts. Planning is a re-runnable, point-in-time wave preview, not a live feed.
SoD scope narrowing is carve-outs only.
In v1, a separation-of-duties scope can only exclude a population; a positive include ("only Finance") is rejected at save. SoD also never auto-revokes a converged conflict — every violation parks for a human decision — and there's no role-mining, conflict-matrix analytics, or cross-tenant constraint library yet.
Brownfield go-live backfill isn't built.
Adoption of access a policy newly covers is built; seeding the ledger from access that already exists across an entire tenant at initial rollout is not. The engine currently assumes it owns the access it grants going forward rather than reconstructing history retroactively.
No lifecycle-template library.
Pre-built joiner and leaver templates that compile down to lifecycle definitions and policies don't exist yet. Policies and lifecycle states are authored directly, not from a template catalog.
Reactions are single-attribute, two action kinds.
A reaction triggers on one watched attribute (equality or set-membership) — no compound predicates in v1. Actions are limited to running a function or submitting a provisioning operation; there's no notification, ticketing, or webhook action yet, and the builder is a flat list, not a visual workflow canvas.
Delete-and-revoke uses a count-only preview.
The full deactivation preview-wave — matching activation's exact, every-identity preview — isn't wired for delete-and-revoke yet. It uses a lighter count-only preview today, backstopped by the blast-radius breaker rather than a pre-computed exact number.

Deciding access is only half of it.

Early access is open. Bring your real access reality — the birthright grants, the toxic combinations, the terminations, and the exceptions you've been tracking in a spreadsheet.