From 2bc48bc592217902abfb528648baa543b997df3e Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Mon, 31 Aug 2026 04:34:26 +1000 Subject: [PATCH 01/12] feat(saas-api): X-CacheKit-Fresh-For remaining-freshness response header (LAB-557) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET 200s carry the entry's remaining freshness in whole seconds (server-clock delta; 0 on stale-window responses; omitted for no-expiry entries and by pre-signal servers) so SDK local caches can bound L1 backfill to min(local_ttl, fresh_for) instead of restarting the freshness clock at time-of-read. Additive and backward compatible; not emitted on HEAD. Re-serving tiers must decay or omit — never replay an undecayed value beyond their documented coherence window. Origin: CodeRabbit outside-diff finding on cachekit-py#233. --- CHANGELOG.md | 16 ++++++++++++++++ sdk-feature-matrix.md | 1 + spec/saas-api.md | 26 ++++++++++++++++++++++++-- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aeecdc4..f6340bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ 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; omitted for entries with no expiry and 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`). 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). + ### SDK Feature Matrix - Consolidated ten conflicting open matrix PRs into one code-verified end-state 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 f1db967..36be533 100644 --- a/spec/saas-api.md +++ b/spec/saas-api.md @@ -90,6 +90,28 @@ 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 `GET` `200 OK` responses for entries with a freshness bound. 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. +- Omitted for entries with no expiry (no freshness bound exists) and 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 omit the header when the remaining freshness is unknown. It MUST NOT replay an undecayed value beyond its documented coherence window (CacheKit's edge tiers: ≤5 seconds). +- `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)). + +**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 as fresh. +- 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. +- 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). +- 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. --- @@ -170,7 +192,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 +250,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 record a stale-flagged response as fresh, 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) From b95674c30dfc8a6835c0013445cea3647041efc2 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Mon, 31 Aug 2026 05:41:23 +1000 Subject: [PATCH 02/12] spec(saas-api): fresh_for is a hard local service bound, not just a freshness bound (LAB-2531) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit (PR #51): X-CacheKit-Fresh-For reports remaining freshness (fresh_until), not remaining eviction time (evict_at), so an L1 with client-side SWR could serve its copy as locally-stale past the server's evict_at — contradicting the Reading-a-stale-entry bound. Close the gap by prohibiting any local service past fresh_for (and any backfill at fresh_for = 0): stale service is the server's job, which owns the stale window through evict_at. A remaining-eviction signal is deliberately not added — it would let clients replicate the stale window invisibly to server-side revalidation and metering. --- CHANGELOG.md | 6 +++++- spec/saas-api.md | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6340bd..570c8e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,11 @@ All notable changes to the CacheKit Protocol Specification. 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`). Additive and backward compatible: absent header = + window, past `evict_at`). The header value is a hard local service bound: + once elapsed (or `0`), the local copy MUST NOT be served in any form — + client-side stale service is prohibited for header-bounded backfills, 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 diff --git a/spec/saas-api.md b/spec/saas-api.md index 36be533..733af93 100644 --- a/spec/saas-api.md +++ b/spec/saas-api.md @@ -108,7 +108,8 @@ Authorization: Bearer ck_live_xxx **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 as fresh. +- 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. The header signals remaining freshness (`fresh_until`), not remaining eviction time (`evict_at`), so a client that served its copy as locally-stale past `fresh_for` would have no way to 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. - 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). - 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. From baf1ad939102f3c3fd2325d6e461f7cc60db0a89 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Mon, 31 Aug 2026 05:48:31 +1000 Subject: [PATCH 03/12] spec(saas-api): close residual serve-past-evict_at paths found by panel review (LAB-2531) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expert-panel follow-up to the fresh_for hard-service-bound rule: - Reading-a-stale-entry: stale-flagged responses MUST NOT be backfilled at all (not merely 'not as fresh'), with or without Fresh-For — closes the backfill-as-locally-stale reading against Freshness-only servers. - Edge tiers MUST emit Fresh-For: 0 when remaining freshness is unknown, never omit — omission reads as 'no freshness bound' and silently restores the unbounded backfill this header exists to kill. - Header absence licenses fresh service only; local stale service of server-backed entries is prohibited unconditionally. - HEAD MUST NOT create/refresh/extend any local entry's service bound. - Value grammar: non-ASCII-digit or > 2,592,000 (30-day cap) treated as 0, same as unparseable/negative. - Documented revocation propagation: an issued fresh_for is a snapshot; DELETE/PATCH-shorten reaches L1s only within the largest outstanding fresh_for — secure caches size TTL to revocation tolerance. Also disambiguated that serving server-returned stale bytes per Reading-a-stale-entry is unaffected (only the local copy is governed), and cut the clause restating the section preamble. --- CHANGELOG.md | 9 +++++---- spec/saas-api.md | 13 +++++++------ 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 570c8e0..ef9cc6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,10 +15,11 @@ All notable changes to the CacheKit Protocol Specification. 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 (or `0`), the local copy MUST NOT be served in any form — - client-side stale service is prohibited for header-bounded backfills, since - the client has no remaining-eviction signal; the server owns the stale - window through `evict_at`. Additive and backward compatible: absent header = + 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 diff --git a/spec/saas-api.md b/spec/saas-api.md index 733af93..9188c04 100644 --- a/spec/saas-api.md +++ b/spec/saas-api.md @@ -103,16 +103,17 @@ Authorization: Bearer ck_live_xxx - Emitted on `GET` `200 OK` responses for entries with a freshness bound. 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. - Omitted for entries with no expiry (no freshness bound exists) and 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 omit the header when the remaining freshness is unknown. It MUST NOT replay an undecayed value beyond its documented coherence window (CacheKit's edge tiers: ≤5 seconds). -- `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)). +- 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 "no freshness bound" 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 (CacheKit's edge tiers: ≤5 seconds). +- `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. The header signals remaining freshness (`fresh_until`), not remaining eviction time (`evict_at`), so a client that served its copy as locally-stale past `fresh_for` would have no way to 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. -- 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 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. +- 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 therefore propagates to L1s within at most the largest outstanding `fresh_for`. 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)). --- @@ -251,7 +252,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`. 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). +- 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) From 0d0e172861864966513e8b8b28048b01a468c188 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Tue, 1 Sep 2026 09:21:05 +1000 Subject: [PATCH 04/12] =?UTF-8?q?spec(saas-api):=20second=20panel=20round?= =?UTF-8?q?=20=E2=80=94=20drop=20false=20edge-coherence=20figure,=20mandat?= =?UTF-8?q?e=20Cache-Control:=20no-store=20(LAB-2531)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blockers: the deployment-specific '<=5 s' coherence figure contradicted the deployed tiers (L0 5 s + colo 5 s compose to ~10 s, colo hits re-stamp a full undecayed L0 TTL) — replaced with the general rule that coherence windows compound across composed tiers that re-stamp rather than decay. And the spec's first contemplation of intermediary caches had no HTTP cache directives while the cache key carries no tenant: byte-identical URLs across tenants make RFC 9111 heuristic caching a cross-tenant read — servers MUST now emit Cache-Control: no-store on every response, and CacheKit-operated tiers MUST partition internal caches by tenant. Majors: fresh + Fresh-For: 0 documented as legal (final sub-second floors to 0); dead 'no expiry' emission branch removed (TTL is mandatory, so the branch failed open into pre-signal legacy behavior); local deadlines SHOULD use a suspend-counting clock (guidance, not wire contract). --- CHANGELOG.md | 15 ++++++++++++++- spec/saas-api.md | 11 +++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 145af97..56da5a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ All notable changes to the CacheKit Protocol Specification. - **`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; omitted for entries with no expiry and by pre-signal servers), so + 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 @@ -24,6 +25,18 @@ All notable changes to the CacheKit Protocol Specification. [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) diff --git a/spec/saas-api.md b/spec/saas-api.md index 554bbdc..c072d1f 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 @@ -100,10 +102,10 @@ Authorization: Bearer ck_live_xxx **Server (emission):** -- Emitted on `GET` `200 OK` responses for entries with a freshness bound. 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. -- Omitted for entries with no expiry (no freshness bound exists) and 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 "no freshness bound" 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 (CacheKit's edge tiers: ≤5 seconds). +- 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):** @@ -113,6 +115,7 @@ Authorization: Bearer ck_live_xxx - 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 therefore propagates to L1s within at most the largest outstanding `fresh_for`. 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)). --- From 04ce9fd272542fa65252d1bc4a4e2e23ce9782ee Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Wed, 2 Sep 2026 09:35:07 +1000 Subject: [PATCH 05/12] spec(saas-api): revocation bound includes in-flight transit and clock error (LAB-2531) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous sentence claimed revocation reaches L1s within 'at most the largest outstanding fresh_for' — an absolute bound contradicted two bullets earlier (transit overstates remaining freshness) and one bullet earlier (suspend-blind clocks serve past the deadline). A GET already in flight when the DELETE lands is backfilled on arrival and served for its full local bound, so the true bound is the largest applied local bound plus transit plus clock error. Take CodeRabbit's wording, name the in-flight mechanism explicitly. --- spec/saas-api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/saas-api.md b/spec/saas-api.md index c072d1f..c3074e4 100644 --- a/spec/saas-api.md +++ b/spec/saas-api.md @@ -116,7 +116,7 @@ Authorization: Bearer ck_live_xxx - 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 therefore propagates to L1s within at most the largest outstanding `fresh_for`. 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)). +- 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)). --- From 9f1cdea60488247ae44192508b2b653ba7e66ca0 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Thu, 3 Sep 2026 22:26:06 +1000 Subject: [PATCH 06/12] spec(saas-api): revocation bound sums the serving path's coherence windows (LAB-557) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A re-stamping tier may compliantly hand out a copy it cached before the origin DELETE — that copy was never in flight, yet is backfilled for its full local bound. The bound therefore adds the path's compounded coherence windows to the local bound, transit, and clock terms; without it the security-sensitive TTL guidance understated revocation exposure. CodeRabbit finding on protocol#51. --- CHANGELOG.md | 6 +++++- spec/saas-api.md | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56da5a6..2287c41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,11 @@ All notable changes to the CacheKit Protocol Specification. 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. + behavior); local deadlines SHOULD use a suspend-counting clock. The + revocation-propagation bound names the serving path's compounded coherence + windows as a summed term alongside the local bound, transit, and clock error + — a re-stamping tier can hand out a pre-`DELETE` copy that was never in + flight (CodeRabbit finding on protocol#51). ### Wire format — compressed-byte reproducibility scoped per-vector (LAB-1751) diff --git a/spec/saas-api.md b/spec/saas-api.md index c3074e4..80d948d 100644 --- a/spec/saas-api.md +++ b/spec/saas-api.md @@ -116,7 +116,7 @@ Authorization: Bearer ck_live_xxx - 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)). +- 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 **sum** of: the serving path's compounded coherence windows (a re-stamping tier may compliantly hand out a copy it cached before the `DELETE` — see [emission](#remaining-freshness) above — and that copy is then backfilled for its full local bound), the largest locally applied service bound, in-flight response transit, and clock or suspend error — a `GET` response already in flight when the `DELETE` lands is likewise 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)). --- From 2a960c5990041ef044c8bdc5b87066bfcad8d1bc Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Mon, 7 Sep 2026 09:23:21 +1000 Subject: [PATCH 07/12] =?UTF-8?q?fix:=20address=20coderabbit=20review=20?= =?UTF-8?q?=E2=80=94=20drop=20colon=20after=20"sum=20of"=20in=20revocation?= =?UTF-8?q?=20bound?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LanguageTool RP_COLON: no colon before a series introduced by a preposition. Punctuation only; the bound's four terms are unchanged. CodeRabbit-Resolved: spec/saas-api.md:119:Remove the colon after "of" --- spec/saas-api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/saas-api.md b/spec/saas-api.md index 80d948d..b02f7c2 100644 --- a/spec/saas-api.md +++ b/spec/saas-api.md @@ -116,7 +116,7 @@ Authorization: Bearer ck_live_xxx - 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 **sum** of: the serving path's compounded coherence windows (a re-stamping tier may compliantly hand out a copy it cached before the `DELETE` — see [emission](#remaining-freshness) above — and that copy is then backfilled for its full local bound), the largest locally applied service bound, in-flight response transit, and clock or suspend error — a `GET` response already in flight when the `DELETE` lands is likewise 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)). +- 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 **sum** of the serving path's compounded coherence windows (a re-stamping tier may compliantly hand out a copy it cached before the `DELETE` — see [emission](#remaining-freshness) above — and that copy is then backfilled for its full local bound), the largest locally applied service bound, in-flight response transit, and clock or suspend error — a `GET` response already in flight when the `DELETE` lands is likewise 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)). --- From ba8cd72e01007c0b5fb11419ec5502c390a4caf0 Mon Sep 17 00:00:00 2001 From: Mark S Date: Mon, 7 Sep 2026 10:05:50 +1000 Subject: [PATCH 08/12] fix(saas-api): forbid re-stamping positive freshness onto stale/exhausted responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit review: a re-stamping serving tier must not assign a positive X-CacheKit-Fresh-For to a response the tier below marked stale or Fresh-For: 0. Doing so resurrects an expired/revoked entry as locally-cacheable fresh — the unbounded-backfill hole this header exists to close. Re-stamping now explicitly applies only to fresh responses with a positive remaining bound; stale/exhausted responses pass through with stale status and Fresh-For: 0 intact. Addresses CodeRabbit finding on spec/saas-api.md (LAB-557). --- spec/saas-api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/saas-api.md b/spec/saas-api.md index b02f7c2..9b3a865 100644 --- a/spec/saas-api.md +++ b/spec/saas-api.md @@ -105,7 +105,7 @@ Authorization: Bearer ck_live_xxx - 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. +- 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. Re-stamping applies **only** to a response the tier below marked `X-CacheKit-Freshness: fresh` with a positive `X-CacheKit-Fresh-For`: a `stale` response, or any response carrying `X-CacheKit-Fresh-For: 0`, MUST be passed through with its `stale` status and `Fresh-For: 0` intact. A tier MUST NOT stamp a positive remaining-freshness bound onto a stale or exhausted value — doing so resurrects an expired (or revoked) entry as locally-cacheable fresh, the exact unbounded-backfill hole this header exists to close. - `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):** From c2fc531529afac0ebfe5b7d444a159ab106acb45 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Fri, 11 Sep 2026 12:06:34 +1000 Subject: [PATCH 09/12] spec(saas-api): fail-closed Fresh-For rules, mandatory decay, Vary: Authorization (LAB-557, LAB-2531) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third expert-panel round on the remaining-freshness header. Every finding applied; the two design calls are decided here rather than parked: F1 (HIGH) — the re-stamp/backfill rules were an enumeration with three escapes: `fresh` + `Fresh-For: 0` was mislabelled `stale` by the passthrough clause, an unrecognized freshness token with a positive value was undefined, and SDK backfill was not gated on the label. Both sides are now fail-closed: a positive value is legal only on a `fresh`-labelled response that itself carried a positive value; every other shape is emitted as `0` with the label passed through unchanged; SDK backfill requires `fresh` (or absent) — a positive value on `stale` is a server bug, not a licence. F2 — decided past the panel's "clamp to evict_at" option: the header MUST decay and MUST NOT be re-stamped, under any label. A clamp needs an evict_at signal between tiers that the deployed path does not carry; decay needs nothing — saas#325's L0/colo tiers already decay — and it keeps a pre-DELETE copy's local service inside the entry's fresh_until. Bytes coherence (deployment-documented, compounding) is unchanged; the header is now stricter than the bytes. evict_at is stated as the store's bound, with the end-to-end revocation bound kept in Remaining Freshness. F3 — Vary: Authorization joins Cache-Control: no-store as a mandatory response header. One static header; an independent second control against cross-tenant disclosure for a cache that ignores no-store but honours Vary. F4 — value grammar is length-guarded (1–7 ASCII digits) before the range check, so a wrapping fixed-width parse cannot land an over-cap value in range. Minors: coherence window and signal-capable server defined at first use (and stated independent of SWR support); decay floors at 0; unknown remaining freshness is `0`, never omitted; absent-header legacy rule scoped to GET 200 OK; the no-local-stale-service rationale is stated once and cross-referenced from the stale-read rule. --- CHANGELOG.md | 16 ++++++++++++++++ spec/saas-api.md | 24 ++++++++++++++---------- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2287c41..6b344ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,22 @@ All notable changes to the CacheKit Protocol Specification. windows as a summed term alongside the local bound, transit, and clock error — a re-stamping tier can hand out a pre-`DELETE` copy that was never in flight (CodeRabbit finding on protocol#51). +- **Third panel round (LAB-2531 F1–F4).** Re-serving-tier and SDK rules are + now fail-closed rather than enumerated: a tier MUST **decay** the header and + MUST NOT re-stamp it (the deployed tiers already decay — zero implementation + cost, and a pre-`DELETE` copy's local service now ends by the entry's + `fresh_until`); a positive value is legal only on a `fresh`-labelled response + that carried a positive value; every other shape (`stale`, `0` under any + label, unrecognized token, missing header) is emitted as `0` with the + freshness label passed through unchanged; unknown remaining freshness is `0`, + never omitted; SDK backfill is gated on the `fresh` label. `Vary: + Authorization` joins `Cache-Control: no-store` as a mandatory response header + (independent second control against cross-tenant HTTP caching). The value + grammar is length-guarded — 1–7 ASCII digits, checked before the range check, + so a wrapping fixed-width parse cannot land an over-cap value in range. + `evict_at` is stated as the store's bound with the end-to-end revocation + bound in Remaining Freshness; *coherence window* and *signal-capable server* + are defined at first use. ### Wire format — compressed-byte reproducibility scoped per-vector (LAB-1751) diff --git a/spec/saas-api.md b/spec/saas-api.md index 9b3a865..0077fb3 100644 --- a/spec/saas-api.md +++ b/spec/saas-api.md @@ -51,7 +51,7 @@ 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. +**HTTP intermediary caching is prohibited.** Servers MUST emit `Cache-Control: no-store` and `Vary: Authorization` 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. `no-store` forbids storing the response at all; `Vary: Authorization` is the independent second control — a cache that wrongly stores despite `no-store` (or RFC 9111 §3.5's rule for authenticated requests) but honors `Vary` still cannot match tenant A's copy to tenant B's request. 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 these headers govern what they emit, not what they may store. --- @@ -102,21 +102,25 @@ Authorization: Bearer ck_live_xxx **Server (emission):** +- A **signal-capable server** is one that implements this section. It is independent of [stale-while-revalidate](#stale-while-revalidate) support: every entry has a `fresh_until`, so a server may emit `X-CacheKit-Fresh-For` without offering a stale window, and a pre-signal SWR server emits `X-CacheKit-Freshness` without it. - 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. Re-stamping applies **only** to a response the tier below marked `X-CacheKit-Freshness: fresh` with a positive `X-CacheKit-Fresh-For`: a `stale` response, or any response carrying `X-CacheKit-Fresh-For: 0`, MUST be passed through with its `stale` status and `Fresh-For: 0` intact. A tier MUST NOT stamp a positive remaining-freshness bound onto a stale or exhausted value — doing so resurrects an expired (or revoked) entry as locally-cacheable fresh, the exact unbounded-backfill hole this header exists to close. +- Omitted only by pre-signal servers. A re-serving tier fronting a pre-signal store is itself pre-signal and passes the absence through. +- **Re-serving tiers** — an edge or colo cache in front of the store that answers from a copy it read earlier — are part of the server. A tier's **coherence window** is the deployment-documented maximum time it may keep serving a copy after the store has changed or deleted the entry; a tier that refreshes its copy's lifetime from the tier below rather than from the store adds its window to the path's total, so windows **compound** along the serving path and a deployment's effective window is their sum, not its largest single tier. That governs the *bytes*. The *header* follows stricter, fail-closed rules: + - **Decay, never re-stamp.** A tier MUST emit the remaining freshness it received (or, for a copy it populated from a write, that write's own freshness bound) minus the seconds elapsed since, flooring at `0`. It MUST NOT emit a value larger than that decayed remainder — its own copy TTL included — under any label. The deployed tiers decay, so the rule costs a conforming implementation nothing, and it keeps a pre-`DELETE` copy's local service inside the entry's `fresh_until`. + - **Unknown means `0`, never absent.** When a tier does not know the remaining freshness — a copy populated without a hint — it MUST emit `X-CacheKit-Fresh-For: 0`. It MUST NOT omit a header it received, nor drop it for such a copy: omission means "pre-signal server" to the client and silently restores the unbounded backfill this header exists to kill. + - **Positive only for `fresh` + positive.** A tier MAY emit a positive value only on a response the tier below labelled exactly `X-CacheKit-Freshness: fresh` (or left unlabelled — the pre-SWR `fresh` default) and stamped with a strictly positive `X-CacheKit-Fresh-For`, and then only the decayed remainder. Every other shape — `stale`, `X-CacheKit-Fresh-For: 0` under any label, an unrecognized freshness token, or a missing `X-CacheKit-Fresh-For` — MUST be emitted with `X-CacheKit-Fresh-For: 0` and its freshness label passed through unchanged (a `fresh` `0` stays `fresh`; a `stale` stays `stale`). Stamping a positive bound onto a stale, exhausted, or unknown value resurrects an expired (or revoked) entry as locally-cacheable fresh — the exact hole this header closes. - `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). +- On a `GET` `200 OK` labelled `X-CacheKit-Freshness: fresh` (or unlabelled) 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. A `stale` or unrecognized freshness label forbids backfill regardless of the number ([Reading a stale entry](#reading-a-stale-entry)) — a positive `X-CacheKit-Fresh-For` on a `stale` response is a server bug, not a license. +- 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.) **Why there is no local stale service, stated once:** the client receives no remaining-eviction signal, so a locally-stale copy could not honor the store's [`evict_at` bound](#reading-a-stale-entry) — and one is deliberately not provided, because it would let clients replicate the stale window locally, invisibly to server-side revalidation single-flight and metering. 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`. Rules elsewhere refer back here rather than restating this. +- Absent header on a `GET` `200 OK` = 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, never local stale service (above). +- The value MUST be 1–7 ASCII digits and at most `2,592,000` (the [30-day TTL cap](#put-v1cachekey), itself seven digits). Anything else — empty, non-digit, signed, longer than seven digits, or over the cap — MUST be treated as `0` (do not extend local service — the conservative action, mirroring the unrecognized-`X-CacheKit-Freshness` → `stale` rule); a larger value is protocol-impossible, a buggy or misconfigured tier rather than a real bound. The length check MUST run first, so the range check never depends on a fixed-width integer conversion that could wrap an over-cap value back into range (a wrapping `atoi`/`strtoul` turns `4297559296` into `2,592,000`). - 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 **sum** of the serving path's compounded coherence windows (a re-stamping tier may compliantly hand out a copy it cached before the `DELETE` — see [emission](#remaining-freshness) above — and that copy is then backfilled for its full local bound), the largest locally applied service bound, in-flight response transit, and clock or suspend error — a `GET` response already in flight when the `DELETE` lands is likewise 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)). +- 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, and a re-serving tier may compliantly hand out a copy it cached before the `DELETE` for the rest of its coherence window ([emission](#remaining-freshness) above). `evict_at` is therefore the **store's** service bound, not an end-to-end one. Revocation propagation is bounded by the **sum** of the serving path's compounded coherence windows, the largest locally applied service bound (decayed, so it ends no later than the served entry's `fresh_until`), in-flight response transit, and clock or suspend error — a `GET` response already in flight when the `DELETE` lands is likewise still backfilled on arrival. 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)). --- @@ -224,7 +228,7 @@ evict_at = fresh_until + stale_ttl | :--- | :--- | | `now < fresh_until` | `200 OK`, `X-CacheKit-Freshness: fresh` | | `fresh_until ≤ now < evict_at` | `200 OK` **with the stored bytes**, `X-CacheKit-Freshness: stale` | -| `now ≥ evict_at` | `404 Not Found`. The server MUST NOT serve an entry past `evict_at`. | +| `now ≥ evict_at` | `404 Not Found`. The store MUST NOT serve an entry past `evict_at`. This is the **store's** bound: copies already handed to re-serving tiers or backfilled into local caches run to their own bounded lifetimes — the end-to-end revocation bound is in [Remaining Freshness](#remaining-freshness). | All lifecycle times are computed against the **server's clock**; SDKs MUST NOT derive freshness for backed entries from their own clocks. @@ -255,7 +259,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 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). +- Local caches (L1) MUST NOT backfill a stale-flagged response at all — not as fresh, not as locally-stale — regardless of any `X-CacheKit-Fresh-For` value (signal-capable servers mark these `0`; the rule holds with or without the header, and the rationale is stated once under [Remaining Freshness](#remaining-freshness)). Local caching MUST NOT extend service of an entry past the store's `evict_at`; for *fresh*-labelled reads near the freshness boundary, [`X-CacheKit-Fresh-For`](#remaining-freshness) 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) From b62dfa059be4b4e7885b28932076636e93855290 Mon Sep 17 00:00:00 2001 From: 27Bslash6 Date: Sun, 13 Sep 2026 13:12:21 +1000 Subject: [PATCH 10/12] fix(saas-api): scope local evict_at rule to header-present responses (LAB-2531) CodeRabbit round five on protocol#51 (c2fc531): - spec/saas-api.md 'Reading a stale entry': the local-cache 'MUST NOT extend service past evict_at' rule contradicted the legacy absent-header rule in Remaining Freshness (a pre-signal response keeps the configured local TTL, which can outlive evict_at). Scope the MUST NOT to responses carrying X-CacheKit-Fresh-For and name the pre-signal case as the origin gap the header closes. No behaviour change: line 119 already governed absence. - CHANGELOG third-round entry said positive values and SDK backfill require the 'fresh' label; the spec accepts 'fresh' OR unlabelled (pre-SWR default). Align the summary so a literal reader does not emit 0 / skip backfill on valid unlabelled signal responses. Record the evict_at scoping in the same entry. --- CHANGELOG.md | 21 ++++++++++++--------- spec/saas-api.md | 2 +- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b344ef..3a4bff8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,18 +45,21 @@ All notable changes to the CacheKit Protocol Specification. now fail-closed rather than enumerated: a tier MUST **decay** the header and MUST NOT re-stamp it (the deployed tiers already decay — zero implementation cost, and a pre-`DELETE` copy's local service now ends by the entry's - `fresh_until`); a positive value is legal only on a `fresh`-labelled response - that carried a positive value; every other shape (`stale`, `0` under any + `fresh_until`); a positive value is legal only on a `fresh`-labelled (or + unlabelled — the pre-SWR `fresh` default) response that carried a positive + value; every other shape (`stale`, `0` under any label, unrecognized token, missing header) is emitted as `0` with the freshness label passed through unchanged; unknown remaining freshness is `0`, - never omitted; SDK backfill is gated on the `fresh` label. `Vary: - Authorization` joins `Cache-Control: no-store` as a mandatory response header - (independent second control against cross-tenant HTTP caching). The value - grammar is length-guarded — 1–7 ASCII digits, checked before the range check, - so a wrapping fixed-width parse cannot land an over-cap value in range. + never omitted; SDK backfill is gated on the `fresh` (or unlabelled) label. + `Vary: Authorization` joins `Cache-Control: no-store` as a mandatory response + header (independent second control against cross-tenant HTTP caching). The + value grammar is length-guarded — 1–7 ASCII digits, checked before the range + check, so a wrapping fixed-width parse cannot land an over-cap value in range. `evict_at` is stated as the store's bound with the end-to-end revocation - bound in Remaining Freshness; *coherence window* and *signal-capable server* - are defined at first use. + bound in Remaining Freshness, and the local-cache "never past `evict_at`" + rule is scoped to responses that carry the header — a pre-signal response + keeps the legacy local TTL, which is the origin gap the header closes; + *coherence window* and *signal-capable server* are defined at first use. ### Wire format — compressed-byte reproducibility scoped per-vector (LAB-1751) diff --git a/spec/saas-api.md b/spec/saas-api.md index 0077fb3..6db9cdd 100644 --- a/spec/saas-api.md +++ b/spec/saas-api.md @@ -259,7 +259,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 backfill a stale-flagged response at all — not as fresh, not as locally-stale — regardless of any `X-CacheKit-Fresh-For` value (signal-capable servers mark these `0`; the rule holds with or without the header, and the rationale is stated once under [Remaining Freshness](#remaining-freshness)). Local caching MUST NOT extend service of an entry past the store's `evict_at`; for *fresh*-labelled reads near the freshness boundary, [`X-CacheKit-Fresh-For`](#remaining-freshness) is the mechanism that lets local caches honor this bound (LAB-557). +- Local caches (L1) MUST NOT backfill a stale-flagged response at all — not as fresh, not as locally-stale — regardless of any `X-CacheKit-Fresh-For` value (signal-capable servers mark these `0`; the rule holds with or without the header, and the rationale is stated once under [Remaining Freshness](#remaining-freshness)). On a response carrying [`X-CacheKit-Fresh-For`](#remaining-freshness), local caching MUST NOT extend service of an entry past the store's `evict_at` — for *fresh*-labelled reads near the freshness boundary, the header is the mechanism that lets local caches honor this bound (LAB-557). A response without it comes from a pre-signal server and follows the legacy absence rule in that section: the configured local TTL applies unchanged and may outlive `evict_at` — the origin gap the header exists to close. - Revalidation is triggered only by `GET`. `HEAD` freshness is informational; an existence check MUST NOT fire a background recompute. ### Revalidation flow (SDK) From 481b1b842beaa5c93d173ec7276676726bb678bc Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Mon, 14 Sep 2026 09:35:29 +1000 Subject: [PATCH 11/12] spec(saas-api): admit no-expiry entries; Fresh-For omitted for them, GET /ttl returns null (LAB-557) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ray's ruling on the Feature Design review (option A, LAB-557 2026-09-13): admit immortal entries and amend the spec to match shipped reality. The spec asserted "there is no no-expiry entry" on the strength of a tenant-default TTL that no server implements — saas has stored `expires_at NULL` for an omitted X-CacheKit-TTL since day one, and Redis, Memcached and File all admit unbounded entries. Three rounds of this PR built rules on that fiction. - PUT: omitted X-CacheKit-TTL means no expiry (no fresh_until, no evict_at, served fresh until deleted or overwritten, never stale). Servers MUST NOT substitute a hidden default — the existing rule that validation must not depend on defaults clients cannot see. - Remaining Freshness: the header is omitted for no-expiry entries. The server bound is unbounded, so min(local_ttl, ∞) is the SDK's configured local TTL — exactly the absent-header path, so "no-expiry" and "pre-signal server" are indistinguishable to the client by design and produce the same action. - Re-serving tiers: "unknown means 0, never absent" splits into "no bound → omit, mirroring the store" and "bound unknown → 0". A tier MUST record at populate whether a copy is unbounded or merely unhinted; one that cannot tell MUST emit 0. Matches what saas#350 already does after Helly R's review. - GET /v1/cache/{key}/ttl: a no-expiry key returns 200 {"ttl": null}, never 404 (the key exists) and never a negative sentinel. null was chosen over Redis's -1 because the field is typed as seconds and all three SDKs already model no expiry as null/None/Option::None — ts types the body `{ ttl: number | null }`, rs has `TtlResponse { ttl: Option }` with a null-parsing test, py returns the JSON value with "None = no expiry" in its docstring. PATCH /ttl on a no-expiry entry bounds it. - The 30-day maximum is restated as a bound on a stated TTL's value range, not a storage-lifetime ceiling — a client could never ask for 31 days but always got forever by omitting the header; the rationale now says so. CHANGELOG entries for the earlier rounds are corrected in place (they are unreleased) rather than contradicted by a new one. --- CHANGELOG.md | 29 ++++++++++++++++++++++------- spec/saas-api.md | 27 +++++++++++++++------------ 2 files changed, 37 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a4bff8..6176536 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,9 @@ All notable changes to the CacheKit Protocol Specification. - **`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 + responses; emitted on every `GET` `200 OK` for a bounded entry; omitted for + no-expiry entries and by pre-signal servers — both mean "no server bound", + same SDK action), 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 @@ -34,9 +35,8 @@ All notable changes to the CacheKit Protocol Specification. 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. The + (final sub-second floors to `0` — serve, don't backfill); local deadlines + SHOULD use a suspend-counting clock. The revocation-propagation bound names the serving path's compounded coherence windows as a summed term alongside the local bound, transit, and clock error — a re-stamping tier can hand out a pre-`DELETE` copy that was never in @@ -49,8 +49,8 @@ All notable changes to the CacheKit Protocol Specification. unlabelled — the pre-SWR `fresh` default) response that carried a positive value; every other shape (`stale`, `0` under any label, unrecognized token, missing header) is emitted as `0` with the - freshness label passed through unchanged; unknown remaining freshness is `0`, - never omitted; SDK backfill is gated on the `fresh` (or unlabelled) label. + freshness label passed through unchanged; a bounded entry whose remainder a + tier has lost is `0`, never omitted; SDK backfill is gated on the `fresh` (or unlabelled) label. `Vary: Authorization` joins `Cache-Control: no-store` as a mandatory response header (independent second control against cross-tenant HTTP caching). The value grammar is length-guarded — 1–7 ASCII digits, checked before the range @@ -60,6 +60,21 @@ All notable changes to the CacheKit Protocol Specification. rule is scoped to responses that carry the header — a pre-signal response keeps the legacy local TTL, which is the origin gap the header closes; *coherence window* and *signal-capable server* are defined at first use. +- **No-expiry entries admitted (LAB-557 — Ray's ruling on the Feature Design + review).** The spec described a tenant-default TTL that no server + implements: saas has stored `expires_at NULL` for an omitted + `X-CacheKit-TTL` since day one, and Redis, Memcached and File all admit + unbounded entries. `PUT` without `X-CacheKit-TTL` now means **no expiry** — + no `fresh_until`, no `evict_at`, served `fresh` until deleted or + overwritten, never stale — and servers MUST NOT substitute a hidden default. + `X-CacheKit-Fresh-For` is omitted for such entries (the server bound is + unbounded, so the SDK's configured local TTL — the absent-header path — is + the right bound), and the re-serving-tier rule splits into "no bound → + omit, mirroring the store" and "bound unknown → `0`"; a tier that cannot + tell the two apart MUST emit `0`. `GET /v1/cache/{key}/ttl` returns + `200 {"ttl": null}` for a no-expiry key — never `404`, never a negative + sentinel — and `PATCH /ttl` bounds it. The 30-day maximum is restated as a + bound on a stated TTL's value range, not a storage-lifetime ceiling. ### Wire format — compressed-byte reproducibility scoped per-vector (LAB-1751) diff --git a/spec/saas-api.md b/spec/saas-api.md index 6db9cdd..996deeb 100644 --- a/spec/saas-api.md +++ b/spec/saas-api.md @@ -102,21 +102,21 @@ Authorization: Bearer ck_live_xxx **Server (emission):** -- A **signal-capable server** is one that implements this section. It is independent of [stale-while-revalidate](#stale-while-revalidate) support: every entry has a `fresh_until`, so a server may emit `X-CacheKit-Fresh-For` without offering a stale window, and a pre-signal SWR server emits `X-CacheKit-Freshness` without it. -- 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. +- A **signal-capable server** is one that implements this section. It is independent of [stale-while-revalidate](#stale-while-revalidate) support: a bounded entry has a `fresh_until` whether or not it has a stale window, so a server may emit `X-CacheKit-Fresh-For` without offering one, and a pre-signal SWR server emits `X-CacheKit-Freshness` without it. +- Emitted on **every** `GET` `200 OK` for an entry that has a freshness bound. An entry stored without `X-CacheKit-TTL` has **no expiry** ([PUT](#put-v1cachekey)): no `fresh_until`, no remainder to report, so the header is **omitted** — the server-side bound is unbounded, `min(local_ttl, ∞)` is the SDK's configured local TTL, and that is exactly the absent-header path below. When present, 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 re-serving tier fronting a pre-signal store is itself pre-signal and passes the absence through. +- Omitted for no-expiry entries and by pre-signal servers. The client cannot tell the two apart, by design: both mean "no server-side freshness bound applies to this read" and both lead to the same SDK action (configured local TTL, fresh service only). A re-serving tier fronting a pre-signal store is itself pre-signal and passes the absence through. - **Re-serving tiers** — an edge or colo cache in front of the store that answers from a copy it read earlier — are part of the server. A tier's **coherence window** is the deployment-documented maximum time it may keep serving a copy after the store has changed or deleted the entry; a tier that refreshes its copy's lifetime from the tier below rather than from the store adds its window to the path's total, so windows **compound** along the serving path and a deployment's effective window is their sum, not its largest single tier. That governs the *bytes*. The *header* follows stricter, fail-closed rules: - **Decay, never re-stamp.** A tier MUST emit the remaining freshness it received (or, for a copy it populated from a write, that write's own freshness bound) minus the seconds elapsed since, flooring at `0`. It MUST NOT emit a value larger than that decayed remainder — its own copy TTL included — under any label. The deployed tiers decay, so the rule costs a conforming implementation nothing, and it keeps a pre-`DELETE` copy's local service inside the entry's `fresh_until`. - - **Unknown means `0`, never absent.** When a tier does not know the remaining freshness — a copy populated without a hint — it MUST emit `X-CacheKit-Fresh-For: 0`. It MUST NOT omit a header it received, nor drop it for such a copy: omission means "pre-signal server" to the client and silently restores the unbounded backfill this header exists to kill. - - **Positive only for `fresh` + positive.** A tier MAY emit a positive value only on a response the tier below labelled exactly `X-CacheKit-Freshness: fresh` (or left unlabelled — the pre-SWR `fresh` default) and stamped with a strictly positive `X-CacheKit-Fresh-For`, and then only the decayed remainder. Every other shape — `stale`, `X-CacheKit-Fresh-For: 0` under any label, an unrecognized freshness token, or a missing `X-CacheKit-Fresh-For` — MUST be emitted with `X-CacheKit-Fresh-For: 0` and its freshness label passed through unchanged (a `fresh` `0` stays `fresh`; a `stale` stays `stale`). Stamping a positive bound onto a stale, exhausted, or unknown value resurrects an expired (or revoked) entry as locally-cacheable fresh — the exact hole this header closes. + - **No bound → omit, mirroring the store; bound unknown → `0`.** A tier serving a copy of a no-expiry entry omits the header, exactly as the store does. A tier holding a copy of a *bounded* entry whose remainder it has lost — populated without a hint — MUST emit `X-CacheKit-Fresh-For: 0`; it MUST NOT omit a header it received, nor drop it for such a copy: omission would claim "no bound" for an entry that has one and silently restore the unbounded backfill this header exists to kill. A tier MUST therefore record at populate whether a copy is unbounded or merely unhinted; a tier that cannot tell the two apart MUST emit `0`. + - **Positive only for `fresh` + positive.** A tier MAY emit a positive value only on a response the tier below labelled exactly `X-CacheKit-Freshness: fresh` (or left unlabelled — the pre-SWR `fresh` default) and stamped with a strictly positive `X-CacheKit-Fresh-For`, and then only the decayed remainder. Every other shape — `stale`, `X-CacheKit-Fresh-For: 0` under any label, an unrecognized freshness token, or a missing `X-CacheKit-Fresh-For` on a bounded entry — MUST be emitted with `X-CacheKit-Fresh-For: 0` and its freshness label passed through unchanged (a `fresh` `0` stays `fresh`; a `stale` stays `stale`). Stamping a positive bound onto a stale, exhausted, or unknown value resurrects an expired (or revoked) entry as locally-cacheable fresh — the exact hole this header closes. - `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 `GET` `200 OK` labelled `X-CacheKit-Freshness: fresh` (or unlabelled) 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. A `stale` or unrecognized freshness label forbids backfill regardless of the number ([Reading a stale entry](#reading-a-stale-entry)) — a positive `X-CacheKit-Fresh-For` on a `stale` response is a server bug, not a license. - 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.) **Why there is no local stale service, stated once:** the client receives no remaining-eviction signal, so a locally-stale copy could not honor the store's [`evict_at` bound](#reading-a-stale-entry) — and one is deliberately not provided, because it would let clients replicate the stale window locally, invisibly to server-side revalidation single-flight and metering. 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`. Rules elsewhere refer back here rather than restating this. -- Absent header on a `GET` `200 OK` = 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, never local stale service (above). +- Absent header on a `GET` `200 OK` = no server-side freshness bound for this read — a no-expiry entry, or a 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, never local stale service (above). - The value MUST be 1–7 ASCII digits and at most `2,592,000` (the [30-day TTL cap](#put-v1cachekey), itself seven digits). Anything else — empty, non-digit, signed, longer than seven digits, or over the cap — MUST be treated as `0` (do not extend local service — the conservative action, mirroring the unrecognized-`X-CacheKit-Freshness` → `stale` rule); a larger value is protocol-impossible, a buggy or misconfigured tier rather than a real bound. The length check MUST run first, so the range check never depends on a fixed-width integer conversion that could wrap an over-cap value back into range (a wrapping `atoi`/`strtoul` turns `4297559296` into `2,592,000`). - 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. @@ -140,7 +140,7 @@ X-CacheKit-TTL: 3600 | Header | Required | Description | | :--- | :---: | :--- | -| `X-CacheKit-TTL` | No | Time-to-live in seconds. Positive integer, minimum 1, maximum 2,592,000 (30 days). Omit to use server default. | +| `X-CacheKit-TTL` | No | Time-to-live in seconds. Positive integer, minimum 1, maximum 2,592,000 (30 days). Omit for **no expiry** — the entry has no `fresh_until` and no `evict_at`, is served `fresh` until deleted or overwritten, and carries no `X-CacheKit-Fresh-For`; servers MUST NOT substitute a hidden default. | | `X-CacheKit-Stale-TTL` | No | Stale-grace window in seconds after freshness expiry. Requires an explicit `X-CacheKit-TTL` on the same request. Validation and semantics: [Stale-While-Revalidate](#stale-while-revalidate). Pre-SWR servers ignore this header. | > [!IMPORTANT] @@ -148,14 +148,14 @@ X-CacheKit-TTL: 3600 > > | Condition | SDK Behavior | Server Behavior | > | :--- | :--- | :--- | -> | TTL omitted | Use client default TTL. If no client default, omit `X-CacheKit-TTL` header. | Apply tenant default TTL. | +> | TTL omitted | Use client default TTL. If no client default, omit `X-CacheKit-TTL` header. | Store with **no expiry**. Servers MUST NOT apply a hidden tenant default — validation must not depend on defaults clients cannot see (the same rule as `X-CacheKit-Stale-TTL`). | > | TTL = 0 | **Reject** — return error to caller. Zero is not a valid TTL. | **Reject** — return `400 Bad Request`. | > | TTL < 1 second | **Round up to 1.** Sub-second durations MUST be ceiled, never truncated to 0. | N/A (header is integer seconds). | > | TTL > 2,592,000 | **Reject** — return error to caller. | **Reject** — return `400 Bad Request`. | > | TTL negative | **Reject** — return error to caller. | **Reject** — return `400 Bad Request`. | > | TTL non-integer | N/A (SDK converts duration to integer seconds). | **Reject** — return `400 Bad Request`. | > -> **Rationale:** TTL=0 is ambiguous across cache systems (Redis rejects it, Memcached treats it as "never expire", HTTP treats it as "immediately stale"). CacheKit defines TTL=0 as an error to prevent silent data loss or unbounded storage. Sub-second durations are ceiled to 1 rather than truncated to 0 to avoid the same ambiguity. The 30-day maximum prevents unbounded storage accumulation; longer-lived entries should use explicit renewal patterns via `PATCH /v1/cache/{key}/ttl`. +> **Rationale:** TTL=0 is ambiguous across cache systems (Redis rejects it, Memcached treats it as "never expire", HTTP treats it as "immediately stale"). CacheKit defines TTL=0 as an error to prevent silent data loss (the Redis and HTTP readings) or an unintended no-expiry entry (the Memcached reading) — no expiry is requested by omitting the header, never by `0`. Sub-second durations are ceiled to 1 rather than truncated to 0 to avoid the same ambiguity. The 30-day maximum bounds the *value range* of a stated TTL (and with it the [`X-CacheKit-Fresh-For`](#remaining-freshness) grammar); it is not a storage-lifetime ceiling — omitting the header stores a no-expiry entry. Entries that need a long but bounded life should renew explicitly via `PATCH /v1/cache/{key}/ttl`. > > **Migration:** The `X-TTL` header is deprecated. The server MUST accept both `X-CacheKit-TTL` and `X-TTL` during the transition period, preferring `X-CacheKit-TTL` when both are present. SDKs MUST send `X-CacheKit-TTL` only. The `X-TTL` header will be removed in protocol version 2.0 (targeted at SDK 1.0 milestone). @@ -234,6 +234,8 @@ All lifecycle times are computed against the **server's clock**; SDKs MUST NOT d Without `X-CacheKit-Stale-TTL` (or with `0`), `evict_at = fresh_until` and server behavior is identical to the pre-SWR protocol. +Without `X-CacheKit-TTL`, the entry has **no expiry**: no `fresh_until`, no `evict_at`. It is served `200 OK` `fresh` until deleted or overwritten, never enters the stale window, and carries no `X-CacheKit-Fresh-For` ([Remaining Freshness](#remaining-freshness)). `X-CacheKit-Stale-TTL` without `X-CacheKit-TTL` is rejected ([validation](#validation)) — an entry that never expires has nothing to be stale relative to. + ### Validation These rules are normative for both SDKs and the SaaS backend, mirroring the [TTL validation rules](#put-v1cachekey): @@ -285,7 +287,7 @@ An SDK that serves a stale hit and owns revalidation (the recompute is the wrapp - **Metering:** a stale-window `GET` is a cache **hit** (`200`) for metered-misses billing; the revalidation `PUT` is an ordinary write. In the [SDK metrics headers](#optional-metrics-headers), a stale serve increments `X-CacheKit-L2-Hits`; a background revalidation MUST NOT increment `X-CacheKit-Misses`. - **Invalidation race:** an explicit `DELETE /v1/cache/{key}` concurrent with an in-flight revalidation may be overwritten by the revalidation `PUT` (last-write-wins) — the same race as today's concurrent miss-path recompute. Callers that need durable invalidation must version their keys. - **Zero-knowledge:** no change to the wire format, ByteStorage envelope, encryption, or AAD; the value bytes remain opaque. -- **`GET /v1/cache/{key}/ttl`:** the returned `ttl` is the remaining seconds until **eviction** (`evict_at`). +- **`GET /v1/cache/{key}/ttl`:** the returned `ttl` is the remaining seconds until **eviction** (`evict_at`), or `null` for a no-expiry entry. - **Compatibility:** additive for servers — a pre-SWR server ignores `X-CacheKit-Stale-TTL` (the entry evicts at `fresh_until`, no freshness header is emitted) and SDK behavior is exactly pre-SWR. It is **not** transparent to mixed readers: enabling `stale_ttl` on a key affects every reader of that key, and a pre-SWR SDK will consume stale-window values as fresh (`200`, no header) where it previously saw a miss. Deployments MUST NOT enable `stale_ttl` on keys whose readers rely on hard TTL expiry (pre-SWR SDKs or security-sensitive consumers). --- @@ -357,11 +359,12 @@ X-CacheKit-Lock-Id: uuid-string ### GET /v1/cache/{key}/ttl -Get remaining TTL for a key. The returned `ttl` is the remaining seconds until **eviction** — for entries with a [stale-grace window](#stale-while-revalidate), that is `evict_at`, not `fresh_until`. +Get remaining TTL for a key. The returned `ttl` is the remaining seconds until **eviction** — for entries with a [stale-grace window](#stale-while-revalidate), that is `evict_at`, not `fresh_until`. A **no-expiry** entry ([PUT](#put-v1cachekey)) returns `200 OK` with `{"ttl": null}`: the key exists, so `404` MUST NOT be returned for it, and `null` — not a negative sentinel — is the representation, because the field is typed as seconds and every SDK already models no expiry as its null / `None` / `Option::None`. SDKs MUST accept `null` and surface it as their no-expiry value. | Status | Meaning | Response Body | | :---: | :--- | :--- | | `200 OK` | TTL returned | `{"ttl": 3542}` | +| `200 OK` | Key exists with no expiry | `{"ttl": null}` | | `404 Not Found` | Key does not exist | — | --- @@ -379,7 +382,7 @@ Content-Type: application/json {"ttl": 7200} ``` -The `ttl` field follows the same validation rules as `X-CacheKit-TTL`: positive integer, minimum 1, maximum 2,592,000. For entries stored with a stale-grace window, see [SWR write semantics](#write-semantics): a PATCH within the fresh window renews it; a PATCH on a stale entry MUST return `409 Conflict`. +The `ttl` field follows the same validation rules as `X-CacheKit-TTL`: positive integer, minimum 1, maximum 2,592,000. For entries stored with a stale-grace window, see [SWR write semantics](#write-semantics): a PATCH within the fresh window renews it; a PATCH on a stale entry MUST return `409 Conflict`. A `PATCH` on a no-expiry entry is within the fresh window by definition and bounds it: `fresh_until = now + ttl`, no stale window. | Status | Meaning | | :---: | :--- | From 78f429620bad0307585b515bde216a5e0cf4e4ec Mon Sep 17 00:00:00 2001 From: 27Bslash6 Date: Mon, 14 Sep 2026 09:57:20 +1000 Subject: [PATCH 12/12] spec(saas-api): fail-closed tier omission, honest no-expiry revocation bound (LAB-2531) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Panel round on 481b1b8 (Mark S + CodeRabbit r4001310558), no-expiry follow-through: B1 — lines 108/111 stated two MUSTs for one situation: a tier fronting a pre-signal store 'passes the absence through' (omit) vs a tier that 'cannot tell unbounded from unhinted MUST emit 0'. Omitting lets the SDK apply its full local TTL to a bounded entry — the origin gap. Rule is now single and fail-closed: a tier MAY omit only on positive knowledge of no expiry (a signal-capable store below omitted, or the copy came from a write with no TTL); everything else, a pre-signal store below included, is 0. Matches saas#350, where tiers omit only when the DO reported a null bound. B2 — line 123 claimed the local revocation term is 'decayed, so it ends no later than fresh_until'. A no-expiry entry has no fresh_until; its local term is the full configured local TTL, re-anchored per read, with no server ceiling. Stated honestly, incl. the formerly-no-expiry PATCH/DELETE case, and revocation-sensitive keys MUST carry an explicit X-CacheKit-TTL. MEDs — GET /ttl null is not transparent to pre-null readers (mixed-reader rule added, mirroring stale_ttl); revalidation PUT MUST re-send TTL or it immortalizes a bounded key; the 30-day cap rationale says storage accumulation is unbounded by this spec (LAB-279). One-line note: a mispartitioned no-expiry copy never self-heals. CHANGELOG no-expiry bullet corrected in place (unreleased). --- CHANGELOG.md | 22 ++++++++++++++++------ spec/saas-api.md | 18 +++++++++--------- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6176536..3a28071 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,12 +69,22 @@ All notable changes to the CacheKit Protocol Specification. overwritten, never stale — and servers MUST NOT substitute a hidden default. `X-CacheKit-Fresh-For` is omitted for such entries (the server bound is unbounded, so the SDK's configured local TTL — the absent-header path — is - the right bound), and the re-serving-tier rule splits into "no bound → - omit, mirroring the store" and "bound unknown → `0`"; a tier that cannot - tell the two apart MUST emit `0`. `GET /v1/cache/{key}/ttl` returns - `200 {"ttl": null}` for a no-expiry key — never `404`, never a negative - sentinel — and `PATCH /ttl` bounds it. The 30-day maximum is restated as a - bound on a stated TTL's value range, not a storage-lifetime ceiling. + the right bound), and the re-serving-tier rule becomes "omit only on + positive knowledge of no expiry; otherwise `0`": a tier may omit only when + a signal-capable store below omitted, or it populated the copy from a write + with no TTL; a tier fronting a pre-signal store emits `0` rather than + passing the absence through (panel round on this commit — the pass-through + re-opened the origin gap for bounded entries behind such a tier). `GET + /v1/cache/{key}/ttl` returns `200 {"ttl": null}` for a no-expiry key — + never `404`, never a negative sentinel — with a mixed-reader caveat for SDKs + that predate `null`; `PATCH /ttl` bounds it. The revocation bound is stated + for no-expiry entries (no `fresh_until`, so the local term is the full + configured local TTL with no server ceiling), revocation-sensitive keys MUST + carry an explicit `X-CacheKit-TTL`, and a revalidation `PUT` MUST re-send + the TTL as well as the stale window or it immortalizes a bounded key. The + 30-day maximum is restated as a bound on a stated TTL's value range, not a + storage-lifetime ceiling — accumulation of no-expiry entries is unbounded by + this spec (hygiene tracked as LAB-279). ### Wire format — compressed-byte reproducibility scoped per-vector (LAB-1751) diff --git a/spec/saas-api.md b/spec/saas-api.md index 996deeb..f8a7dca 100644 --- a/spec/saas-api.md +++ b/spec/saas-api.md @@ -105,11 +105,11 @@ Authorization: Bearer ck_live_xxx - A **signal-capable server** is one that implements this section. It is independent of [stale-while-revalidate](#stale-while-revalidate) support: a bounded entry has a `fresh_until` whether or not it has a stale window, so a server may emit `X-CacheKit-Fresh-For` without offering one, and a pre-signal SWR server emits `X-CacheKit-Freshness` without it. - Emitted on **every** `GET` `200 OK` for an entry that has a freshness bound. An entry stored without `X-CacheKit-TTL` has **no expiry** ([PUT](#put-v1cachekey)): no `fresh_until`, no remainder to report, so the header is **omitted** — the server-side bound is unbounded, `min(local_ttl, ∞)` is the SDK's configured local TTL, and that is exactly the absent-header path below. When present, 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 for no-expiry entries and by pre-signal servers. The client cannot tell the two apart, by design: both mean "no server-side freshness bound applies to this read" and both lead to the same SDK action (configured local TTL, fresh service only). A re-serving tier fronting a pre-signal store is itself pre-signal and passes the absence through. +- Omitted by the store for no-expiry entries, and by pre-signal servers for everything. The client cannot tell the two apart, by design: both mean "no server-side freshness bound applies to this read" and both lead to the same SDK action (configured local TTL, fresh service only). Because absence carries that meaning, a tier that implements this section MUST NOT produce it for a copy it cannot positively confirm has no expiry — the tier rule below. - **Re-serving tiers** — an edge or colo cache in front of the store that answers from a copy it read earlier — are part of the server. A tier's **coherence window** is the deployment-documented maximum time it may keep serving a copy after the store has changed or deleted the entry; a tier that refreshes its copy's lifetime from the tier below rather than from the store adds its window to the path's total, so windows **compound** along the serving path and a deployment's effective window is their sum, not its largest single tier. That governs the *bytes*. The *header* follows stricter, fail-closed rules: - **Decay, never re-stamp.** A tier MUST emit the remaining freshness it received (or, for a copy it populated from a write, that write's own freshness bound) minus the seconds elapsed since, flooring at `0`. It MUST NOT emit a value larger than that decayed remainder — its own copy TTL included — under any label. The deployed tiers decay, so the rule costs a conforming implementation nothing, and it keeps a pre-`DELETE` copy's local service inside the entry's `fresh_until`. - - **No bound → omit, mirroring the store; bound unknown → `0`.** A tier serving a copy of a no-expiry entry omits the header, exactly as the store does. A tier holding a copy of a *bounded* entry whose remainder it has lost — populated without a hint — MUST emit `X-CacheKit-Fresh-For: 0`; it MUST NOT omit a header it received, nor drop it for such a copy: omission would claim "no bound" for an entry that has one and silently restore the unbounded backfill this header exists to kill. A tier MUST therefore record at populate whether a copy is unbounded or merely unhinted; a tier that cannot tell the two apart MUST emit `0`. - - **Positive only for `fresh` + positive.** A tier MAY emit a positive value only on a response the tier below labelled exactly `X-CacheKit-Freshness: fresh` (or left unlabelled — the pre-SWR `fresh` default) and stamped with a strictly positive `X-CacheKit-Fresh-For`, and then only the decayed remainder. Every other shape — `stale`, `X-CacheKit-Fresh-For: 0` under any label, an unrecognized freshness token, or a missing `X-CacheKit-Fresh-For` on a bounded entry — MUST be emitted with `X-CacheKit-Fresh-For: 0` and its freshness label passed through unchanged (a `fresh` `0` stays `fresh`; a `stale` stays `stale`). Stamping a positive bound onto a stale, exhausted, or unknown value resurrects an expired (or revoked) entry as locally-cacheable fresh — the exact hole this header closes. + - **Omit only on positive knowledge of no expiry; otherwise `0`.** A tier MAY omit the header only for a copy it positively knows has no expiry: a signal-capable store below omitted it (such a store omits only for no-expiry entries), or the tier populated the copy from a write that carried no `X-CacheKit-TTL`. For every other copy whose remainder it does not know — populated without a hint, a header lost in transit, or a store below that is pre-signal and therefore omits for bounded entries too — the tier MUST emit `X-CacheKit-Fresh-For: 0`. It MUST NOT omit a header it received, nor drop it for such a copy: omission would claim "no bound" for an entry that may have one and silently restore the unbounded backfill this header exists to kill. A tier MUST therefore record at populate whether a copy is unbounded or merely unhinted; a tier that cannot tell the two apart MUST emit `0`. A tier fronting a pre-signal store is in exactly that position — it emits `0`, it does not pass the absence through; the cost is no local backfill behind that tier until the store signals, the fail-closed posture a mixed deployment should have. Whether the store below signals is a deployment fact the tier is configured with, never inferred per response. + - **Positive only for `fresh` + positive.** A tier MAY emit a positive value only on a response the tier below labelled exactly `X-CacheKit-Freshness: fresh` (or left unlabelled — the pre-SWR `fresh` default) and stamped with a strictly positive `X-CacheKit-Fresh-For`, and then only the decayed remainder. Every other shape — `stale`, `X-CacheKit-Fresh-For: 0` under any label, an unrecognized freshness token, or a missing `X-CacheKit-Fresh-For` on any entry not positively known to be no-expiry — MUST be emitted with `X-CacheKit-Fresh-For: 0` and its freshness label passed through unchanged (a `fresh` `0` stays `fresh`; a `stale` stays `stale`). Stamping a positive bound onto a stale, exhausted, or unknown value resurrects an expired (or revoked) entry as locally-cacheable fresh — the exact hole this header closes. - `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):** @@ -120,7 +120,7 @@ Authorization: Bearer ck_live_xxx - The value MUST be 1–7 ASCII digits and at most `2,592,000` (the [30-day TTL cap](#put-v1cachekey), itself seven digits). Anything else — empty, non-digit, signed, longer than seven digits, or over the cap — MUST be treated as `0` (do not extend local service — the conservative action, mirroring the unrecognized-`X-CacheKit-Freshness` → `stale` rule); a larger value is protocol-impossible, a buggy or misconfigured tier rather than a real bound. The length check MUST run first, so the range check never depends on a fixed-width integer conversion that could wrap an over-cap value back into range (a wrapping `atoi`/`strtoul` turns `4297559296` into `2,592,000`). - 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, and a re-serving tier may compliantly hand out a copy it cached before the `DELETE` for the rest of its coherence window ([emission](#remaining-freshness) above). `evict_at` is therefore the **store's** service bound, not an end-to-end one. Revocation propagation is bounded by the **sum** of the serving path's compounded coherence windows, the largest locally applied service bound (decayed, so it ends no later than the served entry's `fresh_until`), in-flight response transit, and clock or suspend error — a `GET` response already in flight when the `DELETE` lands is likewise still backfilled on arrival. 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)). +- 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, and a re-serving tier may compliantly hand out a copy it cached before the `DELETE` for the rest of its coherence window ([emission](#remaining-freshness) above). `evict_at` is therefore the **store's** service bound, not an end-to-end one. Revocation propagation is bounded by the **sum** of the serving path's compounded coherence windows, the largest locally applied service bound, in-flight response transit, and clock or suspend error — a `GET` response already in flight when the `DELETE` lands is likewise still backfilled on arrival. For a bounded entry the local term is the decayed header, so it ends no later than the served entry's `fresh_until`. For a **no-expiry** entry — or any read served without the header — there is no `fresh_until`: the local term is the reader's full configured local TTL, re-anchored on every read, so a revoked no-expiry value outlives its `DELETE` by the largest local TTL in the fleet with no server-side ceiling, and a `PATCH /ttl` or `DELETE` that later bounds or removes a formerly-no-expiry entry cannot reach copies already distributed without the header. 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)); keys whose TTL is a revocation boundary MUST be stored with an explicit `X-CacheKit-TTL` — a no-expiry entry has no revocation tolerance to size to. --- @@ -155,7 +155,7 @@ X-CacheKit-TTL: 3600 > | TTL negative | **Reject** — return error to caller. | **Reject** — return `400 Bad Request`. | > | TTL non-integer | N/A (SDK converts duration to integer seconds). | **Reject** — return `400 Bad Request`. | > -> **Rationale:** TTL=0 is ambiguous across cache systems (Redis rejects it, Memcached treats it as "never expire", HTTP treats it as "immediately stale"). CacheKit defines TTL=0 as an error to prevent silent data loss (the Redis and HTTP readings) or an unintended no-expiry entry (the Memcached reading) — no expiry is requested by omitting the header, never by `0`. Sub-second durations are ceiled to 1 rather than truncated to 0 to avoid the same ambiguity. The 30-day maximum bounds the *value range* of a stated TTL (and with it the [`X-CacheKit-Fresh-For`](#remaining-freshness) grammar); it is not a storage-lifetime ceiling — omitting the header stores a no-expiry entry. Entries that need a long but bounded life should renew explicitly via `PATCH /v1/cache/{key}/ttl`. +> **Rationale:** TTL=0 is ambiguous across cache systems (Redis rejects it, Memcached treats it as "never expire", HTTP treats it as "immediately stale"). CacheKit defines TTL=0 as an error to prevent silent data loss (the Redis and HTTP readings) or an unintended no-expiry entry (the Memcached reading) — no expiry is requested by omitting the header, never by `0`. Sub-second durations are ceiled to 1 rather than truncated to 0 to avoid the same ambiguity. The 30-day maximum bounds the *value range* of a stated TTL (and with it the [`X-CacheKit-Fresh-For`](#remaining-freshness) grammar); it is not a storage-lifetime ceiling — omitting the header stores a no-expiry entry, and nothing in this spec bounds how long or how many such entries accumulate (storage hygiene is the operator's control, tracked as LAB-279). Entries that need a long but bounded life should renew explicitly via `PATCH /v1/cache/{key}/ttl`. > > **Migration:** The `X-TTL` header is deprecated. The server MUST accept both `X-CacheKit-TTL` and `X-TTL` during the transition period, preferring `X-CacheKit-TTL` when both are present. SDKs MUST send `X-CacheKit-TTL` only. The `X-TTL` header will be removed in protocol version 2.0 (targeted at SDK 1.0 milestone). @@ -234,7 +234,7 @@ All lifecycle times are computed against the **server's clock**; SDKs MUST NOT d Without `X-CacheKit-Stale-TTL` (or with `0`), `evict_at = fresh_until` and server behavior is identical to the pre-SWR protocol. -Without `X-CacheKit-TTL`, the entry has **no expiry**: no `fresh_until`, no `evict_at`. It is served `200 OK` `fresh` until deleted or overwritten, never enters the stale window, and carries no `X-CacheKit-Fresh-For` ([Remaining Freshness](#remaining-freshness)). `X-CacheKit-Stale-TTL` without `X-CacheKit-TTL` is rejected ([validation](#validation)) — an entry that never expires has nothing to be stale relative to. +Without `X-CacheKit-TTL`, the entry has **no expiry**: no `fresh_until`, no `evict_at`. It is served `200 OK` `fresh` until deleted or overwritten, never enters the stale window, and carries no `X-CacheKit-Fresh-For` ([Remaining Freshness](#remaining-freshness)). `X-CacheKit-Stale-TTL` without `X-CacheKit-TTL` is rejected ([validation](#validation)) — an entry that never expires has nothing to be stale relative to. Never expiring has two consequences a writer must weigh: the entry's revocation bound has no server-side ceiling, so read the revocation bound in [Remaining Freshness](#remaining-freshness) before storing a revocation-sensitive key without a TTL; and a copy a mispartitioned tier stores under the wrong tenant never self-heals, so the [tenant-partitioning rule](#authentication) is the only control for it. ### Validation @@ -250,7 +250,7 @@ These rules are normative for both SDKs and the SaaS backend, mirroring the [TTL ### Write semantics -- **`PUT` fully replaces the entry's timing metadata.** Both windows derive from the new request alone; a `PUT` that omits `X-CacheKit-Stale-TTL` (or sends `0`) leaves the entry with **no** stale window, regardless of what the previous entry had. A revalidation `PUT` therefore MUST re-send the stale window it intends to keep. +- **`PUT` fully replaces the entry's timing metadata.** Both windows derive from the new request alone; a `PUT` that omits `X-CacheKit-Stale-TTL` (or sends `0`) leaves the entry with **no** stale window, regardless of what the previous entry had. A revalidation `PUT` therefore MUST re-send both the `X-CacheKit-TTL` and the stale window it intends to keep — omitting `X-CacheKit-TTL` on a recompute does not carry the previous bound forward, it stores a **no-expiry** entry ([PUT](#put-v1cachekey)). - **`PATCH /v1/cache/{key}/ttl` within the fresh window** resets `fresh_until = now + ttl` and preserves the entry's stored stale window; the combined total is re-validated against the 30-day cap. - **`PATCH /v1/cache/{key}/ttl` on an entry past `fresh_until` MUST return `409 Conflict`.** A stale entry regains freshness only via a `PUT` of recomputed bytes — otherwise a routine TTL-renewal job could indefinitely resurrect stale data without revalidation, defeating the `evict_at` bound. @@ -287,7 +287,7 @@ An SDK that serves a stale hit and owns revalidation (the recompute is the wrapp - **Metering:** a stale-window `GET` is a cache **hit** (`200`) for metered-misses billing; the revalidation `PUT` is an ordinary write. In the [SDK metrics headers](#optional-metrics-headers), a stale serve increments `X-CacheKit-L2-Hits`; a background revalidation MUST NOT increment `X-CacheKit-Misses`. - **Invalidation race:** an explicit `DELETE /v1/cache/{key}` concurrent with an in-flight revalidation may be overwritten by the revalidation `PUT` (last-write-wins) — the same race as today's concurrent miss-path recompute. Callers that need durable invalidation must version their keys. - **Zero-knowledge:** no change to the wire format, ByteStorage envelope, encryption, or AAD; the value bytes remain opaque. -- **`GET /v1/cache/{key}/ttl`:** the returned `ttl` is the remaining seconds until **eviction** (`evict_at`), or `null` for a no-expiry entry. +- **`GET /v1/cache/{key}/ttl`:** the returned `ttl` is the remaining seconds until **eviction** (`evict_at`), or `null` for a no-expiry entry (mixed-reader caveat under [GET /v1/cache/{key}/ttl](#get-v1cachekeyttl)). - **Compatibility:** additive for servers — a pre-SWR server ignores `X-CacheKit-Stale-TTL` (the entry evicts at `fresh_until`, no freshness header is emitted) and SDK behavior is exactly pre-SWR. It is **not** transparent to mixed readers: enabling `stale_ttl` on a key affects every reader of that key, and a pre-SWR SDK will consume stale-window values as fresh (`200`, no header) where it previously saw a miss. Deployments MUST NOT enable `stale_ttl` on keys whose readers rely on hard TTL expiry (pre-SWR SDKs or security-sensitive consumers). --- @@ -359,7 +359,7 @@ X-CacheKit-Lock-Id: uuid-string ### GET /v1/cache/{key}/ttl -Get remaining TTL for a key. The returned `ttl` is the remaining seconds until **eviction** — for entries with a [stale-grace window](#stale-while-revalidate), that is `evict_at`, not `fresh_until`. A **no-expiry** entry ([PUT](#put-v1cachekey)) returns `200 OK` with `{"ttl": null}`: the key exists, so `404` MUST NOT be returned for it, and `null` — not a negative sentinel — is the representation, because the field is typed as seconds and every SDK already models no expiry as its null / `None` / `Option::None`. SDKs MUST accept `null` and surface it as their no-expiry value. +Get remaining TTL for a key. The returned `ttl` is the remaining seconds until **eviction** — for entries with a [stale-grace window](#stale-while-revalidate), that is `evict_at`, not `fresh_until`. A **no-expiry** entry ([PUT](#put-v1cachekey)) returns `200 OK` with `{"ttl": null}`: the key exists, so `404` MUST NOT be returned for it, and `null` — not a negative sentinel — is the representation, because the field is typed as seconds and every SDK already models no expiry as its null / `None` / `Option::None`. SDKs MUST accept `null` and surface it as their no-expiry value. This is **not** transparent to readers that predate it: an SDK that asserts an integer `ttl`, or coerces a non-integer to `0`, reads an immortal key as missing or as expiring now. Deployments MUST NOT store no-expiry entries for keys whose `/ttl` readers predate `null` support — the same mixed-reader rule as `stale_ttl` ([Semantics notes](#semantics-notes)). | Status | Meaning | Response Body | | :---: | :--- | :--- |