diff --git a/changelog.d/20261102_write-behind-receipt-delay.md b/changelog.d/20261102_write-behind-receipt-delay.md new file mode 100644 index 0000000..3a329df --- /dev/null +++ b/changelog.d/20261102_write-behind-receipt-delay.md @@ -0,0 +1,20 @@ +### SaaS API — the writer's write-behind copy, and the receipt-delay excess + +- [`spec/saas-api.md` § Remaining Freshness](spec/saas-api.md#remaining-freshness), the writer's + own copy: [API-36](spec/saas-api.md#api-36) and [API-37](spec/saas-api.md#api-37) now cover any + copy kept before the SDK's local cache learns of a `2xx`, including a write-behind copy, filled + before its `PUT` is sent, while the write is pending. Once the local cache learns of the `2xx`, + [API-103](spec/saas-api.md#api-103) binds the copy, its clock started at receipt. Receipt is now + defined as the instant the SDK's transport receives the `2xx`. +- Code the application supplies between an SDK's transport and its local cache, such as a backend + wrapper, can hold the `2xx` back, pass it on without its receipt instant, or drop it. The copy + can then outlive API-103's bound: the new **receipt-delay excess**, capped for a copy filled + before the local cache learned of the `2xx`, uncapped for one filled when it learned. Serving + within the excess conforms to API-103 only where such code caused it, never the SDK's own code, + and an SDK SHOULD require such code to pass the `2xx` on at once. The excess is added to the + revocation-propagation sum. +- API-36's recommended anchor is now the earlier of the `PUT`'s first dispatch and the local + write, so it never starts a write-behind copy's clock after the local write. +- The write-path tolerance now runs from the store's stamp to the later of receipt and the SDK's + local write, which covers a copy filled before its `PUT` was sent. +- No wire change. diff --git a/conformance/coverage.md b/conformance/coverage.md index ab7b88c..f52873f 100644 --- a/conformance/coverage.md +++ b/conformance/coverage.md @@ -229,7 +229,7 @@ Marked not-a-requirement in `spec/intent-presets.md`: | [API-32](../spec/saas-api.md#api-32) | *Remaining Freshness*: The value **MUST** be 1–7 ASCII digits and at most `2,592,000` (the header's cap: 30 days, itself seven digits; emission above keeps … | Binds the server.
**Gap:** No public vector exercises the server's headers, so nothing here catches a server or tier that emits a value of more than seven digits or over 2,592,000; API-33 is the SDK side. | n/a | n/a | n/a | | [API-33](../spec/saas-api.md#api-33) | *Remaining Freshness*: … within it). 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 … | `freshness-headers.json`: `fresh_for_empty`, `fresh_for_negative`, `fresh_for_plus_sign`, `fresh_for_decimal`, `fresh_for_exponent`, `fresh_for_hex`, `fresh_for_digit_separator`, `fresh_for_inner_space`, `fresh_for_nbsp_prefix`, `fresh_for_eight_digits`, `fresh_for_over_cap`, `fresh_for_wraps_32_bit`, `fresh_for_over_64_bits`, `fresh_for_4301_digits`, `fresh_for_superscript_two`, `fresh_for_arabic_indic_one`, `fresh_for_list`
`tools/freshness-headers-verify.py:verify`
**Gap:** The rows reach the parser an SDK's test hands each value, not its read path: whether the GET 200's header reaches it as its HTTP stack delivers the bytes and the result bounds the backfill (API-29), and what it does with two field lines of the header, which the rows hold only combined into one value. | partial (gap) | partial (gap) | partial (gap) | | [API-34](../spec/saas-api.md#api-34) | *Remaining Freshness*: … 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 … | `freshness-headers.json`: `fresh_for_eight_digits`, `fresh_for_wraps_32_bit`, `fresh_for_over_64_bits`, `fresh_for_4301_digits`
`tools/freshness-headers-verify.py:verify`
**Gap:** A parser that converts first, with arithmetic that neither wraps nor fails on a long value, and checks the length after returns the same values, so the order shows only through the conversions the rows break (32- and 64-bit integers, CPython's 4,300-digit limit); and the rows reach the parser, not the read path (API-33). | partial (gap) | partial (gap) | partial (gap) | -| [API-36](../spec/saas-api.md#api-36) | *Remaining Freshness*: … absorbed. The store may still have accepted that write, and no `2xx` starts the clock, so the SDK **MUST** start that copy's `min(local_ttl, effective_ttl)` clock no later than its local write, and MUST NOT serve the copy, in … | **Gap:** As API-103, for an SDK that keeps a copy after a timeout or a transport error and starts its clock only at a later read, or never. | gap | gap | gap | +| [API-36](../spec/saas-api.md#api-36) | *Remaining Freshness*: … that write, and until a `2xx` reaches the local cache nothing starts the clock, so the SDK **MUST** start that copy's `min(local_ttl, effective_ttl)` clock no later than its local write, and MUST NOT serve the copy, in … | **Gap:** As API-103, for an SDK that keeps a copy after a timeout or a transport error and starts its clock only at a later read, or never. | gap | gap | gap | | [API-37](../spec/saas-api.md#api-37) | *Remaining Freshness*: … MUST start that copy's `min(local_ttl, effective_ttl)` clock no later than its local write, and **MUST NOT** serve the copy, in any form, once that has elapsed. Such a copy can then outlive `fresh_until` by up to the time from … | **Gap:** As API-103, for an SDK that serves such a copy past its bound through a local stale-grace policy. | gap | gap | gap | | [API-38](../spec/saas-api.md#api-38) | *Remaining Freshness*: … this sum, so TTL bounds the revocation window only together with them. Security-sensitive caches **MUST** size TTL (and local TTL) to their revocation tolerance, or version their keys (see the invalidation-race note in … | Binds application code.
**Gap:** The TTL an application gives a security-sensitive cache, and whether it versions its keys, are its own choices, which no vector or SDK test can observe. | n/a | n/a | n/a | | [API-39](../spec/saas-api.md#api-39) | *Remaining Freshness*: … keys (see the invalidation-race note in 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. | Binds application code.
**Gap:** As API-38: whether a key whose TTL is a revocation boundary is written with an explicit TTL is the application's configuration. | n/a | n/a | n/a | @@ -294,7 +294,7 @@ Marked not-a-requirement in `spec/intent-presets.md`: | [API-100](../spec/saas-api.md#api-100) | *Remaining Freshness*: Emitted on a write, too. A store that applies a plan maximum TTL **MUST** emit `X-CacheKit-Fresh-For` on every `PUT` `2xx` whose stored entry has a freshness bound, computed as above from the … | Binds the server.
**Gap:** No public vector exercises the server's responses, so nothing here catches a clamping store that answers a bounded PUT without X-CacheKit-Fresh-For, or reports the stated TTL instead of the remainder it stamped. | n/a | n/a | n/a | | [API-101](../spec/saas-api.md#api-101) | *Remaining Freshness*: … the one it stated when the plan maximum clamped the write (the writer's own copy below). A server **MUST NOT** clamp any write or `PATCH` until its store emits the header on `PUT`, every server-side hop that relays a `PUT` `2xx` … | Binds the server.
**Gap:** As API-100, for a deployment that clamps writes before its store emits X-CacheKit-Fresh-For on PUT, or while a server-side hop that relays the PUT 2xx drops the header or a tier bounds its copy by the stated TTL. | n/a | n/a | n/a | | [API-102](../spec/saas-api.md#api-102) | *Remaining Freshness*: … — under any label. A tier, or any other server-side hop, that relays the store's `PUT` `2xx` **MUST NOT** drop the `X-CacheKit-Fresh-For` of the store's `2xx` from the response it relays, nor relay a value larger than the … | Binds the server.
**Gap:** As API-19, for a tier or other server-side hop that drops the X-CacheKit-Fresh-For of the store's PUT 2xx, or relays a larger value. | n/a | n/a | n/a | -| [API-103](../spec/saas-api.md#api-103) | *Remaining Freshness*: … on the `PUT` `2xx` (emission above). Once the store accepts the `PUT`, the SDK **MUST NOT** serve that copy, in any form, after `min(local_ttl, effective_ttl, fresh_for)` has elapsed, where `effective_ttl` is … | **Gap:** No vector reaches an SDK's write path or its local cache, so nothing here catches one that bounds its copy of an accepted write by the local TTL alone, keeps the stated TTL when the PUT 2xx reported a shorter bound, or lengthens the copy by X-CacheKit-Stale-TTL. | gap | gap | gap | +| [API-103](../spec/saas-api.md#api-103) | *Remaining Freshness*: … on the `PUT` `2xx` (emission above). Once the store accepts the `PUT`, the SDK **MUST NOT** serve that copy, in any form, after `min(local_ttl, effective_ttl, fresh_for)` has elapsed, where `effective_ttl` is … | **Gap:** No vector reaches an SDK's write path or its local cache, so nothing here catches one that bounds its copy of an accepted write by the local TTL alone, keeps the stated TTL when the PUT 2xx reported a shorter bound, or lengthens the copy by X-CacheKit-Stale-TTL; nor one that serves its copy before its local cache learns of the PUT 2xx (write-behind) and, once it learns, keeps the copy's API-36 bound from its local write, or restarts the clock then instead of at receipt, though it holds the receipt instant. | gap | gap | gap | | [API-104](../spec/saas-api.md#api-104) | *PATCH /v1/cache/{key}/ttl*: … reports the remaining time to `evict_at`, stale window included), so a `PATCH` response **MUST NOT** create, refresh or extend any local entry's service bound. For entries stored with a stale-grace window, see SWR write … | **Gap:** No vector reaches an SDK's local cache, so nothing here catches one that re-anchors its local copy to the requested ttl when a PATCH /ttl succeeds, though the store may have clamped it. | gap | gap | gap | | [API-105](../spec/saas-api.md#api-105) | *Remaining Freshness*: Decay, never re-stamp. A tier **MUST** emit its copy's remaining bound minus the seconds elapsed since that bound was obtained, flooring at `0`. A copy has … | Binds the server.
**Gap:** As API-19, for a tier that stamps each hit with its copy's original bound instead of the decayed remainder, or bounds its copy of a forwarded write by the stated TTL when the store's PUT 2xx reported a shorter bound. | n/a | n/a | n/a | | [API-106](../spec/saas-api.md#api-106) | *Remaining Freshness*: … no later than `fresh_until`, and one from the effective TTL ends less than one second after it. It **MUST NOT** emit a value larger than that decayed remainder or than `2592000` (API-32) — its own copy TTL included — under any … | Binds the server.
**Gap:** As API-19, for a tier that emits its own copy TTL when that is larger than the decayed remainder, or a remainder above 2,592,000. | n/a | n/a | n/a | diff --git a/conformance/requirements.json b/conformance/requirements.json index b7b3520..09804f9 100644 --- a/conformance/requirements.json +++ b/conformance/requirements.json @@ -2214,7 +2214,7 @@ "API-103": { "section": "Remaining Freshness", "binds": "sdk", - "gap": "No vector reaches an SDK's write path or its local cache, so nothing here catches one that bounds its copy of an accepted write by the local TTL alone, keeps the stated TTL when the PUT 2xx reported a shorter bound, or lengthens the copy by X-CacheKit-Stale-TTL." + "gap": "No vector reaches an SDK's write path or its local cache, so nothing here catches one that bounds its copy of an accepted write by the local TTL alone, keeps the stated TTL when the PUT 2xx reported a shorter bound, or lengthens the copy by X-CacheKit-Stale-TTL; nor one that serves its copy before its local cache learns of the PUT 2xx (write-behind) and, once it learns, keeps the copy's API-36 bound from its local write, or restarts the clock then instead of at receipt, though it holds the receipt instant." }, "API-104": { "section": "PATCH /v1/cache/{key}/ttl", diff --git a/sdk-feature-matrix.md b/sdk-feature-matrix.md index eb243ae..9b10632 100644 --- a/sdk-feature-matrix.md +++ b/sdk-feature-matrix.md @@ -185,7 +185,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`) only, and only with a TTL: the `production` and `dev` presets supply one by default from `cachekit` 0.20.0, a bare `@cache(backend=None)` still needs `ttl=`¹⁰ | ✅ Refresh-ahead within the L1 TTL: an entry past `swr_threshold_ratio` of its TTL is still served while one single-flight background refresh runs, and is never served after the TTL (LAB-728)¹⁰ ¹³ | ✅ `getWithSwr` — refresh-ahead within the L1 TTL: version tokens + background refresh, `maxConcurrentRefreshes` cap, never served after the TTL¹⁰; on Workers requires a bound `ExecutionContext` (see [Cache Backends](#cache-backends) note ¹) | ❌ | | Stale-while-revalidate (server stale-grace) | ✅ CachekitIO backend, PyPI 0.14.0+ ([cachekit-py#228](https://github.com/cachekit-io/cachekit-py/pull/228); wheel inspected 2026-10-05): a PUT carries `X-CacheKit-Stale-TTL` from an explicit `stale_ttl=` or `@cache.io`'s default window, and a GET labelled `stale` is served at once and revalidated in the background under the lock, with a contested lock serving stale without recompute (`tests/unit/test_swr_decorator.py`). `stale_ttl` needs the CachekitIO backend known at decoration and fails there otherwise; a sub-second window is not rounded (see the SaaS API row) | ❌ | 🚧 unreleased — opt-in on `main` ([cachekit-ts#274](https://github.com/cachekit-io/cachekit-ts/pull/274)), ships in npm 0.3.0; CachekitIO backend only: `wrap()` with `staleTtl` sends `X-CacheKit-Stale-TTL` on every write of the key (cold miss, L1 refresh-ahead, revalidation), and a GET labelled `stale` is served at once and revalidated in the background, one at a time per key in each cache (a revalidation still running after 60 s no longer blocks a new one) and under the lock on a lock-capable backend, with a contested or failed lock serving stale without recompute and holding the key off for `min(lockTimeoutMs, 60 s)` (`src/cache.stale-grace.test.ts`). A sub-second window is ceiled. `wrap()` refuses `staleTtl` on an encrypted cache and on a backend without the stale-window capability. On Workers without a bound `waitUntil` a stale read takes the miss path | ❌ | -| Write-behind on a miss (return before the L2 write lands) | ✅ PyPI 0.25.0+ ([cachekit-py#623](https://github.com/cachekit-io/cachekit-py/pull/623); 0.25.0 and 0.25.1 wheels inspected 2026-11-03): opt-in `write_behind=True`, set by no preset: the value is serialized and put in L1 on the caller's thread, and a bounded per-function writer (up to 4 threads) sends each queued write; the queue holds 64 entries and 16 MiB, except that a newer value for a key whose older value is being sent always queues behind that send, with neither bound checked, so the queue can hold one entry more than 64 per send in flight (4 at most), and its bytes have no cap from those values, which stay queued while older entries are sent; an async miss that runs under the distributed lock, a timed-out lock wait included, and a sync miss's streaming (File + Arrow) write stay synchronous; another process's invalidation is not guaranteed to reach a queued write, which can then land after it; a clean interpreter exit flushes queued writes for up to 10 s (`decorators/write_behind.py`) | ❌ | ❌ | ❌ | +| Write-behind on a miss (return before the L2 write lands) | ✅ PyPI 0.25.0+ ([cachekit-py#623](https://github.com/cachekit-io/cachekit-py/pull/623); 0.25.0 and 0.25.1 wheels inspected 2026-11-03): opt-in `write_behind=True`, set by no preset: the value is serialized and put in L1 on the caller's thread, and a bounded per-function writer (up to 4 threads) sends each queued write; the queue holds 64 entries and 16 MiB, except that a newer value for a key whose older value is being sent always queues behind that send, with neither bound checked, so the queue can hold one entry more than 64 per send in flight (4 at most), and its bytes have no cap from those values, which stay queued while older entries are sent; an async miss that runs under the distributed lock, a timed-out lock wait included, and a sync miss's streaming (File + Arrow) write stay synchronous; another process's invalidation is not guaranteed to reach a queued write, which can then land after it; a clean interpreter exit flushes queued writes for up to 10 s (`decorators/write_behind.py`) | ❌ (no write-behind: every write fills L1 after the backend write returns) | 🚧 unreleased — opt-in `writeBehind: true` per `wrap()`ed function on `main` ([cachekit-ts#275](https://github.com/cachekit-io/cachekit-ts/pull/275)), set by no preset: the value is encoded (and encrypted) and put in L1 (unless `skipL1`) on the caller's path, and the L2 write finishes in the background; a miss that holds the distributed lock, a Workers call without `withExecutionContext`, a key with a write or delete pending or in flight, and a write past the 64-write / 16 MiB cap stay on the caller's path. When the write lands with a `PUT` `X-CacheKit-Fresh-For`, the L1 copy is capped at receipt plus it; a backend wrapper still suspended after the `2xx` delays that cap, the receipt-delay excess ([saas-api.md → Remaining Freshness](spec/saas-api.md#remaining-freshness)) (`writeBehind` in `src/cache-core.ts`) | ❌ | | Server-bounded L1 backfill (`X-CacheKit-Fresh-For`, [saas-api.md → Remaining Freshness](spec/saas-api.md#remaining-freshness)) | ⚠️ `cachekit` 0.22.0+ — the CachekitIO read honours `X-CacheKit-Freshness` and `X-CacheKit-Fresh-For`: L1 backfill is bounded to `min(ttl, Fresh-For)`, a `Fresh-For: 0` or stale read is served but never backfilled, and an absent header backfills as before; `Fresh-For` is parsed with the spec grammar (1–7 ASCII digits, at most 2,592,000, length checked first), so a sign (`+5`), `_` separators (`1_0`), whitespace, non-ASCII digits, more than seven digits (`00000005`) or a value over the cap counts as `0` (`_parse_fresh_for` in `backends/cachekitio/backend.py`, `_l1_backfill_ttl` and `_l1_backfill_from_l2` in `decorators/wrapper.py`; 0.22.0 wheel inspected 2026-10-08, `tests/unit/backends/test_cachekitio_swr_transport.py::TestFreshForRead` passes against it). 0.19.0–0.21.0 bound the backfill the same way but parse the value with Python's `int()`, so those malformed values bound it instead of counting as `0`. It never lengthens L1 past the configured TTL. The `PUT` response's `X-CacheKit-Fresh-For` is not read through 0.25.1 (wheel inspected 2026-10-31: only the read path calls `_fresh_for`), so L1's copy of the SDK's own write keeps the stated TTL, or the configured L1 TTL for a write without one, where a [plan maximum TTL](spec/saas-api.md#plan-maximum-ttl) stored a shorter one: not compliant with [API-103](spec/saas-api.md#api-103) | ⚠️ `cachekit-rs` 0.9.0+ — the CachekitIO read honours `X-CacheKit-Freshness` and `X-CacheKit-Fresh-For`: L1 backfill (and its SWR service) is bounded by `Fresh-For`, a `Fresh-For: 0` or stale read is served but never backfilled, and an absent header backfills as before (`freshness_from_headers` in `src/backend/cachekitio.rs`, `get_with_freshness` in `src/reliability.rs`; `.crate` inspected 2026-10-02, `client_tests::freshness::*` and `cachekitio::freshness_header_tests` pass against it). The `PUT` response's `X-CacheKit-Fresh-For` is not read through 0.13.1 (`.crate` inspected 2026-10-31: only `get` calls `freshness_from_headers`), so L1's copy of the SDK's own write keeps the stated TTL, or the configured L1 TTL for a write without one, where a [plan maximum TTL](spec/saas-api.md#plan-maximum-ttl) stored a shorter one: not compliant with [API-103](spec/saas-api.md#api-103) | ⚠️ `@cachekit-io/cachekit` 0.2.1+ — the CachekitIO read honours `X-CacheKit-Freshness` and `X-CacheKit-Fresh-For`: L1 backfill is bounded to `min(ttl, Fresh-For)`, on the `wrap()` SWR path too, counted from receipt; a `Fresh-For: 0` or stale read is served but never backfilled, and an absent header backfills as before; `Fresh-For` is parsed with the spec grammar (1–7 ASCII digits, at most 2,592,000), and any malformed value counts as `0` (`freshnessFromHeaders` and `getWithTtl` in `dist/backends/cachekitio.js`, the bound in `dist/cache-core.js`; `.tgz` inspected 2026-10-24; `src/backends/cachekitio-freshness.test.ts` and `test/protocol/freshness-headers.protocol.test.ts` pass at tag `cachekit-v0.2.1` against the published `cachekit-core-ts` 0.2.0 binding, run 2026-10-24). 0.1.5 reads neither header. The `PUT` response's `X-CacheKit-Fresh-For` is not read through 0.2.3 (`.tgz` inspected 2026-10-31: `set` resolves to `void`, and only the read calls `freshnessFromHeaders`), so L1's copy of the SDK's own write keeps the stated TTL, or the configured L1 TTL for a write without one, where a [plan maximum TTL](spec/saas-api.md#plan-maximum-ttl) stored a shorter one: not compliant with [API-103](spec/saas-api.md#api-103) | ❌ | > [!IMPORTANT] diff --git a/spec/saas-api.md b/spec/saas-api.md index ecd8ded..365214f 100644 --- a/spec/saas-api.md +++ b/spec/saas-api.md @@ -175,9 +175,9 @@ X-CacheKit-L1-Status: miss - 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 MUSTAPI-32 be 1–7 ASCII digits and at most `2,592,000` (the header's cap: 30 days, itself seven digits; [emission](#remaining-freshness) above keeps every value within it). Anything else — empty, non-digit, signed, longer than seven digits, or over the cap — MUSTAPI-33 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 MUSTAPI-34 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 writer's own copy.** An SDK that keeps a local copy of a value it wrote bounds that copy by the write and by the `X-CacheKit-Fresh-For` on the `PUT` `2xx` ([emission](#remaining-freshness) above). Once the store accepts the `PUT`, the SDK MUST NOTAPI-103 serve that copy, in any form, after `min(local_ttl, effective_ttl, fresh_for)` has elapsed, where `effective_ttl` is the write's [effective TTL](#put-v1cachekey) (unbounded for a write without one), `fresh_for` is the `2xx`'s `X-CacheKit-Fresh-For` read with the grammar above (unbounded when the header is absent, and `0` when it is malformed, so the copy is not kept), and the clock starts when the SDK receives the `2xx`. `X-CacheKit-Stale-TTL` never lengthens this bound, because local stale service is not allowed (above). A copy bounded without that header — kept without a `2xx` (below), or kept by an SDK that predates the header on `PUT` — can outlive `fresh_until` on a plan with a maximum TTL by up to `min(local_ttl, effective_ttl)` less that maximum, on top of its timing tolerance: this section's **plan-maximum excess**. Receipt is the first instant the SDK knows the write was accepted, but the store stamps `fresh_until` earlier, when it begins storing the write (before the rest of the work that precedes its reply), and from `floor(stored_at)` ([whole-second stamping](#entry-lifecycle)). A conforming copy can therefore outlive `fresh_until` by the time from the store's stamp to the SDK's local write (the store's remaining processing, transit, and the SDK's own processing), plus less than one second. That error is accepted; it is this section's **write-path tolerance**. An SDK may also keep a copy without having received a `2xx`: after a timeout, a transport error, or an error it absorbed. The store may still have accepted that write, and no `2xx` starts the clock, so the SDK MUSTAPI-36 start that copy's `min(local_ttl, effective_ttl)` clock no later than its local write, and MUST NOTAPI-37 serve the copy, in any form, once that has elapsed. Such a copy can then outlive `fresh_until` by up to the time from the SDK's first dispatch of the `PUT` to its local write (the write call's full duration, retries included, and the SDK's own processing after it), plus a second. An SDK SHOULD measure that copy's bound from when it first dispatched the `PUT`, which keeps it less than one second past `fresh_until`, plus the plan-maximum excess. +- **The writer's own copy.** An SDK that keeps a local copy of a value it wrote bounds that copy by the write and by the `X-CacheKit-Fresh-For` on the `PUT` `2xx` ([emission](#remaining-freshness) above). Once the store accepts the `PUT`, the SDK MUST NOTAPI-103 serve that copy, in any form, after `min(local_ttl, effective_ttl, fresh_for)` has elapsed, where `effective_ttl` is the write's [effective TTL](#put-v1cachekey) (unbounded for a write without one), `fresh_for` is the `2xx`'s `X-CacheKit-Fresh-For` read with the grammar above (unbounded when the header is absent, and `0` when it is malformed, so the copy is not kept), and the clock starts when the SDK receives the `2xx`. The one exception is a copy whose local cache learns of that `2xx` late, or never, which can outlive this bound by the receipt-delay excess (below). `X-CacheKit-Stale-TTL` never lengthens this bound, because local stale service is not allowed (above). A copy bounded without that header — kept before its local cache learns of a `2xx` (below), or kept by an SDK that predates the header on `PUT` — can outlive `fresh_until` on a plan with a maximum TTL by up to `min(local_ttl, effective_ttl)` less that maximum, on top of its timing tolerance: this section's **plan-maximum excess**. Receipt is the instant the SDK's transport receives the `2xx`, the first instant the SDK knows the write was accepted, but the store stamps `fresh_until` earlier, when it begins storing the write (before the rest of the work that precedes its reply), and from `floor(stored_at)` ([whole-second stamping](#entry-lifecycle)). A conforming copy can therefore outlive `fresh_until` by the time from the store's stamp to the later of receipt and the SDK's local write (the store's remaining processing, transit, and the SDK's own processing), plus less than one second. That error is accepted; it is this section's **write-path tolerance**. An SDK may also keep a copy before its local cache learns of a `2xx`: after a timeout, a transport error, or an error it absorbed, or while the write is still pending. The store may still have accepted that write, and until a `2xx` reaches the local cache nothing starts the clock, so the SDK MUSTAPI-36 start that copy's `min(local_ttl, effective_ttl)` clock no later than its local write, and MUST NOTAPI-37 serve the copy, in any form, once that has elapsed. Such a copy can then outlive `fresh_until` by up to the time from the SDK's first dispatch of the `PUT` to its local write (the write call's full duration, retries included, and the SDK's own processing after it), plus a second. An SDK SHOULD measure that copy's bound from the earlier of its first dispatch of the `PUT` and its local write, which keeps it less than one second past `fresh_until`, plus the plan-maximum excess. A write-behind cache, which fills its copy before it sends the `PUT`, keeps such a copy while the write is pending; once its local cache learns of the `2xx`, API-103 binds the copy. Code the application supplies between the SDK's transport and its local cache, such as a backend wrapper, can hold the `2xx` back, or pass it on without its receipt instant, so the local cache learns of it late and, without that instant, counts the bound from when it learned. The copy can then outlive API-103's bound by up to the time the `2xx` was held back, or by that time less `fresh_for` when the code passed the receipt instant on. A copy filled before its local cache learned of the `2xx` never exceeds the bound by more than `min(local_ttl, effective_ttl)` less `fresh_for`, because its API-36 clock started at its local write. A copy filled when the local cache learned of it has no such ceiling. Such code can also drop the `2xx`, or its `X-CacheKit-Fresh-For`, so that the local cache never learns of the bound the store applied; the copy then stays bounded by API-36 and API-37 alone. This is this section's **receipt-delay excess**. A copy served within it conforms to API-103 only where code the application supplies held back or dropped the `2xx` or its `X-CacheKit-Fresh-For`, never the SDK's own code. An SDK that lets such code stand between its transport and its local cache SHOULD require that code to pass the `2xx` on, with its receipt instant and its `X-CacheKit-Fresh-For`, as soon as it arrives. - 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, in-flight response transit, the time from first dispatch to local write of any write whose copy the writer kept without a `2xx`, the plan-maximum excess ([the writer's own copy](#remaining-freshness) above), 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`, or less than one second after it when a re-serving tier's copy came from a write the tier forwarded ([emission](#remaining-freshness) above). For the writer's own copy of a bounded write the local term is the bound the store applied, which ends within the write-path tolerance of `fresh_until`, or up to that time plus a second after it for a copy kept without a `2xx` ([the writer's own copy](#remaining-freshness) above). For a **no-expiry** entry — or any read served without the header — there is no `fresh_until`: the local term is the full configured local TTL of each reader or writer holding a copy, re-anchored on every read and every write, 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. The protocol caps none of the latency terms in this sum, so TTL bounds the revocation window only together with them. Security-sensitive caches MUSTAPI-38 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 MUSTAPI-39 be stored with an explicit `X-CacheKit-TTL` — a no-expiry entry has no revocation tolerance to size to. +- 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, the time from first dispatch to local write of any write whose copy the writer kept before its local cache learned of a `2xx`, the plan-maximum excess and the receipt-delay excess ([the writer's own copy](#remaining-freshness) above), 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`, or less than one second after it when a re-serving tier's copy came from a write the tier forwarded ([emission](#remaining-freshness) above). For the writer's own copy of a bounded write the local term is the bound the store applied, which ends within the write-path tolerance of `fresh_until`, or up to that time plus a second after it for a copy kept before its local cache learned of a `2xx` ([the writer's own copy](#remaining-freshness) above). For a **no-expiry** entry — or any read served without the header — there is no `fresh_until`: the local term is the full configured local TTL of each reader or writer holding a copy, re-anchored on every read and every write, 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. The protocol caps none of the latency terms in this sum, so TTL bounds the revocation window only together with them. Security-sensitive caches MUSTAPI-38 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 MUSTAPI-39 be stored with an explicit `X-CacheKit-TTL` — a no-expiry entry has no revocation tolerance to size to. ---