Skip to content
Sponsor

Control cache sharing

Keep identity-bearing values local while allowing deliberate public and tenant sharing.

In Astilba Cache, scope answers a storage question: may this value leave the current isolate and enter a shared tier?

L1 is local to one process or worker isolate; L2 is shared or durable. The Workers factory supplies a bounded memory() L1 automatically. See Cache fundamentals for the complete storage vocabulary.

Inputs Resolved storage class Current behavior
scope: “public” pub Eligible for shared L2, subject to the development request guard below.
scope: { tenant } Hashed ten:<h> Eligible for shared L2; the raw tenant identifier is not stored in the scope segment.
No declared scope and a visible principal Hashed usr:<h> L1-only and durable: false.
No declared scope and no visible principal pub Eligible for shared L2.

The kernel derives a principal from primitive request.userId or request.tenant values, in that order. Other request fields do not affect automatic scope resolution. The React Router adapter carries the application-derived request object through currentRequest(); the application remains responsible for authenticating it.

profile.ts
const entry = await cache.getOrSetEntry({
key: "profile",
request: { userId },
factory: async () => loadProfile(userId),
})
entry.durable // false — principal-derived values never reach shared L2

A principal-derived fill still needs L2 to run in the current kernel, but the result deliberately skips the L2 write. Configure an L1 Store if you want the private value retained for a later call on the same isolate. Without L1, the current call succeeds with durable: false and the next call fills again. createWorkersCache() includes a bounded memory L1 for this reason.

With dev: true, Cache wraps ctx.request for an explicitly public factory. Reading any request property demotes that fill to L1-only storage. Merely attaching a request does not demote it; the factory must read through the guarded context.

public-feed.ts
const entry = await cache.getOrSetEntry({
key: "feed",
request: { userId },
scope: "public",
factory: async (ctx) => loadFeed(ctx.request?.userId),
})
entry.durable // false in dev: the public factory read request data

This guard is a development aid, not closure analysis. It cannot see identity captured outside ctx.request, and production mode does not install the demoting Proxy. You remain responsible for making every explicit public or tenant cache key cover all inputs that can change the returned value.

A request containing only tenant still has visible identity, so an undeclared scope becomes principal-derived and L1-only. Declare a tenant scope when values are intentionally shared inside one tenant.

tenant-settings.ts
await cache.getOrSet({
key: "settings",
scope: { tenant: tenantId },
factory: async () => loadTenantSettings(tenantId),
})

A plain telemetry sink receives events as emitted and may contain raw identifiers. When telemetry.hosted is true and a project salt is supplied, the kernel HMAC-pseudonymizes every string field except the structural event type. A hosted configuration without a salt suppresses events rather than forwarding raw strings. Built-in delivery also swallows a sink that throws or rejects; configure onSinkError to observe that failure separately.

The memory() Store can emit private_evicted when an LRU entry with usr: scope is removed under entry or byte pressure. Its payload carries only a count and byte size—never a key, scope hash, or tag. createWorkersCache() does not expose this internal L1 telemetry option; raw composition is required to enable it.

Value scope also gates React Router response caching. The middleware records scope evidence for every served Cache entry and successful fill:

  • only readable pub dependencies remain eligible for Cache-Tag emission;
  • a tenant or principal dependency forces Cache-Control: private for the whole response;
  • missing or malformed stored scope also fails closed to private;
  • l3: false may suppress a tag, but it never suppresses the entry’s scope evidence.

A bare RenderCollector.dependsOn() is different: it is not backed by a managed entry, so it has no scope claim and cannot poison the response by itself. See Cache HTTP responses for the complete header algorithm.