diff --git a/CHANGELOG.md b/CHANGELOG.md index 7954064..56da5a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,40 @@ All notable changes to the CacheKit Protocol Specification. ## [Unreleased] +### SaaS API + +- **`X-CacheKit-Fresh-For` remaining-freshness response header (LAB-557).** + `GET /v1/cache/{key}` `200 OK` responses now carry the entry's remaining + freshness in whole seconds (server-clock delta; `0` on stale-window + responses; emitted on every `GET` `200 OK` — TTL is mandatory, so a + "no expiry" entry cannot exist; omitted only by pre-signal servers), so + SDK local caches (L1) can bound backfill to `min(local_ttl, fresh_for)` + instead of restarting the freshness clock at time-of-read — an entry read + near the end of its server-side window could previously be served fresh from + L1 for up to another full TTL, past `fresh_until` (and, with a stale-grace + window, past `evict_at`). The header value is a hard local service bound: + once elapsed, the local copy MUST NOT be served in any form (a `0` value + prohibits backfill entirely) — client-side stale service of server-backed + entries is prohibited regardless of header presence, since the client has + no remaining-eviction signal; the server owns the stale window through + `evict_at`. Additive and backward compatible: absent header = + legacy behavior on both sides. Not emitted on `HEAD`. Spec: + [saas-api.md → Remaining Freshness](spec/saas-api.md#remaining-freshness). + Origin: CodeRabbit outside-diff finding on + [cachekit-py#233](https://github.com/cachekit-io/cachekit-py/pull/233). +- **Second panel round on the same header (LAB-2531).** The deployment-specific + "≤5 seconds" edge-coherence figure is dropped from the normative text — the + deployed tiers compose to roughly double it, and the spec now states the + general truth instead: coherence windows **compound** across composed tiers + that re-stamp rather than decay. New in the same round: servers MUST emit + `Cache-Control: no-store` on every response (the cache key carries no tenant, + so byte-identical URLs across tenants make heuristic HTTP caching + (RFC 9111 §4.2.2) a cross-tenant read; CacheKit-operated tiers MUST partition + internal caches by tenant); `fresh` + `Fresh-For: 0` documented as legal + (final sub-second floors to `0` — serve, don't backfill); the dead + "no expiry" emission branch removed (it failed open into pre-signal legacy + behavior); local deadlines SHOULD use a suspend-counting clock. + ### Wire format — compressed-byte reproducibility scoped per-vector (LAB-1751) - LZ4 compressed bytes are **not canonical** across conforming block encoders. diff --git a/sdk-feature-matrix.md b/sdk-feature-matrix.md index a1f64e9..a06ba71 100644 --- a/sdk-feature-matrix.md +++ b/sdk-feature-matrix.md @@ -173,6 +173,7 @@ The contract a storage backend must satisfy per SDK (bytes in / bytes out; seria | TTL management | ✅ Redis + SaaS + File; Memcached refresh-only (see note) | ✅ Redis + SaaS + File + Workers (`TtlInspectable`); Memcached refresh-only (LAB-429/426) | ✅ Redis + SaaS + File (`TTLBackend`); Memcached refresh-only (LAB-430) | ❌ | | Stale-while-revalidate (client L1) | ⚠️ L1-only mode (`backend=None`) **and an explicit `ttl=`** only¹⁰ | ✅ Serve-stale + single-flight background refresh (LAB-728)¹⁰ ¹³ | ✅ `getWithSwr` — version tokens + background refresh, `maxConcurrentRefreshes` cap; on Workers requires a bound `ExecutionContext` (see [Cache Backends](#cache-backends) note ¹) | ❌ | | Stale-while-revalidate (server stale-grace) | 🚧 LAB-381 | ❌ | ❌ | ❌ | +| Server-bounded L1 backfill (`X-CacheKit-Fresh-For`, [saas-api.md → Remaining Freshness](spec/saas-api.md#remaining-freshness)) | 🚧 LAB-557 | ❌ | ❌ | ❌ | > [!IMPORTANT] > ¹³ **The Rust reliability tier ships in `cachekit-rs` 0.6.0+ and is on by default.** Verified inside the published artifact, not the branch: the `cachekit-rs` 0.6.0 `.crate` from crates.io (published 2026-08-03T14:58:16Z) contains `src/reliability.rs`, `src/flight.rs`, `tests/reliability_tests.rs`, and `get_with_swr` in `src/l1/mod.rs`, and its `Cargo.toml` declares `default = ["cachekitio", "encryption", "l1", "reliability"]`. So a plain `cargo add cachekit-rs` gets **circuit breaker, retry, backpressure and L1 SWR** with no feature flags. Two of the six cells need an opt-in feature: macro-level graceful degradation and the automatic `#[cachekit]` single-flight wiring are emitted by the proc-macro, and `macros = ["dep:cachekit-macros"]` is **not** in `default` — add `--features macros`. Redis-backed presets likewise need the non-default `redis` feature (see [Developer Experience](#developer-experience) note ¹¹). diff --git a/spec/saas-api.md b/spec/saas-api.md index 386c39f..c3074e4 100644 --- a/spec/saas-api.md +++ b/spec/saas-api.md @@ -51,6 +51,8 @@ Authorization: Bearer ck_live_xxxxxxxxxxxxxxxxxxxxxxxxx API keys follow the format `ck_live_...` (production) or `ck_test_...` (staging). The API key implicitly scopes all operations to a tenant. Multi-tenancy is enforced server-side. +**HTTP intermediary caching is prohibited.** Servers MUST emit `Cache-Control: no-store` on every response. The [cache key](cache-key-format.md) carries no tenant component — tenancy rides only in the `Authorization` header — so two tenants using the same namespace, function, and arguments produce byte-identical request paths, and a shared HTTP cache applying heuristic freshness (RFC 9111 §4.2.2) to an unmarked response could serve one tenant's bytes to another. Any CacheKit-operated serving tier that caches responses (edge, colo) MUST partition its internal cache by tenant, never by URL alone; such tiers are part of the server, not HTTP intermediaries, and the `no-store` rule governs what they emit, not what they may store. + --- ## Content Type @@ -90,6 +92,31 @@ Authorization: Bearer ck_live_xxx | Header | Description | | :--- | :--- | | `X-CacheKit-Freshness` | `fresh` or `stale` — lowercase, case-sensitive tokens. Emitted on every `200 OK` by servers implementing [stale-while-revalidate](#stale-while-revalidate). SDKs MUST treat an absent header as `fresh` (pre-SWR servers do not emit it) and an unrecognized value as `stale` (revalidation is the conservative action). Read behavior is specified in [Stale-While-Revalidate](#stale-while-revalidate). | +| `X-CacheKit-Fresh-For` | Remaining freshness in whole seconds. Semantics: [Remaining Freshness](#remaining-freshness). | + +#### Remaining Freshness + +> Status: **specified** (LAB-557). Origin: without a remaining-freshness signal, an SDK that backfills a local cache (L1) from a read assigns its full configured TTL from time-of-read — an entry read near the end of its server-side freshness window is then served locally as fresh for up to another full TTL, past the server's `fresh_until` (and, with a [stale-grace window](#stale-while-revalidate), potentially past `evict_at`). + +`X-CacheKit-Fresh-For` tells the reader how long the served value remains fresh, so local caches can bound their own service window to the server's. + +**Server (emission):** + +- Emitted on **every** `GET` `200 OK` response by signal-capable servers. Every stored entry has a freshness bound — [TTL validation](#put-v1cachekey) rejects `0` and applies the tenant default when the header is omitted — so there is no "no expiry" entry and no compliant reason for a signal-capable server to omit the header. The value is a non-negative integer: `max(0, floor(fresh_until − now))`, computed against the **server's clock** at response time — the client never compares server timestamps against its own clock. +- Stale-window responses (`X-CacheKit-Freshness: stale`) carry `X-CacheKit-Fresh-For: 0` — freshness is already exhausted. `X-CacheKit-Freshness: fresh` with `X-CacheKit-Fresh-For: 0` is also legal — an entry in its final sub-second of freshness floors to `0`. The response is served to the caller normally; the `0` governs only local caching (no backfill). +- Omitted only by pre-signal servers. +- A serving tier that re-serves a value it read earlier (e.g. an edge cache in front of the store) MUST either decay the value by the time already elapsed or emit `X-CacheKit-Fresh-For: 0` when the remaining freshness is unknown — it MUST NOT omit the header it received (omission means "pre-signal server" to the client and would silently restore the unbounded backfill this header exists to kill). It MUST NOT replay an undecayed value beyond its documented coherence window — and coherence windows **compound** across composed tiers: a tier that re-stamps its own full TTL on a hit from the tier below, instead of decaying, adds its window to the path's total, so a deployment's effective window is the sum along the serving path, not its largest single tier. +- `HEAD` does **not** carry this header — an existence check returns no payload, so there is nothing to backfill locally (the `X-CacheKit-Freshness` label on `HEAD` remains informational, per [Stale-While-Revalidate](#stale-while-revalidate)). Correspondingly, a `HEAD` response MUST NOT create, refresh, or extend any local entry's service bound. + +**SDK (consumption):** + +- On a `200 OK` with the header present, a local cache (L1) backfill MUST bound the entry's local lifetime to at most the header value: `min(local_ttl, fresh_for)`. A value of `0` means the entry MUST NOT be backfilled at all. +- The header value is a hard local **service** bound, not merely a freshness bound: once it elapses, the local copy MUST NOT be served in any form — including by client-side stale-while-revalidate or any local stale-grace policy. (Serving server-returned stale bytes per [Reading a stale entry](#reading-a-stale-entry) is unaffected — this rule governs only the local copy.) The client has no remaining-eviction signal, so a copy served as locally-stale past `fresh_for` could not honor the [`evict_at` service bound](#reading-a-stale-entry). Stale service is the server's job: a subsequent read hits the server, which serves the stale window itself (`X-CacheKit-Freshness: stale`, `X-CacheKit-Fresh-For: 0`) until `evict_at`. A remaining-eviction signal is deliberately not provided — it would let clients replicate the stale window locally, invisibly to server-side revalidation and metering. +- Absent header = pre-signal server: legacy behavior (the SDK's configured local TTL applies unchanged). This makes the header purely additive — old SDKs ignore it, and new SDKs against old servers behave exactly as before. Absence licenses only *fresh* service for that configured lifetime — it never licenses local stale service: the [`evict_at` bound](#reading-a-stale-entry) is unconditional, and without the header the client has no freshness signal at all to ground a stale window on. +- An unparseable or negative value MUST be treated as `0` (do not extend local service — the conservative action, mirroring the unrecognized-`X-CacheKit-Freshness` → `stale` rule). The same applies to any value that is not a plain ASCII-digit integer, or that exceeds 2,592,000 (the [30-day TTL cap](#put-v1cachekey) makes larger values protocol-impossible — a buggy or misconfigured tier, not a real bound). +- Network transit slightly overstates remaining freshness at the client (the value was computed at response time). This is accepted: the error is bounded by transit latency, the same class HTTP `Age` handling tolerates, and is negligible against whole-second granularity. +- The local deadline SHOULD be measured against a clock that keeps counting across system suspend (wall-clock anchored, or a `CLOCK_BOOTTIME`-class monotonic source): a suspend-blind monotonic clock stops while the host sleeps and serves past the bound after resume. This is implementation guidance, not wire contract — the same clock discipline applies to all local TTL accounting. +- An issued `fresh_for` is a snapshot, not a lease the server can recall: a later `DELETE`, or a fresh-window `PATCH /ttl` that shortens the entry, does not reach copies already backfilled — remote local caches compliantly serve until their bounded lifetime expires. Revocation propagation is therefore bounded by the largest locally applied service bound, plus in-flight response transit and clock or suspend error — a `GET` response already in flight when the `DELETE` lands is still backfilled on arrival and served for its full local bound. Security-sensitive caches MUST size TTL (and local TTL) to their revocation tolerance, or version their keys (see the invalidation-race note in [Semantics notes](#semantics-notes)). --- @@ -170,7 +197,7 @@ Authorization: Bearer ck_live_xxx | `200 OK` | Key exists | | `404 Not Found` | Key does not exist | -Servers implementing [stale-while-revalidate](#stale-while-revalidate) emit the same `X-CacheKit-Freshness` response header as `GET`. +Servers implementing [stale-while-revalidate](#stale-while-revalidate) emit the same `X-CacheKit-Freshness` response header as `GET`. `X-CacheKit-Fresh-For` is **not** emitted on `HEAD` ([Remaining Freshness](#remaining-freshness) — no payload, nothing to backfill). --- @@ -228,7 +255,7 @@ On a `200` with `X-CacheKit-Freshness: stale`: - An SDK MUST NOT treat the response as a protocol error. - By default it SHOULD return the bytes to the caller immediately — a stale response is never a blocking miss. - An SDK MAY instead treat a stale hit as a **miss** by local policy (e.g. security-sensitive caches where TTL is a revocation boundary) and take the ordinary synchronous miss path. Such caches SHOULD NOT set `X-CacheKit-Stale-TTL` on write in the first place. -- Local caches (L1) MUST NOT record a stale-flagged response as fresh, and local caching MUST NOT extend service of an entry past the server's `evict_at`. +- Local caches (L1) MUST NOT backfill a stale-flagged response at all — not as fresh, not as locally-stale (servers implementing [Remaining Freshness](#remaining-freshness) mark these `X-CacheKit-Fresh-For: 0`; the rule holds with or without that header) — and local caching MUST NOT extend service of an entry past the server's `evict_at`. For *fresh*-labelled reads near the freshness boundary, the [`X-CacheKit-Fresh-For`](#remaining-freshness) header is the mechanism that lets local caches honor this bound (LAB-557). - Revalidation is triggered only by `GET`. `HEAD` freshness is informational; an existence check MUST NOT fire a background recompute. ### Revalidation flow (SDK)