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.
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.
Moves are your responsibility to report
Alfiz does not own your hierarchy, so it cannot observe a move. CallnotifyScopeMoved from the same code path that changes a parent pointer:
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: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.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.
- 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. - Flip strict on (set the variable, restart or redeploy). Every check now pays provider round-trips; the propagation bound is zero.
- Verify with
explain()that the principal’s effective access is what you intend. - Flip strict off once the window that mattered has passed. Caches rebuild on the next checks; nothing else to clean up.
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 cachescan 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.