Skip to main content
Every authorization system that caches has a window in which a revocation has not taken effect yet. Most of them do not tell you how wide it is. Alfiz states the bound, gives you the knobs to narrow it, and provides one escape hatch that ignores it entirely.

Closures are cached; decisions are not

This is the rule the rest of the page follows from. Alfiz caches two things, both of them inputs to a decision:
  • The subject closure. Every subject string that applies to a principal right now: their user id, their groups and the groups above those, their org, everyone, and their implicit manager-chain subjects.
  • The object ancestor chain. The path from a scope instance up to *, as your ancestry resolver reports it.
It never caches the answer. Every can() re-evaluates the grant rows, the revokes, and the expiry clock against whatever closure data it has. That is why a grant expiring at 14:03:00 stops working at 14:03:00 and not at the end of some cache window, because expiry is evaluated and never cached.

The default bounds

The subject TTL is your revocation propagation bound. Revoke a grant, and within subjectCacheTtlMs every process is evaluating against the new rows. Two things cut that short in the process that made the write: the provider’s invalidation stream busts the affected entries immediately, and object chains bust the moment you report a move.
The in-process stream only reaches the process that wrote the event. On a second node, or a second serverless invocation, the TTL alone would be the bound. That is why, since 0.7.0, the event log and revalidation below are on by default wherever the storage driver supports them. The blind-TTL posture is now the explicit opt-out, not the silent default.

Moves are your responsibility to report

Alfiz does not own your hierarchy, so it cannot observe a move. Call notifyScopeMoved from the same code path that changes a parent pointer:
The promise resolves when the move event is durable. The 60-second object TTL exists to bound staleness for the moves that were never reported. It is a backstop and not the design.

The cross-process bound is the default one (0.7.0)

With a storage driver that implements the event methods, which both bundled drivers do, the Application persists invalidation events by default (since 0.7.0), and a client attached to an epoch-bearing provider revalidates by default with a 5-second window. The default cross-process revocation bound is therefore the revalidation window, not the blind TTL. You configure nothing:
The opt-outs, for deployments that deliberately trade looser bounds for zero head reads:
Past the window, the next check performs one read of the log head, coalesced across every concurrent check and constant-cost regardless of organization size. An unchanged head proves nothing changed anywhere, so entry TTLs are renewed rather than expired. A changed head replays only the missed events through the same busting logic the live stream feeds.

What each mode costs you

revalidateAfterMs is inert against a provider with no epoch (TTL-only is all such a deployment can have), and every L2 failure (error, timeout, unparseable or version-mismatched envelope) is a miss, never an answer. The L2 tier remains opt-in.
An L2 entry is served only when provably fresh: with epoch revalidation on, only when it was written under exactly the current log head. Any intervening write anywhere discards it. That is strict, and still a hit whenever writes are quiet, which is the common case.
The cache store holds closure data inside your server trust boundary. Point it only at authenticated, private cache infrastructure.

The incident switch

strict: true on the client makes every check on every surface bypass both cache tiers, exactly as if each call were can.fresh. It exists for one moment: an active incident where “revocations must land now” outranks latency.
The runbook:
  1. Cut the access. Revoke the grants, call setUserActive(userId, false), or both. This is the fix; strict mode is only how fast it lands everywhere.
  2. Flip strict on (set the variable, restart or redeploy). Every check now pays provider round-trips; the propagation bound is zero.
  3. Verify with explain() that the principal’s effective access is what you intend.
  4. Flip strict off once the window that mattered has passed. Caches rebuild on the next checks; nothing else to clean up.
Strict mode is a blunt instrument priced accordingly, since every check is a closure fetch. For one destructive surface, prefer can.fresh below; for one offboarding, setUserActive plus the default revalidation bound is usually already enough.

The escape hatch

can.fresh bypasses both caches on every call, taking a fresh closure supply and fresh ancestry. Pair it with the surfaces where the bounded staleness of the cached path is not acceptable:
  • Destructive actions, where acting on a stale “yes” is irreversible. This is why destructive permissions are declared as their own leaf: the fresh check pays for one surface, not the whole page.
  • Just-in-time elevations, where the expiry boundary matters at the second rather than the minute.
  • Any endpoint where a revocation has to be visible immediately, not within the TTL.
snapshot(principal, { fresh: true }) applies the same posture to every check on a snapshot, and snap.resolve() inherits the freshness the snapshot was taken with.

Why a snapshot is stronger, not weaker

A per-request snapshot reads the same caches can does, so it inherits the same cross-request bounds. Within the request it is stronger: every check sees one subject-data instant and one evaluation clock, so the caches cannot tick over mid-render and produce a page where the button rendered but the action denied.

What is never cached

  • The decision. Always re-evaluated, against whatever closure data is in hand.
  • Grant expiry. Evaluated against the clock at check time, so a grant that expires mid-window stops matching at its expiry, not at the end of the window.
  • Anything outside your infrastructure. There is no Alfiz-operated cache. Every layer described here is a map in your process, or a cache service you point at and operate.
A principal’s active flag is not in that list. It arrives with the subject closure, so setUserActive(userId, false, …) is bounded by exactly the same window as a revocation: immediate in the process that wrote it, subjectCacheTtlMs elsewhere unless you have the event log on. Where deactivation has to bite at once, as with an offboarding kill switch, pair it with can.fresh.