From 4f411fa72c800d2e1ae5c30cfcf1ff6081be133b Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Mon, 2 Nov 2026 23:34:42 +0000 Subject: [PATCH 1/5] docs(saas-api): bound a write-behind copy whose 2xx is held back (receipt-delay excess) A writer that fills its local copy before the PUT goes out learns the accepted write's bound only when its local cache learns of the 2xx, which code between the transport and the cache can delay. Until then the copy is one kept without a 2xx (API-36, API-37); from then API-103 applies at once, clocked from receipt (API-108). The delay is stated as the receipt-delay excess and added to the revocation sum. --- .../20261102_write-behind-receipt-delay.md | 15 +++++++++++++++ conformance/coverage.md | 9 +++++---- conformance/requirements.json | 7 ++++++- sdk-feature-matrix.md | 2 +- spec/saas-api.md | 4 ++-- 5 files changed, 29 insertions(+), 8 deletions(-) create mode 100644 changelog.d/20261102_write-behind-receipt-delay.md 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..97743c2 --- /dev/null +++ b/changelog.d/20261102_write-behind-receipt-delay.md @@ -0,0 +1,15 @@ +### 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: an SDK that serves its copy before its local cache learns whether the store accepted + the write (write-behind) treats that copy as one kept without a `2xx` + ([API-36](spec/saas-api.md#api-36), [API-37](spec/saas-api.md#api-37)) until the local cache + learns of the `2xx`. From then it bounds the copy by [API-103](spec/saas-api.md#api-103) at + once, with the clock still started at receipt ([API-108](spec/saas-api.md#api-108), new). +- Code between an SDK's transport and its local cache, such as an application-supplied backend + wrapper, can hold the `2xx` back. For that delay the copy can outlive API-103's bound by the + delay less `fresh_for`, and never by more than `min(local_ttl, effective_ttl)` less `fresh_for`: + the new **receipt-delay excess**. A `fresh_for` of `0`, which keeps no copy, does not shorten + that delay. An SDK that admits such code SHOULD require it to pass the `2xx` on as soon as it + arrives. The excess is added to the revocation-propagation sum. +- No wire change, and no SDK that fills its copy only after its write returns is affected. diff --git a/conformance/coverage.md b/conformance/coverage.md index ab7b88c..5fef22c 100644 --- a/conformance/coverage.md +++ b/conformance/coverage.md @@ -25,14 +25,14 @@ How ids are assigned, and what each status means: [README.md](README.md#coverage | [`spec/file-backend-format.md`](../spec/file-backend-format.md) | 6 | 1 | 3 | 0 | 2 | | [`spec/intent-presets.md`](../spec/intent-presets.md) | 55 | 0 | 19 | 0 | 36 | | [`spec/interop-mode.md`](../spec/interop-mode.md) | 36 | 5 | 23 | 4 | 4 | -| [`spec/saas-api.md`](../spec/saas-api.md) | 101 | 0 | 15 | 0 | 86 | +| [`spec/saas-api.md`](../spec/saas-api.md) | 102 | 0 | 15 | 0 | 87 | | [`spec/wire-format.md`](../spec/wire-format.md) | 24 | 3 | 18 | 3 | 0 | | SDK | Covered | Partial | Uncovered | Gap | n/a | | :--- | ---: | ---: | ---: | ---: | ---: | -| cachekit-py | 12 | 86 | 5 | 84 | 57 | -| cachekit-ts | 9 | 78 | 7 | 74 | 76 | -| cachekit-rs | 4 | 69 | 3 | 70 | 98 | +| cachekit-py | 12 | 86 | 5 | 85 | 57 | +| cachekit-ts | 9 | 78 | 7 | 75 | 76 | +| cachekit-rs | 4 | 69 | 3 | 71 | 98 | Not indexed yet: [`spec/interop-v2.md`](../spec/interop-v2.md). @@ -299,6 +299,7 @@ Marked not-a-requirement in `spec/intent-presets.md`: | [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 | | [API-107](../spec/saas-api.md#api-107) | *Remaining 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 … | Binds the server.
**Gap:** As API-19, for a tier that stamps a positive value on a stale, exhausted or unknown copy, or rewrites its freshness label; or stamps a positive value on a copy of a forwarded write whose PUT 2xx carried 0 or a malformed value. | n/a | n/a | n/a | +| [API-108](../spec/saas-api.md#api-108) | *Remaining Freshness*: … by API-36 and API-37 from its local write. Once the local cache learns of the `2xx`, the SDK **MUST** bound the copy by API-103 at once, with the clock still started when the SDK received the `2xx`. The local cache can … | **Gap:** As API-103, for an SDK that serves its copy before its local cache learns of the PUT 2xx (write-behind) and, once it learns, keeps the copy's earlier bound, or restarts the clock at that moment instead of at receipt. | gap | gap | gap | Marked not-a-requirement in `spec/saas-api.md`: diff --git a/conformance/requirements.json b/conformance/requirements.json index b7b3520..af9f724 100644 --- a/conformance/requirements.json +++ b/conformance/requirements.json @@ -1521,7 +1521,7 @@ }, "spec/saas-api.md": { "prefix": "API", - "next": 108, + "next": 109, "requirements": { "API-1": { "section": "Authentication", @@ -2235,6 +2235,11 @@ "section": "Remaining Freshness", "binds": "server", "gap": "As API-19, for a tier that stamps a positive value on a stale, exhausted or unknown copy, or rewrites its freshness label; or stamps a positive value on a copy of a forwarded write whose PUT 2xx carried 0 or a malformed value." + }, + "API-108": { + "section": "Remaining Freshness", + "binds": "sdk", + "gap": "As API-103, for an SDK that serves its copy before its local cache learns of the PUT 2xx (write-behind) and, once it learns, keeps the copy's earlier bound, or restarts the clock at that moment instead of at receipt." } }, "retired": { diff --git a/sdk-feature-matrix.md b/sdk-feature-matrix.md index b1cd891..00320f2 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) | 🚧 unreleased — opt-in `write_behind=True` on `main` ([cachekit-py#623](https://github.com/cachekit-io/cachekit-py/pull/623)), set by no preset: the value is serialized and put in L1 on the caller's thread, and a bounded per-function writer sends the write; the async distributed-lock path 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 (`decorators/write_behind.py`) | ❌ | ❌ | ❌ | +| Write-behind on a miss (return before the L2 write lands) | 🚧 unreleased — opt-in `write_behind=True` on `main` ([cachekit-py#623](https://github.com/cachekit-io/cachekit-py/pull/623)), set by no preset: the value is serialized and put in L1 on the caller's thread, and a bounded per-function writer sends the write; the async distributed-lock path 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 (`decorators/write_behind.py`) | ❌ (no write-behind: every write fills L1 after the backend accepts it) | 🚧 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 on the caller's path, and the L2 write finishes in the background; a locked miss, a Workers call without `withExecutionContext`, a key with a write already pending, 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](spec/saas-api.md#api-108) (`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..4982c7a 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`; a copy served before the SDK's local cache learns of that `2xx` 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 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. An SDK may also serve its copy before its local cache learns whether the store accepted the write, as a write-behind cache does when it fills the copy before it sends the `PUT`. Until the local cache learns of a `2xx`, that copy is a copy kept without one, bounded by API-36 and API-37 from its local write. Once the local cache learns of the `2xx`, the SDK MUSTAPI-108 bound the copy by API-103 at once, with the clock still started when the SDK received the `2xx`. The local cache can learn of a `2xx` later than that: code between the SDK's transport and its local cache, such as a backend wrapper the application supplies, can hold the `2xx` back. For that delay the copy can outlive API-103's bound by the delay less `fresh_for`, and never by more than `min(local_ttl, effective_ttl)` less `fresh_for`, because its API-36 clock started at its local write, before receipt. This is this section's **receipt-delay excess**. When `fresh_for` is `0`, a malformed header included, the copy serves for the whole delay, although API-103 keeps none. An SDK that lets such code hold the `2xx` back SHOULD require that code to pass the `2xx` on 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 without 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 without a `2xx`, plus the receipt-delay excess for a copy served before the SDK's local cache learned of the `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. --- From 9fc3d4cfe4a8b0fd8ce60def3f45b24d6099b19a Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Mon, 2 Nov 2026 23:44:19 +0000 Subject: [PATCH 2/5] docs(saas-api): state the receipt-delay excess under API-103 Review follow-up: no separate MUST id (API-103 already binds the copy once the local cache learns of the 2xx); the excess covers only code the application supplies; receipt is the transport's receipt of the 2xx; the write-path tolerance runs to the later of receipt and the local write; the local-term sentence no longer counts the excess twice. --- .../20261102_write-behind-receipt-delay.md | 15 +++++++++------ conformance/coverage.md | 11 +++++------ conformance/requirements.json | 9 ++------- sdk-feature-matrix.md | 2 +- spec/saas-api.md | 4 ++-- 5 files changed, 19 insertions(+), 22 deletions(-) diff --git a/changelog.d/20261102_write-behind-receipt-delay.md b/changelog.d/20261102_write-behind-receipt-delay.md index 97743c2..d2a4705 100644 --- a/changelog.d/20261102_write-behind-receipt-delay.md +++ b/changelog.d/20261102_write-behind-receipt-delay.md @@ -4,12 +4,15 @@ own copy: an SDK that serves its copy before its local cache learns whether the store accepted the write (write-behind) treats that copy as one kept without a `2xx` ([API-36](spec/saas-api.md#api-36), [API-37](spec/saas-api.md#api-37)) until the local cache - learns of the `2xx`. From then it bounds the copy by [API-103](spec/saas-api.md#api-103) at - once, with the clock still started at receipt ([API-108](spec/saas-api.md#api-108), new). -- Code between an SDK's transport and its local cache, such as an application-supplied backend + learns of the `2xx`. From then [API-103](spec/saas-api.md#api-103) binds it at once, 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. For that delay the copy can outlive API-103's bound by the delay less `fresh_for`, and never by more than `min(local_ttl, effective_ttl)` less `fresh_for`: - the new **receipt-delay excess**. A `fresh_for` of `0`, which keeps no copy, does not shorten - that delay. An SDK that admits such code SHOULD require it to pass the `2xx` on as soon as it - arrives. The excess is added to the revocation-propagation sum. + the new **receipt-delay excess**. With a `fresh_for` of `0`, which keeps no copy, the copy still + serves for the delay, up to that cap. An SDK that lets such code hold the `2xx` back SHOULD + require it to pass the `2xx` on as soon as it arrives. The excess is added to the + revocation-propagation sum. +- 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, and no SDK that fills its copy only after its write returns is affected. diff --git a/conformance/coverage.md b/conformance/coverage.md index 5fef22c..d7cc7c5 100644 --- a/conformance/coverage.md +++ b/conformance/coverage.md @@ -25,14 +25,14 @@ How ids are assigned, and what each status means: [README.md](README.md#coverage | [`spec/file-backend-format.md`](../spec/file-backend-format.md) | 6 | 1 | 3 | 0 | 2 | | [`spec/intent-presets.md`](../spec/intent-presets.md) | 55 | 0 | 19 | 0 | 36 | | [`spec/interop-mode.md`](../spec/interop-mode.md) | 36 | 5 | 23 | 4 | 4 | -| [`spec/saas-api.md`](../spec/saas-api.md) | 102 | 0 | 15 | 0 | 87 | +| [`spec/saas-api.md`](../spec/saas-api.md) | 101 | 0 | 15 | 0 | 86 | | [`spec/wire-format.md`](../spec/wire-format.md) | 24 | 3 | 18 | 3 | 0 | | SDK | Covered | Partial | Uncovered | Gap | n/a | | :--- | ---: | ---: | ---: | ---: | ---: | -| cachekit-py | 12 | 86 | 5 | 85 | 57 | -| cachekit-ts | 9 | 78 | 7 | 75 | 76 | -| cachekit-rs | 4 | 69 | 3 | 71 | 98 | +| cachekit-py | 12 | 86 | 5 | 84 | 57 | +| cachekit-ts | 9 | 78 | 7 | 74 | 76 | +| cachekit-rs | 4 | 69 | 3 | 70 | 98 | Not indexed yet: [`spec/interop-v2.md`](../spec/interop-v2.md). @@ -294,12 +294,11 @@ 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. | 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 | | [API-107](../spec/saas-api.md#api-107) | *Remaining 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 … | Binds the server.
**Gap:** As API-19, for a tier that stamps a positive value on a stale, exhausted or unknown copy, or rewrites its freshness label; or stamps a positive value on a copy of a forwarded write whose PUT 2xx carried 0 or a malformed value. | n/a | n/a | n/a | -| [API-108](../spec/saas-api.md#api-108) | *Remaining Freshness*: … by API-36 and API-37 from its local write. Once the local cache learns of the `2xx`, the SDK **MUST** bound the copy by API-103 at once, with the clock still started when the SDK received the `2xx`. The local cache can … | **Gap:** As API-103, for an SDK that serves its copy before its local cache learns of the PUT 2xx (write-behind) and, once it learns, keeps the copy's earlier bound, or restarts the clock at that moment instead of at receipt. | gap | gap | gap | Marked not-a-requirement in `spec/saas-api.md`: diff --git a/conformance/requirements.json b/conformance/requirements.json index af9f724..0e8964b 100644 --- a/conformance/requirements.json +++ b/conformance/requirements.json @@ -1521,7 +1521,7 @@ }, "spec/saas-api.md": { "prefix": "API", - "next": 109, + "next": 108, "requirements": { "API-1": { "section": "Authentication", @@ -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." }, "API-104": { "section": "PATCH /v1/cache/{key}/ttl", @@ -2235,11 +2235,6 @@ "section": "Remaining Freshness", "binds": "server", "gap": "As API-19, for a tier that stamps a positive value on a stale, exhausted or unknown copy, or rewrites its freshness label; or stamps a positive value on a copy of a forwarded write whose PUT 2xx carried 0 or a malformed value." - }, - "API-108": { - "section": "Remaining Freshness", - "binds": "sdk", - "gap": "As API-103, for an SDK that serves its copy before its local cache learns of the PUT 2xx (write-behind) and, once it learns, keeps the copy's earlier bound, or restarts the clock at that moment instead of at receipt." } }, "retired": { diff --git a/sdk-feature-matrix.md b/sdk-feature-matrix.md index 00320f2..4309f85 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) | 🚧 unreleased — opt-in `write_behind=True` on `main` ([cachekit-py#623](https://github.com/cachekit-io/cachekit-py/pull/623)), set by no preset: the value is serialized and put in L1 on the caller's thread, and a bounded per-function writer sends the write; the async distributed-lock path 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 (`decorators/write_behind.py`) | ❌ (no write-behind: every write fills L1 after the backend accepts it) | 🚧 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 on the caller's path, and the L2 write finishes in the background; a locked miss, a Workers call without `withExecutionContext`, a key with a write already pending, 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](spec/saas-api.md#api-108) (`writeBehind` in `src/cache-core.ts`) | ❌ | +| Write-behind on a miss (return before the L2 write lands) | 🚧 unreleased — opt-in `write_behind=True` on `main` ([cachekit-py#623](https://github.com/cachekit-io/cachekit-py/pull/623)), set by no preset: the value is serialized and put in L1 on the caller's thread, and a bounded per-function writer sends the write; the async distributed-lock path 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 (`decorators/write_behind.py`) | ❌ (no write-behind: every write fills L1 after the backend accepts it) | 🚧 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 locked miss, 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 4982c7a..4f465cb 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`; a copy served before the SDK's local cache learns of that `2xx` 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 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. An SDK may also serve its copy before its local cache learns whether the store accepted the write, as a write-behind cache does when it fills the copy before it sends the `PUT`. Until the local cache learns of a `2xx`, that copy is a copy kept without one, bounded by API-36 and API-37 from its local write. Once the local cache learns of the `2xx`, the SDK MUSTAPI-108 bound the copy by API-103 at once, with the clock still started when the SDK received the `2xx`. The local cache can learn of a `2xx` later than that: code between the SDK's transport and its local cache, such as a backend wrapper the application supplies, can hold the `2xx` back. For that delay the copy can outlive API-103's bound by the delay less `fresh_for`, and never by more than `min(local_ttl, effective_ttl)` less `fresh_for`, because its API-36 clock started at its local write, before receipt. This is this section's **receipt-delay excess**. When `fresh_for` is `0`, a malformed header included, the copy serves for the whole delay, although API-103 keeps none. An SDK that lets such code hold the `2xx` back SHOULD require that code to pass the `2xx` on as soon as it arrives. +- **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`; a copy served before the SDK's local cache learns of that `2xx` 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 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 instant the SDK's transport receives the `2xx`, the first instant the SDK knows the write was accepted (its local cache may learn of it later, below), 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 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. An SDK may also serve its copy before its local cache learns whether the store accepted the write, as a write-behind cache does when it fills the copy before it sends the `PUT`. Until the local cache learns of a `2xx`, that copy is a copy kept without one, bounded by API-36 and API-37 from its local write. Once the local cache learns of the `2xx`, API-103 binds the copy at once, its clock started at receipt. Code the application supplies between the SDK's transport and its local cache, such as a backend wrapper, can hold the `2xx` back, so the local cache learns of it after receipt. For that delay the copy can outlive API-103's bound by the delay less `fresh_for`, and never by more than `min(local_ttl, effective_ttl)` less `fresh_for`, because its API-36 clock started at its local write, before receipt. This is this section's **receipt-delay excess**; the SDK's own code holds no `2xx` back under it. When `fresh_for` is `0`, a malformed header included, the copy serves for the delay, up to `min(local_ttl, effective_ttl)` from its local write, although API-103 keeps none. An SDK that lets such code hold the `2xx` back SHOULD require that code to pass the `2xx` on 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 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 without a `2xx`, plus the receipt-delay excess for a copy served before the SDK's local cache learned of the `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 without 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 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. --- From 1ecb00c3ae0e1713fcd31bf2ef64f5ccedf5af37 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Mon, 2 Nov 2026 23:58:29 +0000 Subject: [PATCH 3/5] docs(saas-api): make the receipt-delay excess normative and cover a late or instant-less 2xx Review follow-up: API-103's exception names the excess; API-36/37 cover any copy kept before the local cache learns of a 2xx; the excess is up to the hold time (less fresh_for when the receipt instant is passed on), capped only for a copy filled before the local cache learned; it is accepted only for application-supplied code; the SHOULD asks for the receipt instant too. --- .../20261102_write-behind-receipt-delay.md | 27 ++++++++++--------- conformance/coverage.md | 2 +- sdk-feature-matrix.md | 2 +- spec/saas-api.md | 2 +- 4 files changed, 18 insertions(+), 15 deletions(-) diff --git a/changelog.d/20261102_write-behind-receipt-delay.md b/changelog.d/20261102_write-behind-receipt-delay.md index d2a4705..ede2419 100644 --- a/changelog.d/20261102_write-behind-receipt-delay.md +++ b/changelog.d/20261102_write-behind-receipt-delay.md @@ -1,18 +1,21 @@ ### 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: an SDK that serves its copy before its local cache learns whether the store accepted - the write (write-behind) treats that copy as one kept without a `2xx` - ([API-36](spec/saas-api.md#api-36), [API-37](spec/saas-api.md#api-37)) until the local cache - learns of the `2xx`. From then [API-103](spec/saas-api.md#api-103) binds it at once, its clock - started at receipt. Receipt is now defined as the instant the SDK's transport receives the `2xx`. + 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. For that delay the copy can outlive API-103's bound by the - delay less `fresh_for`, and never by more than `min(local_ttl, effective_ttl)` less `fresh_for`: - the new **receipt-delay excess**. With a `fresh_for` of `0`, which keeps no copy, the copy still - serves for the delay, up to that cap. An SDK that lets such code hold the `2xx` back SHOULD - require it to pass the `2xx` on as soon as it arrives. The excess is added to the - revocation-propagation sum. + wrapper, can hold the `2xx` back, or pass it on without its receipt instant. The copy can then + outlive API-103's bound by up to the time the `2xx` was held back (that time less `fresh_for` + when the receipt instant is passed on): the new **receipt-delay excess**. A copy filled before + the local cache learned of the `2xx` never exceeds the bound by more than + `min(local_ttl, effective_ttl)` less `fresh_for`; a copy filled when it learned has no ceiling. + Serving within the excess conforms to API-103 only where code the application supplies held the + `2xx` back, never the SDK's own code. An SDK that lets such code hold the `2xx` back SHOULD + require it to pass the `2xx` on, with its receipt instant, as soon as it arrives. The excess is + added to the revocation-propagation sum. - 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, and no SDK that fills its copy only after its write returns is affected. +- No wire change. diff --git a/conformance/coverage.md b/conformance/coverage.md index d7cc7c5..a8cc00a 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 | diff --git a/sdk-feature-matrix.md b/sdk-feature-matrix.md index 4309f85..12e85eb 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) | 🚧 unreleased — opt-in `write_behind=True` on `main` ([cachekit-py#623](https://github.com/cachekit-io/cachekit-py/pull/623)), set by no preset: the value is serialized and put in L1 on the caller's thread, and a bounded per-function writer sends the write; the async distributed-lock path 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 (`decorators/write_behind.py`) | ❌ (no write-behind: every write fills L1 after the backend accepts it) | 🚧 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 locked miss, 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`) | ❌ | +| Write-behind on a miss (return before the L2 write lands) | 🚧 unreleased — opt-in `write_behind=True` on `main` ([cachekit-py#623](https://github.com/cachekit-io/cachekit-py/pull/623)), set by no preset: the value is serialized and put in L1 on the caller's thread, and a bounded per-function writer sends the write; the async distributed-lock path 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 (`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 4f465cb..c56c8f3 100644 --- a/spec/saas-api.md +++ b/spec/saas-api.md @@ -175,7 +175,7 @@ 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`; a copy served before the SDK's local cache learns of that `2xx` 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 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 instant the SDK's transport receives the `2xx`, the first instant the SDK knows the write was accepted (its local cache may learn of it later, below), 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 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. An SDK may also serve its copy before its local cache learns whether the store accepted the write, as a write-behind cache does when it fills the copy before it sends the `PUT`. Until the local cache learns of a `2xx`, that copy is a copy kept without one, bounded by API-36 and API-37 from its local write. Once the local cache learns of the `2xx`, API-103 binds the copy at once, its clock started at receipt. Code the application supplies between the SDK's transport and its local cache, such as a backend wrapper, can hold the `2xx` back, so the local cache learns of it after receipt. For that delay the copy can outlive API-103's bound by the delay less `fresh_for`, and never by more than `min(local_ttl, effective_ttl)` less `fresh_for`, because its API-36 clock started at its local write, before receipt. This is this section's **receipt-delay excess**; the SDK's own code holds no `2xx` back under it. When `fresh_for` is `0`, a malformed header included, the copy serves for the delay, up to `min(local_ttl, effective_ttl)` from its local write, although API-103 keeps none. An SDK that lets such code hold the `2xx` back SHOULD require that code to pass the `2xx` on as soon as it arrives. +- **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, 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 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 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 when it first dispatched the `PUT`, 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, as API-36's dispatch-to-write term has none. This is this section's **receipt-delay excess**. A copy served within it conforms to API-103 only where code the application supplies held the `2xx` back, never the SDK's own code. An SDK that lets such code hold the `2xx` back SHOULD require that code to pass the `2xx` on, with its receipt instant, 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 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 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. From 2a4139d89d179743a03a07fb152d7c8af44657d5 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Tue, 3 Nov 2026 00:04:37 +0000 Subject: [PATCH 4/5] docs(saas-api): anchor the no-2xx copy's recommended bound no later than its local write A write-behind copy is filled before its PUT is dispatched, so measuring its bound from first dispatch would start it after the local write that API-36 requires as the latest anchor. --- 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 c56c8f3..e6cc895 100644 --- a/spec/saas-api.md +++ b/spec/saas-api.md @@ -175,7 +175,7 @@ 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`. The one exception is a copy whose local cache learns of that `2xx` late, 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 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 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 when it first dispatched the `PUT`, 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, as API-36's dispatch-to-write term has none. This is this section's **receipt-delay excess**. A copy served within it conforms to API-103 only where code the application supplies held the `2xx` back, never the SDK's own code. An SDK that lets such code hold the `2xx` back SHOULD require that code to pass the `2xx` on, with its receipt instant, as soon as it arrives. +- **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, 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 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 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, as API-36's dispatch-to-write term has none. This is this section's **receipt-delay excess**. A copy served within it conforms to API-103 only where code the application supplies held the `2xx` back, never the SDK's own code. An SDK that lets such code hold the `2xx` back SHOULD require that code to pass the `2xx` on, with its receipt instant, 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 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 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. From 88be2eae8b081556ae969047caec0d540989dd09 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Tue, 3 Nov 2026 00:09:40 +0000 Subject: [PATCH 5/5] docs(saas-api): cover a 2xx the local cache never learns of; one name for the pre-learning copy Review follow-up: API-103's exception covers a 2xx learned late or never; application code that drops the 2xx or its Fresh-For leaves the copy under API-36/37; the plan-maximum excess, the revocation sum and the local term use the re-keyed API-36 wording. --- .../20261102_write-behind-receipt-delay.md | 17 ++++++++--------- conformance/coverage.md | 2 +- conformance/requirements.json | 2 +- spec/saas-api.md | 4 ++-- 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/changelog.d/20261102_write-behind-receipt-delay.md b/changelog.d/20261102_write-behind-receipt-delay.md index ede2419..3a329df 100644 --- a/changelog.d/20261102_write-behind-receipt-delay.md +++ b/changelog.d/20261102_write-behind-receipt-delay.md @@ -7,15 +7,14 @@ [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, or pass it on without its receipt instant. The copy can then - outlive API-103's bound by up to the time the `2xx` was held back (that time less `fresh_for` - when the receipt instant is passed on): the new **receipt-delay excess**. A copy filled before - the local cache learned of the `2xx` never exceeds the bound by more than - `min(local_ttl, effective_ttl)` less `fresh_for`; a copy filled when it learned has no ceiling. - Serving within the excess conforms to API-103 only where code the application supplies held the - `2xx` back, never the SDK's own code. An SDK that lets such code hold the `2xx` back SHOULD - require it to pass the `2xx` on, with its receipt instant, as soon as it arrives. The excess is - added to the revocation-propagation sum. + 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 a8cc00a..f52873f 100644 --- a/conformance/coverage.md +++ b/conformance/coverage.md @@ -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; 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. | 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 0e8964b..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; 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." + "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/spec/saas-api.md b/spec/saas-api.md index e6cc895..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`. The one exception is a copy whose local cache learns of that `2xx` late, 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 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 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, as API-36's dispatch-to-write term has none. This is this section's **receipt-delay excess**. A copy served within it conforms to API-103 only where code the application supplies held the `2xx` back, never the SDK's own code. An SDK that lets such code hold the `2xx` back SHOULD require that code to pass the `2xx` on, with its receipt instant, as soon as it arrives. +- **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 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 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. ---