From 2bc48bc592217902abfb528648baa543b997df3e Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Mon, 31 Aug 2026 04:34:26 +1000 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] =?UTF-8?q?spec(saas-api):=20second=20panel=20round=20?= =?UTF-8?q?=E2=80=94=20drop=20false=20edge-coherence=20figure,=20mandate?= =?UTF-8?q?=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 5/5] 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)). ---