> ## Documentation Index
> Fetch the complete documentation index at: https://docs.alfiz.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Caching and Staleness: What Your Revocation Bound Actually Is

> Alfiz caches closures, never decisions. Learn the default TTL bounds, how to tighten them across processes with the event log, and when to reach for can.fresh.

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

| What                              | Default                | Option                   |
| --------------------------------- | ---------------------- | ------------------------ |
| Subject closure                   | 30 s                   | `subjectCacheTtlMs`      |
| Object ancestor chain             | 60 s                   | `objectCacheTtlMs`       |
| Subject cache size                | 10 000 principals, LRU | `maxSubjectCacheEntries` |
| Object cache size                 | 10 000 chains, LRU     | `maxObjectCacheEntries`  |
| Group topology (Application side) | 30 s                   | `groupTopologyTtlMs`     |

**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.

<Warning>
  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.
</Warning>

## 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:

```ts theme={null}
await db.docs.update({ where: { id }, data: { folderId: newFolderId } });
await app.notifyScopeMoved(`docs.doc:${id}`);
```

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:

```ts theme={null}
const app = createApplication({
  catalog,
  storage: prismaDriver(prisma),   // schema carries AlfizEpoch/AlfizEvent → events persist
  ancestry,
});

export const alfiz = createAlfizClient({
  catalog,
  provider: app,                    // epoch present → revalidateAfterMs defaults to 5_000
  cacheStore: respCacheStore(redis), // optional L2 for cold starts
});
```

The opt-outs, for deployments that deliberately trade looser bounds for zero head reads:

```ts theme={null}
createApplication({ ..., events: { persist: false } });
createAlfizClient({ ..., revalidateAfterMs: false }); // TTL-only: the pre-0.7.0 posture
```

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

| Mode                                                                     | Cross-process staleness bound                          | Steady-state cost per check                                                             |
| ------------------------------------------------------------------------ | ------------------------------------------------------ | --------------------------------------------------------------------------------------- |
| Event log + revalidation (**default** with a capable driver)             | The revalidation window (5 s default) plus one request | 0 queries warm; **one** single-row read per window, amortized over every principal      |
| TTL only (`revalidateAfterMs: false`, or a driver without event methods) | Subject / object TTL (30 s / 60 s)                     | 0 queries warm; full closure fetch per TTL expiry                                       |
| + `cacheStore` (L2)                                                      | Same as above                                          | Cold starts read one cache entry and one head read, instead of the full closure fan-out |
| Epoch unreachable                                                        | Falls back to the TTL bounds                           | Fail-closed to the database, so stale data is never served past its window              |

<Note>
  `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.
</Note>

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.

<Warning>
  The cache store holds closure data inside your server trust boundary. Point it only at authenticated, private cache infrastructure.
</Warning>

## 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.

```ts theme={null}
export const alfiz = createAlfizClient({
  catalog,
  provider: app,
  strict: process.env.ALFIZ_STRICT === "1",
});
```

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:

```ts theme={null}
await alfiz.can.fresh({ userId }, "docs.files.delete", "docs.folder:9");
```

* **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](/api/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.

<Warning>
  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`.
</Warning>
