From 1a847ecb25c9e9f2cd4df75688e4baa9d5d984d3 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 31 Oct 2026 20:07:29 +1100 Subject: [PATCH 1/8] docs(saas-api): per-plan maximum TTL, clamped, and a 90-day TTL range Each plan now has a maximum TTL (Free 24 h, Startup 30 d, Professional 90 d, Enterprise none). On a plan with one, a stated TTL above it is stored with the maximum rather than rejected, and a write without a TTL is stored with the maximum rather than with no expiry. The maximum bounds the whole stored life, fresh window first. The stated-TTL value range rises to 7,776,000; X-CacheKit-Fresh-For keeps its 2,592,000 ceiling. A PUT 2xx now reports the bound the store applied as X-CacheKit-Fresh-For, and the writer's own copy and a forwarding tier's copy are bounded by it, so a clamped write's local copy cannot outlive the stored entry. Requirement ids whose demand changed are retired and re-minted. --- changelog.d/20261031_plan-maximum-ttl.md | 31 +++++++++ conformance/coverage.md | 34 +++++----- conformance/requirements.json | 84 ++++++++++++++++-------- sdk-feature-matrix.md | 4 +- spec/saas-api.md | 77 +++++++++++++++------- 5 files changed, 162 insertions(+), 68 deletions(-) create mode 100644 changelog.d/20261031_plan-maximum-ttl.md diff --git a/changelog.d/20261031_plan-maximum-ttl.md b/changelog.d/20261031_plan-maximum-ttl.md new file mode 100644 index 0000000..aa39cf5 --- /dev/null +++ b/changelog.d/20261031_plan-maximum-ttl.md @@ -0,0 +1,31 @@ +### SaaS API — per-plan maximum TTL, clamped; a 90-day TTL range; the applied bound on the `PUT` response + +- [`spec/saas-api.md` § Plan maximum TTL](spec/saas-api.md#plan-maximum-ttl) (new): Free 86,400 s + (24 hours), Startup 2,592,000 s (30 days), Professional 7,776,000 s (90 days), Enterprise none. + On a plan with a maximum, a stated TTL above it is stored with the maximum, not rejected, and a + write without a TTL is stored with the maximum instead of with no expiry + ([API-77](spec/saas-api.md#api-77)). The maximum bounds the entry's whole stored life: the fresh + window is kept first and the stale window gets what is left. `PATCH /ttl` is clamped the same + way ([API-78](spec/saas-api.md#api-78)). Validation stays plan-independent, so a client can still + pre-validate every write. On Enterprise, and for entries stored before the change, nothing + changes. +- The value range of `X-CacheKit-TTL`, of `PATCH /ttl`'s `ttl` and of `ttl + stale_ttl` rises from + 2,592,000 to 7,776,000 (`stale_ttl` alone to 7,775,999). A larger value is still a `400`. +- The plan maximum is the only default for a write without a TTL + ([API-79](spec/saas-api.md#api-79), which merges and retires API-40 and API-41). +- A `PUT` `2xx` now carries `X-CacheKit-Fresh-For`: the freshness bound the store applied + ([API-80](spec/saas-api.md#api-80)), and a server that clamps must send it + ([API-81](spec/saas-api.md#api-81)). An SDK bounds its copy of its own write by + `min(local_ttl, effective_ttl, fresh_for)` ([API-85](spec/saas-api.md#api-85), replacing API-35). + A re-serving tier bounds its copy of a forwarded write the same way + ([API-82](spec/saas-api.md#api-82), replacing API-20; [API-84](spec/saas-api.md#api-84), + replacing API-27) and relays the header unraised ([API-83](spec/saas-api.md#api-83)). +- The `X-CacheKit-Fresh-For` grammar keeps its 2,592,000 maximum: a server reports an entry with + more than 30 days of freshness left as `2592000`. +- **Breaking for callers on a plan with a maximum TTL:** once the server applies this, a write + without a TTL is no longer stored with no expiry, and a TTL above the plan's maximum is + shortened to it. +- **Breaking for SDKs:** one that refuses a TTL over 2,592,000, or a `ttl + stale_ttl` over it, + refuses writes the server accepts. One that ignores `X-CacheKit-Fresh-For` on the `PUT` response + keeps its copy of a clamped write for the stated TTL, past the stored entry's `fresh_until`. The + [feature matrix](sdk-feature-matrix.md) records each SDK's state. diff --git a/conformance/coverage.md b/conformance/coverage.md index 60c5074..87c20f7 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) | 76 | 0 | 15 | 0 | 61 | +| [`spec/saas-api.md`](../spec/saas-api.md) | 80 | 0 | 15 | 0 | 65 | | [`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 | 69 | 47 | -| cachekit-ts | 9 | 78 | 7 | 59 | 66 | -| cachekit-rs | 4 | 69 | 3 | 55 | 88 | +| cachekit-py | 12 | 86 | 5 | 69 | 51 | +| cachekit-ts | 9 | 78 | 7 | 59 | 70 | +| cachekit-rs | 4 | 69 | 3 | 55 | 92 | Not indexed yet: [`spec/interop-v2.md`](../spec/interop-v2.md). @@ -217,28 +217,23 @@ Marked not-a-requirement in `spec/intent-presets.md`: | [API-17](../spec/saas-api.md#api-17) | *Encoding rules*: Clients **MUST NOT** double-encode. A literal `%` in a key is sent as `%25` once; `%2525` decodes to `%25`, a different key. | `path-encoding.json`: `100%`, `ns:key`, `%2e%2e`, `User%3AKey`
`tools/path-encoding-verify.py:verify`
**Gap:** The rows pin one call of the encoder: a client that hands the encoded segment to a URL builder that escapes '%' again, turning ns%3Akey into ns%253Akey, passes them unless its test asserts on the parsed request path (API-15). | partial (gap) | partial (gap) | partial (gap) | | [API-18](../spec/saas-api.md#api-18) | *GET /v1/cache/{key}*: … on every `GET` `200 OK` by servers implementing stale-while-revalidate. For `GET` responses, SDKs **MUST** treat an absent header as `fresh` (pre-SWR servers do not emit it) and an unrecognized value as `stale` (revalidation … | `freshness-headers.json`: `freshness_absent`, `freshness_fresh`, `freshness_stale`, `freshness_capitalised`, `freshness_uppercase_stale`, `freshness_empty`, `freshness_nbsp_suffix`, `freshness_list_fresh_first`, `freshness_list_fresh_last`
`tools/freshness-headers-verify.py:verify`
**Gap:** The rows reach the parser an SDK's test hands each value, not its read path: whether it applies the result to the GET 200 it received where it backfills a local cache (API-58), revalidates or treats a stale hit as a miss, 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-19](../spec/saas-api.md#api-19) | *Remaining Freshness*: … fresh service only). Because absence carries that meaning, a tier that implements this section **MUST NOT** produce it for a copy it cannot positively confirm has no expiry — the tier rule below. | Binds the server.
**Gap:** No public vector exercises a re-serving tier, so nothing here catches a tier that passes a pre-signal store's missing X-CacheKit-Fresh-For through, or omits it for a copy it populated without a hint. | n/a | n/a | n/a | -| [API-20](../spec/saas-api.md#api-20) | *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. | n/a | n/a | n/a | -| [API-21](../spec/saas-api.md#api-21) | *Remaining Freshness*: … (whole-second stamping), so the copy's bound ends less than one second after `fresh_until`). It **MUST NOT** emit a value larger than that decayed remainder — its own copy TTL included — under any label. The deployed tiers … | Binds the server.
**Gap:** As API-19, for a tier that emits its own copy TTL when that is larger than the decayed remainder. | n/a | n/a | n/a | -| [API-22](../spec/saas-api.md#api-22) | *Remaining Freshness*: … an accepted write that carried no TTL at all — neither `X-CacheKit-TTL` nor legacy `X-TTL`. A tier **MUST** determine a write's TTL with the store's own precedence (`X-CacheKit-TTL`, else `X-TTL`); the absence of … | Binds the server.
**Gap:** As API-19, for a tier that reads only X-CacheKit-TTL and so records a write that carried legacy X-TTL as unbounded. | n/a | n/a | n/a | +| [API-21](../spec/saas-api.md#api-21) | *Remaining Freshness*: … of the store's `2xx` from the response it relays, nor relay a value larger than the store's. It **MUST NOT** emit a value larger than that decayed remainder — its own copy TTL included — under any label. The deployed tiers … | Binds the server.
**Gap:** As API-19, for a tier that emits its own copy TTL when that is larger than the decayed remainder. | n/a | n/a | n/a | +| [API-22](../spec/saas-api.md#api-22) | *Remaining Freshness*: … (a write without a TTL on a plan with a maximum TTL is bounded, and its `2xx` says so). A tier **MUST** determine a write's TTL with the store's own precedence (`X-CacheKit-TTL`, else `X-TTL`); the absence of … | Binds the server.
**Gap:** As API-19, for a tier that reads only X-CacheKit-TTL and so records a write that carried legacy X-TTL as unbounded. | n/a | n/a | n/a | | [API-23](../spec/saas-api.md#api-23) | *Remaining Freshness*: … or a store below that is pre-signal and therefore omits for bounded entries too — the tier **MUST** emit `X-CacheKit-Fresh-For: 0`. It MUST NOT omit a header it received, nor drop it for such a copy: omission would … | Binds the server.
**Gap:** As API-19, for a tier that omits the header, rather than sending 0, for a copy whose remainder it does not know. | n/a | n/a | n/a | | [API-24](../spec/saas-api.md#api-24) | *Remaining Freshness*: … and therefore omits for bounded entries too — the tier MUST emit `X-CacheKit-Fresh-For: 0`. It **MUST NOT** omit a header it received, nor drop it for such a copy: omission would claim "no bound" for an entry that may have one … | Binds the server.
**Gap:** As API-19, for a tier that strips a received X-CacheKit-Fresh-For, or drops it when it re-serves the copy. | n/a | n/a | n/a | | [API-25](../spec/saas-api.md#api-25) | *Remaining Freshness*: … that may have one and silently restore the unbounded backfill this header exists to kill. A tier **MUST** therefore record at populate whether a copy is unbounded or merely unhinted; a tier that cannot tell the two apart … | Binds the server.
**Gap:** As API-19, for a tier that keeps no record at populate of whether its copy was unbounded or merely unhinted. | n/a | n/a | n/a | | [API-26](../spec/saas-api.md#api-26) | *Remaining Freshness*: … at populate whether a copy is unbounded or merely unhinted; a tier that cannot tell the two apart **MUST** emit `0`. A tier fronting a pre-signal store is in exactly that position — it emits `0`, it does not pass the absence … | Binds the server.
**Gap:** As API-19, for a tier that cannot tell an unbounded copy from an unhinted one and omits the header for both. | n/a | n/a | n/a | -| [API-27](../spec/saas-api.md#api-27) | *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. | n/a | n/a | n/a | | [API-28](../spec/saas-api.md#api-28) | *Remaining Freshness*: … on `HEAD` remains informational, per Stale-While-Revalidate). Correspondingly, a `HEAD` response **MUST NOT** create, refresh, or extend any local entry's service bound. | **Gap:** No vector reaches an SDK's local cache, so nothing here catches one that creates a local entry, or extends its deadline, on a HEAD 200. | gap | gap | gap | | [API-29](../spec/saas-api.md#api-29) | *Remaining Freshness*: … `X-CacheKit-Freshness: fresh` (or unlabelled) with the header present, a local cache (L1) backfill **MUST** bound the entry's local lifetime to at most the header value: `min(local_ttl, fresh_for)`. A value of `0` means the … | **Gap:** No vector reaches an SDK's local cache, so nothing here catches one that backfills with its configured local TTL whatever X-CacheKit-Fresh-For says. | gap | gap | gap | | [API-30](../spec/saas-api.md#api-30) | *Remaining Freshness*: … lifetime to at most the header value: `min(local_ttl, fresh_for)`. A value of `0` means the entry **MUST NOT** be backfilled at all. A `stale` or unrecognized freshness label forbids backfill regardless of the number (Reading a … | `freshness-headers.json`: `fresh_for_zero`
`tools/freshness-headers-verify.py:verify`
**Gap:** The row reaches the parser an SDK's test hands the value: a parser that reads 0 as no value fails it, but no vector reaches the local cache, so nothing here catches an SDK that parses 0 and backfills anyway (API-29). | partial (gap) | partial (gap) | partial (gap) | | [API-31](../spec/saas-api.md#api-31) | *Remaining Freshness*: … value is a hard local service bound, not merely a freshness bound: once it elapses, the local copy **MUST NOT** be served in any form — including by client-side stale-while-revalidate or any local stale-grace policy. (Serving … | **Gap:** As API-29, for an SDK that serves a local copy past its bound through client-side stale-while-revalidate or a local stale-grace policy. | gap | gap | gap | -| [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 30-day TTL cap, itself seven digits). Anything else — empty, … | 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*: … digits). Anything else — empty, non-digit, signed, longer than seven digits, or over the cap — **MUST** be treated as `0` (do not extend local service — the conservative action, mirroring the … | `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-32](../spec/saas-api.md#api-32) | *Remaining Freshness*: The value **MUST** be 1–7 ASCII digits and at most `2,592,000` (30 days, itself seven digits: the header's ceiling, which emission above … | 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*: … TTL range). 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-35](../spec/saas-api.md#api-35) | *Remaining Freshness*: … a value it wrote bounds that copy by the write itself. Once the store accepts the `PUT`, the SDK **MUST NOT** serve that copy, in any form, after `min(local_ttl, effective_ttl)` has elapsed, where `effective_ttl` is the write's … | **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, or lengthens it by X-CacheKit-Stale-TTL. | gap | gap | 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-35, 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-35, for an SDK that serves such a copy past its bound through a local stale-grace policy. | gap | gap | 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-85, 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-85, 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 | -| [API-40](../spec/saas-api.md#api-40) | *PUT /v1/cache/{key}*: … to store the entry with no expiry (see the No-expiry contract under TTL Validation Rules); servers **MUST NOT** substitute a hidden default. | Binds the server.
**Gap:** No public vector exercises the server, so nothing here catches one that stores a write without TTL headers under a tenant default TTL. | n/a | n/a | n/a | -| [API-41](../spec/saas-api.md#api-41) | *PUT /v1/cache/{key}*: … nor `X-TTL` — see No-expiry contract below. There is no tenant-default TTL mechanism: servers **MUST NOT** apply a hidden default — validation must not depend on defaults clients cannot see (the same rule as … | Binds the server.
**Gap:** As API-40, and for a server whose validation consults a default the client cannot see. | n/a | n/a | n/a | | [API-42](../spec/saas-api.md#api-42) | *PUT /v1/cache/{key}*: Round up to 1. Sub-second durations **MUST** be ceiled, never truncated to 0. | **Gap:** No vector maps a duration to a header, so nothing here catches a client that sends a sub-second TTL unrounded, or truncates it to 0 and then rejects it or sends 0. | gap | gap | gap | | [API-43](../spec/saas-api.md#api-43) | *PUT /v1/cache/{key}*: Migration: The `X-TTL` header is deprecated. The server **MUST** accept both `X-CacheKit-TTL` and `X-TTL` during the transition period, preferring `X-CacheKit-TTL` when both are … | Binds the server.
**Gap:** No public vector exercises the server, so nothing here catches one that ignores X-TTL, or prefers it to X-CacheKit-TTL when a write carries both. | n/a | n/a | n/a | | [API-44](../spec/saas-api.md#api-44) | *PUT /v1/cache/{key}*: … rule, the stale-window requirement, and the re-serving-tier rules in Remaining Freshness. SDKs **MUST** send `X-CacheKit-TTL` only. The `X-TTL` header will be removed in protocol version 2.0 (targeted at SDK 1.0 milestone). | **Gap:** No vector covers request headers, so nothing here catches a client that sends X-TTL instead of, or beside, X-CacheKit-TTL. | gap | gap | gap | @@ -274,6 +269,15 @@ Marked not-a-requirement in `spec/intent-presets.md`: | [API-74](../spec/saas-api.md#api-74) | *SSRF Protection*: All SDKs **MUST** enforce SSRF protection when accepting the API URL. The following rules apply: | **Gap:** No vector holds an API URL, so nothing here checks HTTPS-only, the listed private ranges or the hostname allowlist. | gap | gap | gap | | [API-75](../spec/saas-api.md#api-75) | *Counting outcomes*: Plain read. A value read **MUST** count one outcome: an L1 hit when the in-memory layer served it, an L2 hit when a backend read did, or a miss when … | **Gap:** No vector covers request headers, so nothing here catches a client that counts a plain read twice, or counts a read that failed at the backend; only the SDK's own counter tests do. | gap | gap | gap | | [API-76](../spec/saas-api.md#api-76) | *Counting outcomes*: … caching wrapper (the cachekit-py decorators, cachekit-ts `wrap()`, cachekit-rs `#[cachekit]`) **MUST** count one outcome, however many reads it makes. A call that runs the wrapped function counts one miss, before its … | **Gap:** No vector covers request headers, so nothing here catches a client that counts a wrapped call that runs the function as anything but one miss, a joined in-process caller as anything but one L1 hit, or a miss on each single-flight re-poll or lock re-check; only the SDK's own counter tests do. | gap | gap | gap | +| [API-77](../spec/saas-api.md#api-77) | *Plan maximum TTL*: … so a client can pre-validate every write. Then, on a plan with a maximum TTL `max_ttl`, the server **MUST** store the write with | Binds the server.
**Gap:** No public vector exercises the server, so nothing here catches one that rejects a TTL over the tenant's plan maximum, stores it unclamped, or keeps a stale window that carries the entry past the maximum. | n/a | n/a | n/a | +| [API-78](../spec/saas-api.md#api-78) | *Plan maximum TTL*: … so a write whose TTL reaches the maximum stores no stale window. A `PATCH /v1/cache/{key}/ttl` **MUST** be clamped the same way, with its `ttl` in place of `effective_ttl` and the entry's stored stale window in place of … | Binds the server.
**Gap:** As API-77, for a PATCH /ttl handler that stores the requested ttl past the plan maximum, or shortens the fresh window to make room for the stored stale window. | n/a | n/a | n/a | +| [API-79](../spec/saas-api.md#api-79) | *PUT /v1/cache/{key}*: … a plan without one — see No-expiry contract below. The plan maximum is the only default: servers **MUST NOT** apply any other, because a client must be able to know what its write stores. The plan maximum is published in this … | Binds the server.
**Gap:** No public vector exercises the server, so nothing here catches one that stores a write without TTL headers under any default other than the tenant's plan maximum TTL. | n/a | n/a | n/a | +| [API-80](../spec/saas-api.md#api-80) | *Remaining Freshness*: Emitted on a write, too. A signal-capable server **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 store that answers a bounded PUT without X-CacheKit-Fresh-For, or reports the stated TTL instead of the one it applied. | n/a | n/a | n/a | +| [API-81](../spec/saas-api.md#api-81) | *Remaining Freshness*: … TTL clamped the write (the writer's own copy below). A server that applies a plan maximum TTL **MUST** be signal-capable, so a `PUT` `2xx` without the header means the store applied exactly the write's effective TTL, or … | Binds the server.
**Gap:** As API-80, for a deployment that clamps writes to a plan maximum TTL before its store emits X-CacheKit-Fresh-For on PUT. | n/a | n/a | n/a | +| [API-82](../spec/saas-api.md#api-82) | *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-83](../spec/saas-api.md#api-83) | *Remaining Freshness*: … and one from the effective TTL ends less than one second after it. A tier that forwards a `PUT` **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 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-84](../spec/saas-api.md#api-84) | *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. | n/a | n/a | n/a | +| [API-85](../spec/saas-api.md#api-85) | *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 | Marked not-a-requirement in `spec/saas-api.md`: diff --git a/conformance/requirements.json b/conformance/requirements.json index 16a81fa..3816d49 100644 --- a/conformance/requirements.json +++ b/conformance/requirements.json @@ -1521,7 +1521,7 @@ }, "spec/saas-api.md": { "prefix": "API", - "next": 77, + "next": 86, "requirements": { "API-1": { "section": "Authentication", @@ -1763,11 +1763,6 @@ "binds": "server", "gap": "No public vector exercises a re-serving tier, so nothing here catches a tier that passes a pre-signal store's missing X-CacheKit-Fresh-For through, or omits it for a copy it populated without a hint." }, - "API-20": { - "section": "Remaining Freshness", - "binds": "server", - "gap": "As API-19, for a tier that stamps each hit with its copy's original bound instead of the decayed remainder." - }, "API-21": { "section": "Remaining Freshness", "binds": "server", @@ -1798,11 +1793,6 @@ "binds": "server", "gap": "As API-19, for a tier that cannot tell an unbounded copy from an unhinted one and omits the header for both." }, - "API-27": { - "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." - }, "API-28": { "section": "Remaining Freshness", "binds": "sdk", @@ -1875,20 +1865,15 @@ ], "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)." }, - "API-35": { - "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, or lengthens it by X-CacheKit-Stale-TTL." - }, "API-36": { "section": "Remaining Freshness", "binds": "sdk", - "gap": "As API-35, 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": "As API-85, 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." }, "API-37": { "section": "Remaining Freshness", "binds": "sdk", - "gap": "As API-35, for an SDK that serves such a copy past its bound through a local stale-grace policy." + "gap": "As API-85, for an SDK that serves such a copy past its bound through a local stale-grace policy." }, "API-38": { "section": "Remaining Freshness", @@ -1900,16 +1885,6 @@ "binds": "caller", "gap": "As API-38: whether a key whose TTL is a revocation boundary is written with an explicit TTL is the application's configuration." }, - "API-40": { - "section": "PUT /v1/cache/{key}", - "binds": "server", - "gap": "No public vector exercises the server, so nothing here catches one that stores a write without TTL headers under a tenant default TTL." - }, - "API-41": { - "section": "PUT /v1/cache/{key}", - "binds": "server", - "gap": "As API-40, and for a server whose validation consults a default the client cannot see." - }, "API-42": { "section": "PUT /v1/cache/{key}", "binds": "sdk", @@ -2110,9 +2085,60 @@ "section": "Counting outcomes", "binds": "sdk", "gap": "No vector covers request headers, so nothing here catches a client that counts a wrapped call that runs the function as anything but one miss, a joined in-process caller as anything but one L1 hit, or a miss on each single-flight re-poll or lock re-check; only the SDK's own counter tests do." + }, + "API-77": { + "section": "Plan maximum TTL", + "binds": "server", + "gap": "No public vector exercises the server, so nothing here catches one that rejects a TTL over the tenant's plan maximum, stores it unclamped, or keeps a stale window that carries the entry past the maximum." + }, + "API-78": { + "section": "Plan maximum TTL", + "binds": "server", + "gap": "As API-77, for a PATCH /ttl handler that stores the requested ttl past the plan maximum, or shortens the fresh window to make room for the stored stale window." + }, + "API-79": { + "section": "PUT /v1/cache/{key}", + "binds": "server", + "gap": "No public vector exercises the server, so nothing here catches one that stores a write without TTL headers under any default other than the tenant's plan maximum TTL." + }, + "API-80": { + "section": "Remaining Freshness", + "binds": "server", + "gap": "No public vector exercises the server's responses, so nothing here catches a store that answers a bounded PUT without X-CacheKit-Fresh-For, or reports the stated TTL instead of the one it applied." + }, + "API-81": { + "section": "Remaining Freshness", + "binds": "server", + "gap": "As API-80, for a deployment that clamps writes to a plan maximum TTL before its store emits X-CacheKit-Fresh-For on PUT." + }, + "API-82": { + "section": "Remaining Freshness", + "binds": "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." + }, + "API-83": { + "section": "Remaining Freshness", + "binds": "server", + "gap": "As API-19, for a tier that drops the X-CacheKit-Fresh-For of the store's PUT 2xx, or relays a larger value." + }, + "API-84": { + "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." + }, + "API-85": { + "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." } }, - "retired": {} + "retired": { + "API-20": "Replaced by API-82: a tier's copy of a forwarded write takes its bound from the X-CacheKit-Fresh-For on the store's PUT 2xx when that carries one.", + "API-27": "Replaced by API-84: an accepted write qualifies for a positive value by its bound, which the store's PUT 2xx can shorten, not by its stated TTL.", + "API-35": "Replaced by API-85: the writer's copy is also bounded by the X-CacheKit-Fresh-For on the PUT 2xx.", + "API-40": "Merged with API-41 into API-79: a write without a TTL is stored with the plan maximum TTL on a plan that has one.", + "API-41": "Merged with API-40 into API-79: a write without a TTL is stored with the plan maximum TTL on a plan that has one." + } }, "spec/wire-format.md": { "prefix": "WIRE", diff --git a/sdk-feature-matrix.md b/sdk-feature-matrix.md index 9c92bbf..451130d 100644 --- a/sdk-feature-matrix.md +++ b/sdk-feature-matrix.md @@ -186,7 +186,7 @@ The contract a storage backend must satisfy per SDK (bytes in / bytes out; seria | 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`) | ❌ | ❌ | ❌ | -| 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 | ✅ `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) | ✅ `@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 | ❌ | +| 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 where a [plan maximum TTL](spec/saas-api.md#plan-maximum-ttl) stored a shorter one ([API-85](spec/saas-api.md#api-85)) | ✅ `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 where a [plan maximum TTL](spec/saas-api.md#plan-maximum-ttl) stored a shorter one ([API-85](spec/saas-api.md#api-85)) | ✅ `@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 where a [plan maximum TTL](spec/saas-api.md#plan-maximum-ttl) stored a shorter one ([API-85](spec/saas-api.md#api-85)) | ❌ | > [!IMPORTANT] > ¹³ **The Rust reliability tier ships in `cachekit-rs` 0.6.0+ and is on by default.** Verified inside the published artifact, not the branch: the `cachekit-rs` 0.6.0 `.crate` from crates.io (published 2026-08-03T14:58:16Z) contains `src/reliability.rs`, `src/flight.rs`, `tests/reliability_tests.rs`, and `get_with_swr` in `src/l1/mod.rs`, and its `Cargo.toml` declares `default = ["cachekitio", "encryption", "l1", "reliability"]`. So a plain `cargo add cachekit-rs` gets **circuit breaker, retry, backpressure and L1 SWR** with no feature flags. Two of the six cells need an opt-in feature: macro-level graceful degradation and the automatic `#[cachekit]` single-flight wiring are emitted by the proc-macro, and `macros = ["dep:cachekit-macros"]` is **not** in `default` — add `--features macros`. Redis-backed presets likewise need the non-default `redis` feature (see [Developer Experience](#developer-experience) note ¹¹). @@ -299,7 +299,7 @@ its spec: | Temporal sentinels ([spec](spec/wire-format.md#datetime-extension-format)) | ✅ PyPI 0.22.0 (wheel run 2026-10-07): the default path revives `__datetime__`, `__date__` and `__time__` (each `temporal_sentinel_vectors` payload under `StandardSerializer`), as do `AutoSerializer` and interop mode, and all three write them; the three `wire-format.json` 1.4.0 `temporal_sentinel_vectors` run in CI since 0.23.0 ([cachekit-py#550](https://github.com/cachekit-io/cachekit-py/pull/550)) | ❌ crates.io 0.10.0 (`.crate` inspected 2026-10-07): revives none and writes none, in either mode; interop's value profile refuses a datetime; `main` at `b5fb2a6` likewise (no sentinel reviver in `src/`). cachekit-rs vendors no `wire-format.json` | ❌ npm 0.2.1 (`.tgz` inspected 2026-10-24; 0.1.5, inspected 2026-10-07, behaves the same): the default path revives none, and writes a `Date` as its ISO string (`dist/serialization/serializer.js`); interop mode revives only `__datetime__`, leaving `__date__` and `__time__` as maps, since JavaScript has no date-only or time-only type. Since 0.2.1 the `wire-format.json` 1.4.0 `temporal_sentinel_vectors` run in both lanes as expected failures naming [WIRE-20](spec/wire-format.md#wire-20), each pinning the map the read returns ([cachekit-ts#229](https://github.com/cachekit-io/cachekit-ts/pull/229)) | ⚠️ Untested | | Encryption (AES-256-GCM) | ✅ Compliant | ✅ Canonical (cachekit-core) | ✅ Compliant | ⚠️ Untested | | AAD v0x03 | ✅ Compliant 0.23.0+ (5 components — every auto serializer appends `original_type`; interop mode is the sole 4-component path; wheels tested 2026-10-19). Earlier releases fall short: through 0.22.0 the `cache_key` component omits a prefixing backend's key prefix, short of the [logical backend key](spec/encryption.md#additional-authenticated-data-aad) ([cachekit-py#518](https://github.com/cachekit-io/cachekit-py/pull/518); see the Encryption table); and, against [ENC-2](spec/encryption.md#enc-2)/[ENC-3](spec/encryption.md#enc-3), 0.9.0 through 0.22.0 accept bare Arrow IPC on an encrypted Arrow read ([cachekit-py#517](https://github.com/cachekit-io/cachekit-py/pull/517)), 0.9.0 through 0.22.0 sniff the decrypted plaintext on a config-drift read by an `auto` handler with encryption off of an entry stored under the `auto` serializer name ([cachekit-py#521](https://github.com/cachekit-io/cachekit-py/pull/521)), 0.1.0 through 0.22.0 sniff it in a directly built `EncryptionWrapper(serializer=AutoSerializer())` ([cachekit-py#521](https://github.com/cachekit-io/cachekit-py/pull/521)), and 0.1.0 through 0.4.0 also sniff it with the default `EncryptionWrapper` constructor and on every encrypted read by a cache handler built with `encryption=True`, not from 0.6.1 (see the Encryption section) | ✅ Compliant (4 components) | ✅ Compliant (4 components) | ❌ Not implemented | -| SaaS API | ⚠️ Compliant (CachekitIO backend) except, through `cachekit` 0.22.0 (wheel inspected 2026-10-05): a PUT with a TTL also sends the legacy `X-TTL` ([API-44](spec/saas-api.md#api-44)); any fractional `ttl`, the window `@cache.io` derives from it, and a fractional `refresh_ttl` value go on the wire unrounded, so the server answers `400` and the write is lost ([API-42](spec/saas-api.md#api-42), [API-54](spec/saas-api.md#api-54)); both fixed in 0.23.0 ([cachekit-py#520](https://github.com/cachekit-io/cachekit-py/pull/520)). Where the [TTL rules](spec/saas-api.md#put-v1cachekey) have the SDK reject a TTL with an error to the caller, `ttl=0` and a TTL over 2,592,000 are sent instead, so the server answers `400` and the write is lost | ⚠️ Compliant (CachekitIO backend) except, through `cachekit-rs` 0.10.0 (`.crate` inspected 2026-10-05): `refresh_ttl` on the CachekitIO and Workers backends rejects a sub-second TTL instead of sending 1 ([API-42](spec/saas-api.md#api-42); `PATCH /ttl` takes the `X-CacheKit-TTL` rules), fixed in 0.11.0 ([cachekit-rs#142](https://github.com/cachekit-io/cachekit-rs/pull/142)); and a TTL over 2,592,000 is sent, where the [TTL rules](spec/saas-api.md#put-v1cachekey) have the SDK reject it, so the server answers `400` and the write is lost (`validate_ttl` rejects only zero) | ✅ Compliant (CachekitIO backend) from npm 0.2.1 on the [TTL rules](spec/saas-api.md#put-v1cachekey), [API-44](spec/saas-api.md#api-44) and [API-65](spec/saas-api.md#api-65) (`.tgz` inspected 2026-10-24): a PUT sends only `X-CacheKit-TTL`, ceiled to whole seconds, and refuses a zero, negative, non-finite or over-2,592,000 TTL with `ConfigurationError` before sending ([cachekit-ts#110](https://github.com/cachekit-io/cachekit-ts/pull/110); `validateTtl` in `dist/backends/cachekitio.js`); 🚧 unreleased, on `main` for npm 0.3.0 ([cachekit-ts#274](https://github.com/cachekit-io/cachekit-ts/pull/274)): a `wrap()` with `staleTtl` also sends `X-CacheKit-Stale-TTL`, ceiled to whole seconds and checked against [API-54](spec/saas-api.md#api-54) and the 30-day cap before sending (`validateStaleTtl`), and every write of that key sends it again ([API-55](spec/saas-api.md#api-55)); a lock response is a lease only when its `lock_id` is a non-empty string ([API-65](spec/saas-api.md#api-65); [cachekit-ts#215](https://github.com/cachekit-io/cachekit-ts/pull/215); `dist/backends/cachekitio-lockable.js`); `src/backends/cachekitio-ttl.test.ts` and `src/backends/cachekitio-lockable.test.ts` pass at tag `cachekit-v0.2.1`. ⚠️ Through 0.1.5 (`.tgz` inspected 2026-10-05) a PUT sends only the legacy `X-TTL` ([API-44](spec/saas-api.md#api-44)), unrounded, omits it for `ttl` 0, storing the entry with no expiry, and sends a TTL over 2,592,000 for the server to reject with `400`; and a `200` lock response whose `lock_id` is absent, empty or not a string counts as a lease, so single-flight is off and the release sends `undefined` as the lock id | ❌ Not implemented | +| SaaS API | ⚠️ Compliant (CachekitIO backend) except, through `cachekit` 0.22.0 (wheel inspected 2026-10-05): a PUT with a TTL also sends the legacy `X-TTL` ([API-44](spec/saas-api.md#api-44)); any fractional `ttl`, the window `@cache.io` derives from it, and a fractional `refresh_ttl` value go on the wire unrounded, so the server answers `400` and the write is lost ([API-42](spec/saas-api.md#api-42), [API-54](spec/saas-api.md#api-54)); both fixed in 0.23.0 ([cachekit-py#520](https://github.com/cachekit-io/cachekit-py/pull/520)). Where the [TTL rules](spec/saas-api.md#put-v1cachekey) have the SDK reject a TTL with an error to the caller, `ttl=0` and a TTL over 7,776,000 are sent instead, so the server answers `400` and the write is lost. Through 0.25.1 (wheel inspected 2026-10-31) a decorator refuses a `ttl + stale_ttl` over 2,592,000 with `ConfigurationError`, though the [stale-window range](spec/saas-api.md#validation) now runs to 7,776,000 (`_max_total_ttl` in `decorators/wrapper.py`) | ⚠️ Compliant (CachekitIO backend) except, through `cachekit-rs` 0.10.0 (`.crate` inspected 2026-10-05): `refresh_ttl` on the CachekitIO and Workers backends rejects a sub-second TTL instead of sending 1 ([API-42](spec/saas-api.md#api-42); `PATCH /ttl` takes the `X-CacheKit-TTL` rules), fixed in 0.11.0 ([cachekit-rs#142](https://github.com/cachekit-io/cachekit-rs/pull/142)); and a TTL over 7,776,000 is sent, where the [TTL rules](spec/saas-api.md#put-v1cachekey) have the SDK reject it, so the server answers `400` and the write is lost (`validate_ttl` rejects only zero) | ⚠️ Compliant (CachekitIO backend) from npm 0.2.1 on the [TTL rules](spec/saas-api.md#put-v1cachekey), [API-44](spec/saas-api.md#api-44) and [API-65](spec/saas-api.md#api-65) (`.tgz` inspected 2026-10-24): a PUT sends only `X-CacheKit-TTL`, ceiled to whole seconds, and refuses a zero, negative, non-finite or over-2,592,000 TTL with `ConfigurationError` before sending. That last bound predates the 90-day TTL range, so through 0.2.3 (`.tgz` inspected 2026-10-31) it refuses a TTL from 2,592,001 to 7,776,000 that the server accepts (`MAX_TTL_SECONDS` in `dist/backends/cachekitio.js`) ([cachekit-ts#110](https://github.com/cachekit-io/cachekit-ts/pull/110); `validateTtl` in `dist/backends/cachekitio.js`); 🚧 unreleased, on `main` for npm 0.3.0 ([cachekit-ts#274](https://github.com/cachekit-io/cachekit-ts/pull/274)): a `wrap()` with `staleTtl` also sends `X-CacheKit-Stale-TTL`, ceiled to whole seconds and checked against [API-54](spec/saas-api.md#api-54) and a 30-day cap before sending (`validateStaleTtl`), where the [stale-window range](spec/saas-api.md#validation) now runs to 7,776,000, and every write of that key sends it again ([API-55](spec/saas-api.md#api-55)); a lock response is a lease only when its `lock_id` is a non-empty string ([API-65](spec/saas-api.md#api-65); [cachekit-ts#215](https://github.com/cachekit-io/cachekit-ts/pull/215); `dist/backends/cachekitio-lockable.js`); `src/backends/cachekitio-ttl.test.ts` and `src/backends/cachekitio-lockable.test.ts` pass at tag `cachekit-v0.2.1`. ⚠️ Through 0.1.5 (`.tgz` inspected 2026-10-05) a PUT sends only the legacy `X-TTL` ([API-44](spec/saas-api.md#api-44)), unrounded, omits it for `ttl` 0, storing the entry with no expiry, and sends a TTL over 2,592,000 for the server to reject with `400`; and a `200` lock response whose `lock_id` is absent, empty or not a string counts as a lease, so single-flight is off and the release sends `undefined` as the lock id | ❌ Not implemented | | SaaS API — cache-key path encoding ([spec](spec/saas-api.md#cache-key-path-encoding)) | ✅ Compliant — rejects the empty key since 0.21.0 ([cachekit-py#397](https://github.com/cachekit-io/cachekit-py/pull/397)); `.`, `..`, `health`, `ttl` and `lock` since 0.20.0 ([cachekit-py#364](https://github.com/cachekit-io/cachekit-py/pull/364)); rules 1/3/4 since 0.18.0 ([cachekit-py#279](https://github.com/cachekit-io/cachekit-py/pull/279)) | ✅ Compliant — rejects the empty key since `cachekit-rs` 0.9.0 ([cachekit-rs#100](https://github.com/cachekit-io/cachekit-rs/pull/100); `.crate` inspected 2026-10-10); rejects `.`, `..`, `health`, `ttl` and `lock` since `cachekit-rs` 0.8.0 ([cachekit-rs#76](https://github.com/cachekit-io/cachekit-rs/pull/76); `.crate` inspected 2026-09-30) | ✅ Compliant — `@cachekit-io/cachekit` 0.2.1+ ([cachekit-ts#118](https://github.com/cachekit-io/cachekit-ts/pull/118); `encodeKey` in `dist/backends/cachekitio.js`, `.tgz` inspected 2026-10-24; `test/protocol/path-encoding.protocol.test.ts` passes at tag `cachekit-v0.2.1`); absent from 0.1.5 | ❌ Not implemented | | Test vectors in CI¹⁶ | ✅ interop/v1 (full set, incl. AAD + encryption through the real stack) — fixture 1.1.0 (`ns`/`nsapi` namespace reservation) since PyPI 0.20.0 ([cachekit-py#350](https://github.com/cachekit-io/cachekit-py/pull/350)); fixture 1.2.0 (no `..` in a segment) since 0.21.0 ([cachekit-py#391](https://github.com/cachekit-io/cachekit-py/pull/391)); fixture 1.3.0 vendored byte for byte (sha256-pinned) + CI-executed since 0.23.0 ([cachekit-py#537](https://github.com/cachekit-io/cachekit-py/pull/537)), its key, value and error vectors and both reader groups through the interop key derivation and value reader; `decode-bounds.json` vendored + CI-executed since [cachekit-py#276](https://github.com/cachekit-io/cachekit-py/pull/276) (LAB-2503); `1.1.0` since 0.20.0 ([cachekit-py#363](https://github.com/cachekit-io/cachekit-py/pull/363)); `1.2.0` (the `incomplete` reason) vendored byte for byte (sha256-pinned) + CI-executed since 0.23.0 ([cachekit-py#537](https://github.com/cachekit-io/cachekit-py/pull/537)), both `incomplete` vectors included; every version asserting the structural guard's own error for every reject vector at every payload read path (the [Decode bounds](spec/interop-mode.md#decode-bounds) MUST); at the envelope entry point (`ByteStorage.retrieve`), every `1.2.0` reject vector asserts cachekit-core 0.7.0's pre-scan error, with the reason for each depth-only and overclaim-only vector, and every accept vector gets past the pre-scan, in [cachekit-py#627](https://github.com/cachekit-io/cachekit-py/pull/627) (unreleased; see the Wire format row); `path-encoding.json` vendored + CI-executed since 0.20.0 ([cachekit-py#364](https://github.com/cachekit-io/cachekit-py/pull/364)); fixture 1.1.0 (empty-key row) since 0.21.0 ([cachekit-py#397](https://github.com/cachekit-io/cachekit-py/pull/397)); `path-encoding.json` 1.2.0 and `freshness-headers.json` 1.0.0 vendored byte for byte (sha256-pinned) + CI-executed since 0.23.0 ([cachekit-py#522](https://github.com/cachekit-io/cachekit-py/pull/522)); `cache-keys.json` 1.1.0 ([cachekit-py#460](https://github.com/cachekit-io/cachekit-py/pull/460)), `file-backend.json` 1.1.0 ([cachekit-py#431](https://github.com/cachekit-io/cachekit-py/pull/431)) and `wire-format.json` 1.3.0 ([cachekit-py#448](https://github.com/cachekit-io/cachekit-py/pull/448); its reject vectors: see the Wire format row) vendored byte for byte (sha256-pinned) + CI-executed, each since 0.21.0; `wire-format.json` 1.4.0 (every new accept, constructed, reject, payload-reject and temporal vector), `file-backend.json` 1.2.0 (every `miss_preserve` vector on all five read paths, the expired flagged entries included, and every `return_payload` vector) and `python-frame.json` (first vendor: every frame vector through `SerializationWrapper.unwrap`, every error vector at the check its `rejected_by` names, the CK frame through the interop reader, and every `encrypted_read_vectors` frame failing closed under both tamper policies with the tenant resolved by the reader) vendored byte for byte (sha256-pinned) + CI-executed since 0.23.0 ([cachekit-py#550](https://github.com/cachekit-io/cachekit-py/pull/550)); `encryption.json` `1.5.0` (first vendor: both `master_key_input` accept rows and every reject row, every `keyring.configuration` row, every `aad_reject_vectors` row failing authentication, and the `decrypted_container` rows for its checksummed Arrow and orjson readers returning no value), `cache-keys.json` `1.3.0` (every key, error and serializer-object vector) and the `python-frame.json` revision of [protocol#180](https://github.com/cachekit-io/protocol/pull/180) (11 frame vectors: not yet the [protocol#193](https://github.com/cachekit-io/protocol/pull/193) revision, which adds `padded_header_frame`), each vendored byte for byte (sha256-pinned) + CI-executed since 0.23.0 ([cachekit-py#561](https://github.com/cachekit-io/cachekit-py/pull/561)) — ⚠️ except two `keyring.configuration` cases, `keyring_current_key_decrypt_only` and its uppercase twin, each with the current key given as `master_key=`, which run as strict expected failures naming [ENC-9](spec/encryption.md#enc-9): cachekit-py refuses the current key repeated in `CACHEKIT_PREVIOUS_MASTER_KEYS` only on the first call, not at load | ✅ interop/v1 (full set) since [#33](https://github.com/cachekit-io/cachekit-rs/pull/33) — fixture 1.1.0 since `cachekit-rs` 0.8.0 ([cachekit-rs#89](https://github.com/cachekit-io/cachekit-rs/pull/89); `.crate` inspected 2026-09-30); fixture 1.2.0 since 0.9.0 ([cachekit-rs#98](https://github.com/cachekit-io/cachekit-rs/pull/98); `.crate` inspected 2026-10-06); fixture 1.3.0 vendored byte for byte (sha256-pinned) + CI-executed in [cachekit-rs#152](https://github.com/cachekit-io/cachekit-rs/pull/152), since 0.11.0, both reader groups through `interop::deserialize` and every value vector read back through it; `decode-bounds.json` vendored + CI-executed since [cachekit-rs#73](https://github.com/cachekit-io/cachekit-rs/pull/73) (LAB-2503; default CI green on `main`); `1.1.0` since 0.8.0 ([cachekit-rs#93](https://github.com/cachekit-io/cachekit-rs/pull/93)); `1.2.0` (the `incomplete` reason) in [cachekit-rs#152](https://github.com/cachekit-io/cachekit-rs/pull/152), since 0.11.0, asserting the structural guard's own `decode bound:` error for every reject vector through both decoders and `get` / `interop_get` / `interop_get_swr` — the guard enforces depth itself since [cachekit-rs#93](https://github.com/cachekit-io/cachekit-rs/pull/93) (no envelope entry point¹⁵); `path-encoding.json` 1.1.0 vendored byte for byte (sha256-pinned) + CI-executed since 0.9.0 ([cachekit-rs#100](https://github.com/cachekit-io/cachekit-rs/pull/100); `.crate` inspected 2026-10-10), driving both the `encode_key` and the URL-builder tests; fixture 1.2.0 (encoder-mistake rows) and `freshness-headers.json` 1.0.0, vendored byte for byte (sha256-pinned) + CI-executed in [cachekit-rs#146](https://github.com/cachekit-io/cachekit-rs/pull/146), since 0.11.0, every freshness row run through the `GET` response's header reader (`freshness_from_headers`); `file-backend.json` 1.1.0 vendored byte for byte + CI-executed since 0.9.0 ([cachekit-rs#107](https://github.com/cachekit-io/cachekit-rs/pull/107); `.crate` inspected 2026-10-05), by tests in `src/backend/file.rs` behind the `file` feature, which default CI enables; `1.2.0` re-vendored byte for byte (sha256-pinned) + CI-executed in [cachekit-rs#158](https://github.com/cachekit-io/cachekit-rs/pull/158), since 0.11.0, every vector through both header read paths (`read_entry` behind `get`, `probe_header` behind `exists` and `ttl`), the expired flagged entries included, and the five vectors a version-1 writer can produce through `write_entry`; `python-frame.json` first vendored byte for byte (sha256-pinned) + CI-executed in [cachekit-rs#158](https://github.com/cachekit-io/cachekit-rs/pull/158) and re-vendored in [cachekit-rs#160](https://github.com/cachekit-io/cachekit-rs/pull/160), both since 0.11.0, its 29 CK-prefixed vectors refused by both decoders and `get` / `interop_get` / `interop_get_swr`, on a client without encryption (auto mode by the trailing bytes, interop mode by naming the frame) — ⚠️ except `bare_envelope_fed_to_frame_reader`, which decodes¹⁵; `encryption.json` first vendored byte for byte (sha256-pinned) + CI-executed at `1.3.0` in [cachekit-rs#150](https://github.com/cachekit-io/cachekit-rs/pull/150) and re-vendored at `1.5.0` in [cachekit-rs#160](https://github.com/cachekit-io/cachekit-rs/pull/160), both since 0.11.0: both master key input accept rows accepted by every hex and raw-bytes entry point and decrypted through the `secure` hex and `CACHEKIT_MASTER_KEY` paths, the `keyring.configuration` rows at load, and the `aad_reject_vectors` and `decrypted_container` rows whose AAD this reader builds, each through the reads of `get` / `interop_get` / `interop_get_swr` that can present it (`get` only for the namespaced-prefix row, the interop reads only for the two interop-reader container rows) | ✅ interop/v1 (full set, incl. its key vectors) + inline Python-generated AAD-construction and encryption (decrypt-Python-ciphertext) vectors — every "since 0.2.1" below is a PR in the `cachekit-v0.2.1` tag's history (checked 2026-10-24; the Node lane's `test/protocol` suite passes at that tag against the published `cachekit-core-ts` 0.2.0 binding); fixture 1.1.0 since npm 0.2.1 ([cachekit-ts#143](https://github.com/cachekit-io/cachekit-ts/pull/143); `'nsapi'` in `dist/serialization/interop.js` of the 0.2.1 `.tgz`, absent from 0.1.5's `validateInteropSegment`); fixture 1.2.0 since 0.2.1 ([cachekit-ts#164](https://github.com/cachekit-io/cachekit-ts/pull/164); `must not contain '..'` in `dist/serialization/interop.js`, absent from 0.1.5); fixture 1.3.0 vendored byte for byte (sha256-pinned) + CI-executed in [cachekit-ts#224](https://github.com/cachekit-io/cachekit-ts/pull/224), since 0.2.1: the key, value and error vectors and both reader groups, every value vector read back through `decodeInteropValue` as its value, with the reader fixes two of them needed (a `__proto__` map key reads as an ordinary key, and a leading U+FEFF is kept); decode bounds enforced ([#112](https://github.com/cachekit-io/cachekit-ts/pull/112)); `decode-bounds.json` vendored + CI-executed since [cachekit-ts#121](https://github.com/cachekit-io/cachekit-ts/pull/121) (LAB-2737), `1.1.0` in [cachekit-ts#152](https://github.com/cachekit-io/cachekit-ts/pull/152), since 0.2.1; `1.2.0` (the `incomplete` reason) in [cachekit-ts#224](https://github.com/cachekit-io/cachekit-ts/pull/224), since 0.2.1, both incomplete vectors through all three untrusted decode entry points (`MessagePackSerializer.decode`, `decodeInteropValue`, `deserializeEvent`), asserting the pre-scan's own error; every reject vector asserts the guard error it trips (pre-scan, or the event size cap ahead of it) — ⚠️ except, through npm 0.2.1, the envelope entry point (`unpack` in `cachekit-core-ts` and `cachekit-core-wasm`), whose bindings embed `cachekit-core` 0.6.0 and so have no guard to assert; 🚧 unreleased — on `main`, `unpack` runs every vector too, in the Node lane and inside workerd, in [cachekit-ts#245](https://github.com/cachekit-io/cachekit-ts/pull/245): each reject vector fails with `cachekit-core` 0.7.0's envelope pre-scan error (both bindings on core 0.7.0 since [cachekit-ts#240](https://github.com/cachekit-io/cachekit-ts/pull/240)), pinning the depth bound on depth-only vectors and the byte or slot budget on overclaim-only ones, and each accept vector gets past the pre-scan to core's typed decode; it stays unreleased until a published `@cachekit-io/cachekit` depends on `cachekit-core-ts` and `cachekit-core-wasm` releases that embed core 0.7.0, because npm 0.2.1 pins both at exactly 0.2.0 (see the Wire format row); for the three reject vectors over the invalidation event entry point's (`deserializeEvent`) 4096 B size cap, the test also calls that site's structural guard (`assertDecodeDepth`, at depth 3) directly and asserts its rejection, as [Decode bounds](spec/interop-mode.md#decode-bounds) requires where a size cap rejects first, in [cachekit-ts#182](https://github.com/cachekit-io/cachekit-ts/pull/182), since 0.2.1; `path-encoding.json` vendored + CI-executed since [cachekit-ts#118](https://github.com/cachekit-io/cachekit-ts/pull/118), since 0.2.1; fixture 1.1.0 (empty-key row) in [cachekit-ts#165](https://github.com/cachekit-io/cachekit-ts/pull/165), since 0.2.1; fixture 1.2.0 (encoder-mistake rows) in [cachekit-ts#217](https://github.com/cachekit-io/cachekit-ts/pull/217), since 0.2.1, every row through `encodeKey` and all eight request-path operations; `freshness-headers.json` 1.0.0 vendored byte for byte (sha256-pinned) + CI-executed in [cachekit-ts#217](https://github.com/cachekit-io/cachekit-ts/pull/217), since 0.2.1, each row through a real `Headers` and `freshnessFromHeaders`; `wire-format.json` 1.3.0 vendored byte for byte + CI-executed in the Node and Workers lanes in [cachekit-ts#184](https://github.com/cachekit-io/cachekit-ts/pull/184), since 0.2.1 (its reject vectors: see the Wire format row); `1.4.0` re-vendored byte for byte (sha256-pinned) in [cachekit-ts#229](https://github.com/cachekit-io/cachekit-ts/pull/229), since 0.2.1, every group in both lanes (reject and payload reject vectors: see the Wire format row; temporal sentinels: see their row); `file-backend.json` 1.2.0 in [cachekit-ts#229](https://github.com/cachekit-io/cachekit-ts/pull/229) (footnote ⁴); `python-frame.json` vendored byte for byte (sha256-pinned) + CI-executed in [cachekit-ts#229](https://github.com/cachekit-io/cachekit-ts/pull/229), since 0.2.1, for its container vectors ([WIRE-21](spec/wire-format.md#wire-21)): the envelope read path and the interop reader refuse `ck_frame_fed_to_interop_reader`, the envelope read path refuses `plain_msgpack_fed_to_frame_reader`, and `bare_envelope_fed_to_frame_reader`, cachekit-ts's own default container, is read by every cachekit-ts reader; the [protocol#180](https://github.com/cachekit-io/protocol/pull/180) revision re-vendored byte for byte (sha256-pinned) in [cachekit-ts#230](https://github.com/cachekit-io/cachekit-ts/pull/230), since 0.2.1, where both readers also refuse the error vectors `serializer_name_missing` and `serializer_name_empty`, whole CK v3 frames. Its frame-check and `encrypted_read_vectors` groups target cachekit-py's CK frame reader, which cachekit-ts does not have; `encryption.json` vendored + CI-executed in the Workers lane since 0.1.4 ([cachekit-ts#78](https://github.com/cachekit-io/cachekit-ts/pull/78)); `1.2.0`, with its `keyring` and `default_tenant` groups, re-vendored byte for byte (sha256-pinned) in [cachekit-ts#211](https://github.com/cachekit-io/cachekit-ts/pull/211), since 0.2.1; `1.3.0` re-vendored byte for byte (sha256-pinned) in [cachekit-ts#222](https://github.com/cachekit-io/cachekit-ts/pull/222), since 0.2.1, its accept and eleven hex reject master key input rows driven through `createCache.secure` with the key passed both as `masterKey` and through `CACHEKIT_MASTER_KEY` (cachekit-ts has no raw-key entry point, so the raw-bytes rows do not apply); `1.5.0` re-vendored byte for byte (sha256-pinned) in [cachekit-ts#230](https://github.com/cachekit-io/cachekit-ts/pull/230), since 0.2.1: both master key accept rows, `master_key_first_byte_80` included, through `createCache.secure`; each `keyring.configuration` row accepted or refused as its `verdict` says, through `encryption.previousMasterKeys`, `createCache.secure`'s `previousMasterKeys` and `CACHEKIT_PREVIOUS_MASTER_KEYS`; the five `aad_reject_vectors` rows whose AAD inputs a cachekit-ts reader builds, each failing authentication with degradation off (the other five need an `original_type` or a format no cachekit-ts reader builds); the four `decrypted_container` rows that name a cachekit-ts reader (the Arrow and orjson rows name cachekit-py's); and the raw wasm bindings decrypt `standard_serializer_default` and `standard_serializer_integrity_off` in the Workers lane, each under its own `aad_hex` (no cachekit-ts reader builds their five-component AAD) | ⚠️ Pending | | Interop mode ([spec](spec/interop-mode.md), opt-in) | ✅ Released — PyPI 0.14.0+¹⁷ ([#220](https://github.com/cachekit-io/cachekit-py/pull/220)) | ✅ Released — crates.io 0.4.0+ ([#33](https://github.com/cachekit-io/cachekit-rs/pull/33)) | ✅ Released — npm 0.1.3+ ([#71](https://github.com/cachekit-io/cachekit-ts/pull/71)) | ❌ Not implemented | diff --git a/spec/saas-api.md b/spec/saas-api.md index e4a179f..4ff9244 100644 --- a/spec/saas-api.md +++ b/spec/saas-api.md @@ -158,13 +158,14 @@ X-CacheKit-L1-Status: miss **Server (emission):** - A **signal-capable server** is one that implements this section. It is independent of [stale-while-revalidate](#stale-while-revalidate) support: a bounded entry has a `fresh_until` whether or not it has a stale window, so a server may emit `X-CacheKit-Fresh-For` without offering one, and a pre-signal SWR server emits `X-CacheKit-Freshness` without it. -- Emitted on **every** `GET` `200 OK` for an entry that has a freshness bound. An entry stored without a TTL — neither `X-CacheKit-TTL` nor legacy `X-TTL` ([PUT](#put-v1cachekey)) — has **no expiry**: no `fresh_until`, no remainder to report, so the header is **omitted** — the server-side bound is unbounded, `min(local_ttl, ∞)` is the SDK's configured local TTL, and that is exactly the absent-header path below. When present, the value is a non-negative integer: `max(0, floor(fresh_until − now))`, computed against the **server's clock** at response time — the client never compares server timestamps against its own clock. +- Emitted on **every** `GET` `200 OK` for an entry that has a freshness bound. A **no-expiry** entry — one written without a TTL, neither `X-CacheKit-TTL` nor legacy `X-TTL`, on a plan without a [maximum TTL](#plan-maximum-ttl) ([PUT](#put-v1cachekey)) — has no `fresh_until`, no remainder to report, so the header is **omitted** — the server-side bound is unbounded, `min(local_ttl, ∞)` is the SDK's configured local TTL, and that is exactly the absent-header path below. When present, the value is a non-negative integer: `min(2592000, max(0, floor(fresh_until − now)))`, computed against the **server's clock** at response time — the client never compares server timestamps against its own clock. The `2,592,000` ceiling (30 days) is the header's grammar ([consumption](#remaining-freshness) below), lower than the 90-day [TTL range](#put-v1cachekey): an entry with more than 30 days of freshness left reports `2592000`, never a larger value, so a local copy is bounded short of the entry's true freshness, the conservative direction. +- Emitted on a **write**, too. A signal-capable server MUSTAPI-80 emit `X-CacheKit-Fresh-For` on every `PUT` `2xx` whose stored entry has a freshness bound, computed as above from the `fresh_until` it stamped, and omits it when the write stored a no-expiry entry. That is how a writer learns the bound the store applied, which is shorter than the one it stated when a [plan maximum TTL](#plan-maximum-ttl) clamped the write ([the writer's own copy](#remaining-freshness) below). A server that applies a plan maximum TTL MUSTAPI-81 be signal-capable, so a `PUT` `2xx` without the header means the store applied exactly the write's [effective TTL](#put-v1cachekey), or stored a no-expiry entry because the write carried none. - Stale-window responses (`X-CacheKit-Freshness: stale`) carry `X-CacheKit-Fresh-For: 0` — freshness is already exhausted. `X-CacheKit-Freshness: fresh` with `X-CacheKit-Fresh-For: 0` is also legal — an entry in its final sub-second of freshness floors to `0`. The response is served to the caller normally; the `0` governs only local caching (no backfill). - Omitted by the store for no-expiry entries, and by pre-signal servers for everything. The client cannot tell the two apart, by design: both mean "no server-side freshness bound applies to this read" and both lead to the same SDK action (configured local TTL, fresh service only). Because absence carries that meaning, a tier that implements this section MUST NOTAPI-19 produce it for a copy it cannot positively confirm has no expiry — the tier rule below. - **Re-serving tiers** — an edge or colo cache in front of the store that answers from a copy it read earlier — are part of the server. A tier's **coherence window** is the deployment-documented maximum time it may keep serving a copy after the store has changed or deleted the entry; a tier that refreshes its copy's lifetime from the tier below rather than from the store adds its window to the path's total, so windows **compound** along the serving path and a deployment's effective window is their sum, not its largest single tier. That governs the *bytes*. The *header* follows stricter, fail-closed rules: - - **Decay, never re-stamp.** A tier MUSTAPI-20 emit its copy's remaining bound minus the seconds elapsed since that bound was obtained, flooring at `0`. A copy has exactly one bound source: the response it was read from (the bound is that response's `X-CacheKit-Fresh-For`), or — for a copy the tier populated from a write it forwarded — that write, once the store has accepted it (the bound is the write's effective TTL, see [Migration](#put-v1cachekey), measured from when the tier dispatched the write; the store stamps `fresh_until` after that dispatch, from `floor(stored_at)` ([whole-second stamping](#entry-lifecycle)), so the copy's bound ends less than one second after `fresh_until`). It MUST NOTAPI-21 emit a value larger than that decayed remainder — its own copy TTL included — under any label. The deployed tiers decay, so the rule costs a conforming implementation nothing, and it keeps a pre-`DELETE` copy's local service within one second of the entry's `fresh_until`. - - **Omit only on positive knowledge of no expiry; otherwise `0`.** A tier MAY omit the header only for a copy it positively knows has no expiry: a signal-capable store below omitted it (such a store omits only for no-expiry entries), or the tier populated the copy from an accepted write that carried no TTL at all — neither `X-CacheKit-TTL` nor legacy `X-TTL`. A tier MUSTAPI-22 determine a write's TTL with the store's own precedence (`X-CacheKit-TTL`, else `X-TTL`); the absence of `X-CacheKit-TTL` alone proves nothing, because a legacy `X-TTL: 60` write is bounded. For every other copy whose remainder it does not know — populated without a hint, a header lost in transit, or a store below that is pre-signal and therefore omits for bounded entries too — the tier MUSTAPI-23 emit `X-CacheKit-Fresh-For: 0`. It MUST NOTAPI-24 omit a header it received, nor drop it for such a copy: omission would claim "no bound" for an entry that may have one and silently restore the unbounded backfill this header exists to kill. A tier MUSTAPI-25 therefore record at populate whether a copy is unbounded or merely unhinted; a tier that cannot tell the two apart MUSTAPI-26 emit `0`. A tier fronting a pre-signal store is in exactly that position — it emits `0`, it does not pass the absence through; the cost is no local backfill behind that tier until the store signals, the fail-closed posture a mixed deployment should have. Whether the store below signals is a deployment fact the tier is configured with, never inferred per response. - - **Positive only for `fresh` + positive.** A tier MAY emit a positive value only when its copy's bound source qualifies, and then only the decayed remainder: a response the tier below labelled exactly `X-CacheKit-Freshness: fresh` (or left unlabelled — the pre-SWR `fresh` default) and stamped with a strictly positive `X-CacheKit-Fresh-For`; or an accepted write with a positive effective TTL, whose copy is labelled `fresh` (a just-accepted write is fresh by construction). Every other shape — `stale`, `X-CacheKit-Fresh-For: 0` under any label, an unrecognized freshness token, or a missing `X-CacheKit-Fresh-For` on any entry not positively known to be no-expiry — MUSTAPI-27 be emitted with `X-CacheKit-Fresh-For: 0` and its freshness label passed through unchanged (a `fresh` `0` stays `fresh`; a `stale` stays `stale`). Stamping a positive bound onto a stale, exhausted, or unknown value resurrects an expired (or revoked) entry as locally-cacheable fresh — the exact hole this header closes. + - **Decay, never re-stamp.** A tier MUSTAPI-82 emit its copy's remaining bound minus the seconds elapsed since that bound was obtained, flooring at `0`. A copy has exactly one bound source: the response it was read from (the bound is that response's `X-CacheKit-Fresh-For`), or — for a copy the tier populated from a write it forwarded — that write, once the store has accepted it. The bound of a forwarded write is the `X-CacheKit-Fresh-For` on the store's `PUT` `2xx` when it carries one, otherwise the write's effective TTL (see [Migration](#put-v1cachekey)), and either is measured from when the tier dispatched the write. The store stamps `fresh_until` after that dispatch, from `floor(stored_at)` ([whole-second stamping](#entry-lifecycle)), and computes the header after stamping, so a bound from the header ends no later than `fresh_until`, and one from the effective TTL ends less than one second after it. A tier that forwards a `PUT` MUST NOTAPI-83 drop the `X-CacheKit-Fresh-For` of the store's `2xx` from the response it relays, nor relay a value larger than the store's. It MUST NOTAPI-21 emit a value larger than that decayed remainder — its own copy TTL included — under any label. The deployed tiers decay, so the rule costs a conforming implementation nothing, and it keeps a pre-`DELETE` copy's local service within one second of the entry's `fresh_until`. + - **Omit only on positive knowledge of no expiry; otherwise `0`.** A tier MAY omit the header only for a copy it positively knows has no expiry: a signal-capable store below omitted it (such a store omits only for no-expiry entries), or the tier populated the copy from an accepted write that carried no TTL at all — neither `X-CacheKit-TTL` nor legacy `X-TTL` — and whose `2xx` carried no `X-CacheKit-Fresh-For` (a write without a TTL on a plan with a [maximum TTL](#plan-maximum-ttl) is bounded, and its `2xx` says so). A tier MUSTAPI-22 determine a write's TTL with the store's own precedence (`X-CacheKit-TTL`, else `X-TTL`); the absence of `X-CacheKit-TTL` alone proves nothing, because a legacy `X-TTL: 60` write is bounded. For every other copy whose remainder it does not know — populated without a hint, a header lost in transit, or a store below that is pre-signal and therefore omits for bounded entries too — the tier MUSTAPI-23 emit `X-CacheKit-Fresh-For: 0`. It MUST NOTAPI-24 omit a header it received, nor drop it for such a copy: omission would claim "no bound" for an entry that may have one and silently restore the unbounded backfill this header exists to kill. A tier MUSTAPI-25 therefore record at populate whether a copy is unbounded or merely unhinted; a tier that cannot tell the two apart MUSTAPI-26 emit `0`. A tier fronting a pre-signal store is in exactly that position — it emits `0`, it does not pass the absence through; the cost is no local backfill behind that tier until the store signals, the fail-closed posture a mixed deployment should have. Whether the store below signals is a deployment fact the tier is configured with, never inferred per response. + - **Positive only for `fresh` + positive.** A tier MAY emit a positive value only when its copy's bound source qualifies, and then only the decayed remainder: a response the tier below labelled exactly `X-CacheKit-Freshness: fresh` (or left unlabelled — the pre-SWR `fresh` default) and stamped with a strictly positive `X-CacheKit-Fresh-For`; or an accepted write whose bound (above) is positive, whose copy is labelled `fresh` (a just-accepted write is fresh by construction). Every other shape — `stale`, `X-CacheKit-Fresh-For: 0` under any label, an unrecognized freshness token, or a missing `X-CacheKit-Fresh-For` on any entry not positively known to be no-expiry — MUSTAPI-84 be emitted with `X-CacheKit-Fresh-For: 0` and its freshness label passed through unchanged (a `fresh` `0` stays `fresh`; a `stale` stays `stale`). Stamping a positive bound onto a stale, exhausted, or unknown value resurrects an expired (or revoked) entry as locally-cacheable fresh — the exact hole this header closes. - `HEAD` does **not** carry this header — an existence check returns no payload, so there is nothing to backfill locally (the `X-CacheKit-Freshness` label on `HEAD` remains informational, per [Stale-While-Revalidate](#stale-while-revalidate)). Correspondingly, a `HEAD` response MUST NOTAPI-28 create, refresh, or extend any local entry's service bound. **SDK (consumption):** @@ -172,11 +173,11 @@ X-CacheKit-L1-Status: miss - On a `GET` `200 OK` labelled `X-CacheKit-Freshness: fresh` (or unlabelled) with the header present, a local cache (L1) backfill MUSTAPI-29 bound the entry's local lifetime to at most the header value: `min(local_ttl, fresh_for)`. A value of `0` means the entry MUST NOTAPI-30 be backfilled at all. A `stale` or unrecognized freshness label forbids backfill regardless of the number ([Reading a stale entry](#reading-a-stale-entry)) — a positive `X-CacheKit-Fresh-For` on a `stale` response is a server bug, not a license. - The header value is a hard local **service** bound, not merely a freshness bound: once it elapses, the local copy MUST NOTAPI-31 be served in any form — including by client-side stale-while-revalidate or any local stale-grace policy. (Serving server-returned stale bytes per [Reading a stale entry](#reading-a-stale-entry) is unaffected — this rule governs only the local copy.) **Why there is no local stale service, stated once:** the client receives no remaining-eviction signal, so a locally-stale copy could not honor the store's [`evict_at` bound](#reading-a-stale-entry) — and one is deliberately not provided, because it would let clients replicate the stale window locally, invisibly to server-side revalidation single-flight and metering. Stale service is the server's job: a subsequent read hits the server, which serves the stale window itself (`X-CacheKit-Freshness: stale`, `X-CacheKit-Fresh-For: 0`) until `evict_at`. Rules elsewhere refer back here rather than restating this. - Absent header on a `GET` `200 OK` = 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 [30-day TTL cap](#put-v1cachekey), itself seven digits). 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`). +- The value MUSTAPI-32 be 1–7 ASCII digits and at most `2,592,000` (30 days, itself seven digits: the header's ceiling, which [emission](#remaining-freshness) above keeps below the 90-day [TTL range](#put-v1cachekey)). 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.** A `PUT` response carries no `X-CacheKit-Fresh-For`, so an SDK that keeps a local copy of a value it wrote bounds that copy by the write itself. Once the store accepts the `PUT`, the SDK MUST NOTAPI-35 serve that copy, in any form, after `min(local_ttl, effective_ttl)` has elapsed, where `effective_ttl` is the write's [effective TTL](#put-v1cachekey) and the clock starts when the SDK receives the store's `2xx` response. `X-CacheKit-Stale-TTL` never lengthens this bound, because local stale service is not allowed (above). A write with no effective TTL stores a no-expiry entry, so the configured local TTL applies, as for an absent header. 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`. +- **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 store's answer to it: the store can apply a shorter TTL than the write stated ([plan maximum TTL](#plan-maximum-ttl)), and reports the bound it applied as `X-CacheKit-Fresh-For` on the `PUT` `2xx` ([emission](#remaining-freshness) above). Once the store accepts the `PUT`, the SDK MUST NOTAPI-85 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 `2xx` without the header means the store applied the effective TTL, or stored a no-expiry entry for a write without one, so a write with neither a TTL nor the header keeps the configured local TTL, as for an absent header on a read. An SDK that predates the header on `PUT` bounds its copy by `min(local_ttl, effective_ttl)` alone, so on a plan with a maximum TTL its copy can outlive `fresh_until` by up to that bound less the plan's maximum TTL, on top of the write-path tolerance below. 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. On a plan with a maximum TTL it can outlive `fresh_until` by up to `min(local_ttl, effective_ttl)` less that maximum on top of this, because without a `2xx` the SDK cannot learn the bound the store applied. 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`. - 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`, 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 write's bound, 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` (on a plan with a [maximum TTL](#plan-maximum-ttl), plus the amount by which that copy's `min(local_ttl, effective_ttl)` exceeds the maximum), the same excess for a writer that predates `X-CacheKit-Fresh-For` on `PUT`, 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 any plan-maximum excess ([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. --- @@ -209,7 +210,7 @@ X-CacheKit-L1-Status: miss | Header | Required | Description | | :--- | :---: | :--- | -| `X-CacheKit-TTL` | No | Time-to-live in seconds. Positive integer, minimum 1, maximum 2,592,000 (30 days). Omit — together with legacy `X-TTL` — to store the entry with **no expiry** (see the **No-expiry contract** under TTL Validation Rules); servers MUST NOTAPI-40 substitute a hidden default. | +| `X-CacheKit-TTL` | No | Time-to-live in seconds. Positive integer, minimum 1, maximum 7,776,000 (90 days). The store applies at most the tenant's [plan maximum TTL](#plan-maximum-ttl). Omit — together with legacy `X-TTL` — to store the entry with the plan maximum TTL, or with **no expiry** on a plan without one (see the **No-expiry contract** under TTL Validation Rules). | | `X-CacheKit-Stale-TTL` | No | Stale-grace window in seconds after freshness expiry. Requires an explicit TTL (`X-CacheKit-TTL`, or legacy `X-TTL`) on the same request. Validation and semantics: [Stale-While-Revalidate](#stale-while-revalidate). Pre-SWR servers ignore this header. | > [!IMPORTANT] @@ -217,22 +218,23 @@ X-CacheKit-L1-Status: miss > > | Condition | SDK Behavior | Server Behavior | > | :--- | :--- | :--- | -> | TTL omitted | Use client default TTL. If no client default, omit `X-CacheKit-TTL` header. | Store with **no expiry** when the request carries neither `X-CacheKit-TTL` nor `X-TTL` — see **No-expiry contract** below. There is no tenant-default TTL mechanism: servers MUST NOTAPI-41 apply a hidden default — validation must not depend on defaults clients cannot see (the same rule as `X-CacheKit-Stale-TTL`). | +> | TTL omitted | Use client default TTL. If no client default, omit `X-CacheKit-TTL` header. | When the request carries neither `X-CacheKit-TTL` nor `X-TTL`: store with the tenant's [plan maximum TTL](#plan-maximum-ttl), or with **no expiry** on a plan without one — see **No-expiry contract** below. The plan maximum is the only default: servers MUST NOTAPI-79 apply any other, because a client must be able to know what its write stores. The plan maximum is published in this spec, and the `2xx` reports the bound it produced ([Remaining Freshness](#remaining-freshness)). | > | TTL = 0 | **Reject** — return error to caller. Zero is not a valid TTL. | **Reject** — return `400 Bad Request`. | > | TTL < 1 second | **Round up to 1.** Sub-second durations MUSTAPI-42 be ceiled, never truncated to 0. | N/A (header is integer seconds). | -> | TTL > 2,592,000 | **Reject** — return error to caller. | **Reject** — return `400 Bad Request`. | +> | TTL > 7,776,000 | **Reject** — return error to caller. | **Reject** — return `400 Bad Request`. | +> | TTL > the tenant's plan maximum TTL, and at most 7,776,000 | **Send it.** The SDK does not know the tenant's plan. | **Store with the plan maximum TTL** ([Plan maximum TTL](#plan-maximum-ttl)); never reject. | > | TTL negative | **Reject** — return error to caller. | **Reject** — return `400 Bad Request`. | > | TTL non-integer | N/A (SDK converts duration to integer seconds). | **Reject** — return `400 Bad Request`. | > -> **No-expiry contract.** An entry stored without a TTL — neither `X-CacheKit-TTL` nor legacy `X-TTL` (see **Migration** below) — has no `fresh_until` and no `evict_at`: +> **No-expiry contract.** An entry written without a TTL — neither `X-CacheKit-TTL` nor legacy `X-TTL` (see **Migration** below) — on a plan without a [maximum TTL](#plan-maximum-ttl) has no `fresh_until` and no `evict_at`. On a plan with a maximum TTL such a write is bounded instead, so no-expiry entries exist only on a plan without one, or from before the tenant's plan gained one: > - **Reads:** permanently **fresh** — while the entry is present, `GET` and `HEAD` return `200 OK` with `X-CacheKit-Freshness: fresh`, and `GET` carries no [`X-CacheKit-Fresh-For`](#remaining-freshness). It never enters a stale window. Presence ends only on explicit `DELETE`, overwrite, or capacity eviction (below). > - **Storage bound: deployment-configured.** No-expiry entries are never age-evicted. Whether, and at what ceiling, a deployment applies capacity eviction is deployment configuration outside this protocol; no-expiry entries are eligible victims of whatever capacity eviction the deployment applies, so "no expiry" is **not** a durability guarantee, and an evicted entry gives no client-visible signal beyond a subsequent miss. A deployment that needs a specific storage ceiling must provision a tenant or namespace quota. > - **`GET /v1/cache/{key}/ttl`:** see the [endpoint](#get-v1cachekeyttl). -> - **`PATCH /v1/cache/{key}/ttl`:** `200`; gives the entry its first expiry (`fresh_until = floor(now) + ttl`, [whole-second stamping](#entry-lifecycle)) — the one way to bound an existing no-expiry entry without rewriting it. That `200` is indistinguishable from the [absent-key no-op](#patch-v1cachekeyttl); confirm presence separately if it matters. +> - **`PATCH /v1/cache/{key}/ttl`:** `200`; gives the entry its first expiry (`fresh_until = floor(now) + ttl`, [whole-second stamping](#entry-lifecycle), with `ttl` clamped to the [plan maximum TTL](#plan-maximum-ttl) on a plan with one) — the one way to bound an existing no-expiry entry without rewriting it. That `200` is indistinguishable from the [absent-key no-op](#patch-v1cachekeyttl); confirm presence separately if it matters. > -> **Rationale:** TTL=0 is ambiguous across cache systems (Redis rejects it, Memcached treats it as "never expire", HTTP treats it as "immediately stale"). CacheKit defines TTL=0 as an error to prevent silent data loss (the Redis and HTTP readings) or an unintended no-expiry entry (the Memcached reading) — no expiry is requested by omitting the header, never by `0`. Sub-second durations are ceiled to 1 rather than truncated to 0 to avoid the same ambiguity. The 30-day maximum bounds the value range of a **stated** TTL (and with it the [`X-CacheKit-Fresh-For`](#remaining-freshness) grammar); it is not a storage-lifetime ceiling (see the no-expiry contract above). Entries that need a long but bounded life should renew explicitly via `PATCH /v1/cache/{key}/ttl`. +> **Rationale:** TTL=0 is ambiguous across cache systems (Redis rejects it, Memcached treats it as "never expire", HTTP treats it as "immediately stale"). CacheKit defines TTL=0 as an error to prevent silent data loss (the Redis and HTTP readings) or an unintended no-expiry entry (the Memcached reading) — no expiry is requested by omitting the header, never by `0`. Sub-second durations are ceiled to 1 rather than truncated to 0 to avoid the same ambiguity. The 90-day maximum bounds the value range of a **stated** TTL; it is not a storage-lifetime ceiling. The [plan maximum TTL](#plan-maximum-ttl) is one, on a plan that has it, and clamps rather than rejects, so a write that passes these plan-independent rules never fails on a plan the client cannot see. The [`X-CacheKit-Fresh-For`](#remaining-freshness) grammar keeps its 30-day ceiling, which predates the 90-day range. Entries that need a long but bounded life should renew explicitly via `PATCH /v1/cache/{key}/ttl`. > -> **Migration:** The `X-TTL` header is deprecated. The server MUSTAPI-43 accept both `X-CacheKit-TTL` and `X-TTL` during the transition period, preferring `X-CacheKit-TTL` when both are present. A write's **effective TTL** is therefore `X-CacheKit-TTL` when present, otherwise `X-TTL`; a write has no TTL — and stores a no-expiry entry — only when it carries **neither**. Every rule in this spec that depends on whether a write carried a TTL reads the effective TTL: the no-expiry rule, the stale-window requirement, and the re-serving-tier rules in [Remaining Freshness](#remaining-freshness). SDKs MUSTAPI-44 send `X-CacheKit-TTL` only. The `X-TTL` header will be removed in protocol version 2.0 (targeted at SDK 1.0 milestone). +> **Migration:** The `X-TTL` header is deprecated. The server MUSTAPI-43 accept both `X-CacheKit-TTL` and `X-TTL` during the transition period, preferring `X-CacheKit-TTL` when both are present. A write's **effective TTL** is therefore `X-CacheKit-TTL` when present, otherwise `X-TTL`; a write has no TTL only when it carries **neither**, and then stores a no-expiry entry, or one bounded by the [plan maximum TTL](#plan-maximum-ttl) on a plan with one. The effective TTL is what the write states; the TTL the store applies is the effective TTL clamped to the plan maximum. Every rule in this spec that depends on whether a write carried a TTL reads the effective TTL: the no-expiry rule, the stale-window requirement, and the re-serving-tier rules in [Remaining Freshness](#remaining-freshness). SDKs MUSTAPI-44 send `X-CacheKit-TTL` only. The `X-TTL` header will be removed in protocol version 2.0 (targeted at SDK 1.0 milestone). > [!IMPORTANT] > **Maximum value size:** A single cache value may be at most **25 MB**. Larger values are rejected with `413 Payload Too Large` — a **permanent** error: SDKs MUST NOTAPI-45 retry and SHOULD surface "value too large" to the caller. This ceiling MAY change, so SDKs MUSTAPI-46 treat any `413` as "value too large" regardless of the exact byte count. It is unrelated to the SDK serializer's 512 MiB in-memory safety bound (see `wire-format.md`) — that bound governs what the SDK will serialize, not what the service will store. @@ -242,6 +244,34 @@ X-CacheKit-L1-Status: miss | `200 OK` | Value stored | | `413 Payload Too Large` | Value exceeds the maximum stored value size (25 MB). Permanent — do not retry. | +**Response headers:** + +| Header | Description | +| :--- | :--- | +| `X-CacheKit-Fresh-For` | On a `2xx`: the freshness bound the store applied, in whole seconds, capped at 2,592,000; omitted for a no-expiry entry. Semantics: [Remaining Freshness](#remaining-freshness), including [the writer's own copy](#remaining-freshness). | + +#### Plan maximum TTL + +Each plan has a maximum TTL: the longest an entry written under it lives in the store, its stale window included. + +| Plan | Maximum TTL | +| :--- | :--- | +| Free | 86,400 seconds (24 hours) | +| Startup | 2,592,000 seconds (30 days) | +| Professional | 7,776,000 seconds (90 days) | +| Enterprise | None | + +The maximum clamps a write; it never rejects one. The [TTL validation rules](#put-v1cachekey) and the [stale-window validation](#validation) run first and do not depend on the plan, so a client can pre-validate every write. Then, on a plan with a maximum TTL `max_ttl`, the server MUSTAPI-77 store the write with + +``` +applied_ttl = min(effective_ttl, max_ttl), or max_ttl for a write without a TTL +applied_stale_ttl = min(stale_ttl, max_ttl − applied_ttl) +``` + +so that `evict_at − floor(stored_at) ≤ max_ttl`. The fresh window is kept first: the stale window gets only what the maximum leaves, so a write whose TTL reaches the maximum stores no stale window. A [`PATCH /v1/cache/{key}/ttl`](#patch-v1cachekeyttl) MUSTAPI-78 be clamped the same way, with its `ttl` in place of `effective_ttl` and the entry's stored stale window in place of `stale_ttl`, so that `evict_at − floor(now) ≤ max_ttl`. On a plan without a maximum TTL, writes and `PATCH`es are stored as stated, and a write without a TTL stores a no-expiry entry. + +The maximum applies when a write or `PATCH` is stamped. An entry keeps the timing it was stored with when the tenant's plan changes later, a no-expiry entry included. The store reports the bound it applied on the `PUT` `2xx` as `X-CacheKit-Fresh-For` ([Remaining Freshness](#remaining-freshness)), and `GET /v1/cache/{key}/ttl` reads the remaining lifetime at any time. + --- ### DELETE /v1/cache/{key} @@ -300,6 +330,8 @@ fresh_until = floor(stored_at) + ttl evict_at = fresh_until + stale_ttl ``` +`ttl` and `stale_ttl` are the values the store applied: the write's, unless a [plan maximum TTL](#plan-maximum-ttl) clamped them. + **Whole-second stamping.** `stored_at` is the store's clock when it stores the write, between receiving the `PUT` and replying to it. The store MUSTAPI-50 stamp the write from `floor(stored_at)`, and MUSTAPI-51 stamp a [`PATCH /v1/cache/{key}/ttl`](#patch-v1cachekeyttl) the same way, from `floor(now)`, so `fresh_until` and `evict_at` are always whole seconds. Flooring shortens the fresh window by less than a second and never lengthens it: an entry is fresh for more than `ttl − 1` and at most `ttl` seconds after `stored_at` (for a `PATCH`, after the `now` it was stamped from). The sub-second tolerances in [Remaining Freshness](#remaining-freshness) and the [`/ttl` rounding](#get-v1cachekeyttl) rest on this rule. | Window | `GET` / `HEAD` behavior | @@ -312,7 +344,7 @@ All lifecycle times are computed against the **store's clock**; SDKs MUST NOT 2,591,999 | `400 Bad Request`. The standalone bound is checked **before** any arithmetic (no integer wrap on `evict_at`). | +| `stale_ttl` negative, non-integer, or > 7,775,999 | `400 Bad Request`. The standalone bound is checked **before** any arithmetic (no integer wrap on `evict_at`). | | `X-CacheKit-Stale-TTL` present without an explicit TTL (`X-CacheKit-TTL`, or legacy `X-TTL`) | `400 Bad Request`. Validation must not depend on hidden tenant defaults — clients must be able to pre-validate. | -| `ttl + stale_ttl > 2,592,000` | `400 Bad Request` (the stale window shares the [30-day TTL cap](#put-v1cachekey)) | +| `ttl + stale_ttl > 7,776,000` | `400 Bad Request` (the stale window shares the [90-day TTL range](#put-v1cachekey)) | +| `ttl + stale_ttl` > the tenant's [plan maximum TTL](#plan-maximum-ttl), and at most 7,776,000 | Accepted; the store clamps the windows, the fresh window first | | `stale_ttl = 0` | Accepted; equivalent to omitting the header | | `stale_ttl < 1` second (sub-second duration in SDK API) | SDKs MUSTAPI-54 ceil to 1, never truncate to 0 (same rule as TTL) | ### Write semantics -- **`PUT` fully replaces the entry's timing metadata.** Both windows derive from the new request alone; a `PUT` that omits `X-CacheKit-Stale-TTL` (or sends `0`) leaves the entry with **no** stale window, regardless of what the previous entry had. A revalidation `PUT` therefore MUSTAPI-55 re-send both the `X-CacheKit-TTL` and the stale window it intends to keep — the previous bound is never carried forward, so a recompute that carries neither `X-CacheKit-TTL` nor legacy `X-TTL` stores a **no-expiry** entry ([PUT](#put-v1cachekey)). -- **`PATCH /v1/cache/{key}/ttl` within the fresh window** resets `fresh_until = floor(now) + ttl` ([whole-second stamping](#entry-lifecycle)) and preserves the entry's stored stale window; the combined total is re-validated against the 30-day cap. +- **`PUT` fully replaces the entry's timing metadata.** Both windows derive from the new request alone; a `PUT` that omits `X-CacheKit-Stale-TTL` (or sends `0`) leaves the entry with **no** stale window, regardless of what the previous entry had. A revalidation `PUT` therefore MUSTAPI-55 re-send both the `X-CacheKit-TTL` and the stale window it intends to keep — the previous bound is never carried forward, so a recompute that carries neither `X-CacheKit-TTL` nor legacy `X-TTL` stores a **no-expiry** entry, or one bounded by the [plan maximum TTL](#plan-maximum-ttl) on a plan with one ([PUT](#put-v1cachekey)). +- **`PATCH /v1/cache/{key}/ttl` within the fresh window** resets `fresh_until = floor(now) + ttl` ([whole-second stamping](#entry-lifecycle)) and preserves the entry's stored stale window; the combined total is re-validated against the 90-day TTL range, then clamped to the [plan maximum TTL](#plan-maximum-ttl) on a plan with one, which can shorten the stale window. - **`PATCH /v1/cache/{key}/ttl` on an entry past `fresh_until` MUSTAPI-56 return `409 Conflict`.** A stale entry regains freshness only via a `PUT` of recomputed bytes — otherwise a routine TTL-renewal job could indefinitely resurrect stale data without revalidation, defeating the `evict_at` bound. ### Reading a stale entry @@ -478,12 +511,12 @@ Content-Type: application/json {"ttl": 7200} ``` -The `ttl` field follows the same validation rules as `X-CacheKit-TTL`: positive integer, minimum 1, maximum 2,592,000. For entries stored with a stale-grace window, see [SWR write semantics](#write-semantics): a PATCH within the fresh window renews it; a PATCH on a stale entry MUSTAPI-73 return `409 Conflict`. A `PATCH` on a no-expiry entry is within the fresh window by definition and bounds it: `fresh_until = floor(now) + ttl` ([whole-second stamping](#entry-lifecycle)), no stale window. +The `ttl` field follows the same validation rules as `X-CacheKit-TTL`: positive integer, minimum 1, maximum 7,776,000. On a plan with a [maximum TTL](#plan-maximum-ttl) the store clamps it, and the stored stale window, to that maximum; the `200` does not report the applied value, so a client that needs it reads `GET /v1/cache/{key}/ttl`. For entries stored with a stale-grace window, see [SWR write semantics](#write-semantics): a PATCH within the fresh window renews it; a PATCH on a stale entry MUSTAPI-73 return `409 Conflict`. A `PATCH` on a no-expiry entry is within the fresh window by definition and bounds it: `fresh_until = floor(now) + ttl` ([whole-second stamping](#entry-lifecycle)), no stale window. | Status | Meaning | | :---: | :--- | | `200 OK` | TTL updated. Also returned for an absent or evicted key — the request is a no-op; this endpoint never returns `404` | -| `400 Bad Request` | Invalid TTL (zero, negative, exceeds maximum), or `ttl` plus the entry's stored stale window exceeds 2,592,000 (the 30-day TTL cap). The client cannot pre-validate the second case: the stale window is server-side state. A `PATCH` with a smaller `ttl` can succeed | +| `400 Bad Request` | Invalid TTL (zero, negative, exceeds maximum), or `ttl` plus the entry's stored stale window exceeds 7,776,000 (the 90-day TTL range). The client cannot pre-validate the second case: the stale window is server-side state. A `PATCH` with a smaller `ttl` can succeed | | `409 Conflict` | Entry is past `fresh_until` ([SWR write semantics](#write-semantics)) — refresh requires a `PUT` of recomputed bytes | --- @@ -568,7 +601,7 @@ SDKs should classify errors for circuit breaker integration: | Class | Status Codes | SDK Action | | :--- | :--- | :--- | | **Transient** | `429`, `500`, `502`, `503`, network timeouts | Retry with backoff | -| **Permanent** | `400`, `401`, `403`, `405`, `409`, `413` | Do not retry, surface to caller. For the `PATCH /ttl` `400` where `ttl` plus the stored stale window exceeds the 30-day TTL cap: retry only with a smaller `ttl`. For `409` (`PATCH /ttl` past `fresh_until`): do not re-`PATCH` — recompute and `PUT` ([write semantics](#write-semantics)) | +| **Permanent** | `400`, `401`, `403`, `405`, `409`, `413` | Do not retry, surface to caller. For the `PATCH /ttl` `400` where `ttl` plus the stored stale window exceeds the 90-day TTL range: retry only with a smaller `ttl`. For `409` (`PATCH /ttl` past `fresh_until`): do not re-`PATCH` — recompute and `PUT` ([write semantics](#write-semantics)) | | **Cache miss** | `404` on `GET`/`HEAD /v1/cache/{key}` | Not an error — return `None`/`null` (`GET`) or `false` (`HEAD`) | | **Key absent** | `404` on `GET /v1/cache/{key}/ttl` | Not an error — return `None`/`null` for the TTL ([GET /v1/cache/{key}/ttl](#get-v1cachekeyttl)) | From 8a491988967ea96ca7ba2066c0b19743a7c0aba8 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 31 Oct 2026 20:22:20 +1100 Subject: [PATCH 2/8] docs(saas-api): apply review findings to the plan maximum TTL change Keep API-20 and API-27: their requirement sentences are unchanged and every existing tier stays compliant. Read a forwarded write's PUT header with the consumption grammar, forbid a PATCH response from extending a local copy, state the unresolved-plan rule, correct the dispatch-anchored tolerance claim, mark the writer's-copy gap in the matrix, and trim restated text. --- changelog.d/20261031_plan-maximum-ttl.md | 20 ++++++------- conformance/coverage.md | 29 ++++++++++--------- conformance/requirements.json | 35 ++++++++++++----------- sdk-feature-matrix.md | 2 +- spec/saas-api.md | 36 ++++++++++++------------ 5 files changed, 63 insertions(+), 59 deletions(-) diff --git a/changelog.d/20261031_plan-maximum-ttl.md b/changelog.d/20261031_plan-maximum-ttl.md index aa39cf5..d350afe 100644 --- a/changelog.d/20261031_plan-maximum-ttl.md +++ b/changelog.d/20261031_plan-maximum-ttl.md @@ -4,11 +4,12 @@ (24 hours), Startup 2,592,000 s (30 days), Professional 7,776,000 s (90 days), Enterprise none. On a plan with a maximum, a stated TTL above it is stored with the maximum, not rejected, and a write without a TTL is stored with the maximum instead of with no expiry - ([API-77](spec/saas-api.md#api-77)). The maximum bounds the entry's whole stored life: the fresh - window is kept first and the stale window gets what is left. `PATCH /ttl` is clamped the same - way ([API-78](spec/saas-api.md#api-78)). Validation stays plan-independent, so a client can still - pre-validate every write. On Enterprise, and for entries stored before the change, nothing - changes. + ([API-77](spec/saas-api.md#api-77)). The maximum bounds `evict_at` from each write's stamp: the + fresh window is kept first and the stale window gets what is left. `PATCH /ttl` is clamped the + same way ([API-78](spec/saas-api.md#api-78)), and its response must not extend a local copy + ([API-84](spec/saas-api.md#api-84)). Validation stays plan-independent, so a client can still + pre-validate every write. On Enterprise nothing is clamped, and entries stored before the change + keep their timing until their next write or `PATCH`. - The value range of `X-CacheKit-TTL`, of `PATCH /ttl`'s `ttl` and of `ttl + stale_ttl` rises from 2,592,000 to 7,776,000 (`stale_ttl` alone to 7,775,999). A larger value is still a `400`. - The plan maximum is the only default for a write without a TTL @@ -16,10 +17,9 @@ - A `PUT` `2xx` now carries `X-CacheKit-Fresh-For`: the freshness bound the store applied ([API-80](spec/saas-api.md#api-80)), and a server that clamps must send it ([API-81](spec/saas-api.md#api-81)). An SDK bounds its copy of its own write by - `min(local_ttl, effective_ttl, fresh_for)` ([API-85](spec/saas-api.md#api-85), replacing API-35). - A re-serving tier bounds its copy of a forwarded write the same way - ([API-82](spec/saas-api.md#api-82), replacing API-20; [API-84](spec/saas-api.md#api-84), - replacing API-27) and relays the header unraised ([API-83](spec/saas-api.md#api-83)). + `min(local_ttl, effective_ttl, fresh_for)` ([API-83](spec/saas-api.md#api-83), replacing API-35). + A re-serving tier takes its copy of a forwarded write's bound from the same header + ([API-20](spec/saas-api.md#api-20)) and relays it unraised ([API-82](spec/saas-api.md#api-82)). - The `X-CacheKit-Fresh-For` grammar keeps its 2,592,000 maximum: a server reports an entry with more than 30 days of freshness left as `2592000`. - **Breaking for callers on a plan with a maximum TTL:** once the server applies this, a write @@ -27,5 +27,5 @@ shortened to it. - **Breaking for SDKs:** one that refuses a TTL over 2,592,000, or a `ttl + stale_ttl` over it, refuses writes the server accepts. One that ignores `X-CacheKit-Fresh-For` on the `PUT` response - keeps its copy of a clamped write for the stated TTL, past the stored entry's `fresh_until`. The + keeps its copy of a clamped write past the stored entry's `fresh_until`. The [feature matrix](sdk-feature-matrix.md) records each SDK's state. diff --git a/conformance/coverage.md b/conformance/coverage.md index 87c20f7..6e0d44b 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) | 80 | 0 | 15 | 0 | 65 | +| [`spec/saas-api.md`](../spec/saas-api.md) | 81 | 0 | 15 | 0 | 66 | | [`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 | 69 | 51 | -| cachekit-ts | 9 | 78 | 7 | 59 | 70 | -| cachekit-rs | 4 | 69 | 3 | 55 | 92 | +| cachekit-py | 12 | 86 | 5 | 70 | 51 | +| cachekit-ts | 9 | 78 | 7 | 60 | 70 | +| cachekit-rs | 4 | 69 | 3 | 56 | 92 | Not indexed yet: [`spec/interop-v2.md`](../spec/interop-v2.md). @@ -217,21 +217,23 @@ Marked not-a-requirement in `spec/intent-presets.md`: | [API-17](../spec/saas-api.md#api-17) | *Encoding rules*: Clients **MUST NOT** double-encode. A literal `%` in a key is sent as `%25` once; `%2525` decodes to `%25`, a different key. | `path-encoding.json`: `100%`, `ns:key`, `%2e%2e`, `User%3AKey`
`tools/path-encoding-verify.py:verify`
**Gap:** The rows pin one call of the encoder: a client that hands the encoded segment to a URL builder that escapes '%' again, turning ns%3Akey into ns%253Akey, passes them unless its test asserts on the parsed request path (API-15). | partial (gap) | partial (gap) | partial (gap) | | [API-18](../spec/saas-api.md#api-18) | *GET /v1/cache/{key}*: … on every `GET` `200 OK` by servers implementing stale-while-revalidate. For `GET` responses, SDKs **MUST** treat an absent header as `fresh` (pre-SWR servers do not emit it) and an unrecognized value as `stale` (revalidation … | `freshness-headers.json`: `freshness_absent`, `freshness_fresh`, `freshness_stale`, `freshness_capitalised`, `freshness_uppercase_stale`, `freshness_empty`, `freshness_nbsp_suffix`, `freshness_list_fresh_first`, `freshness_list_fresh_last`
`tools/freshness-headers-verify.py:verify`
**Gap:** The rows reach the parser an SDK's test hands each value, not its read path: whether it applies the result to the GET 200 it received where it backfills a local cache (API-58), revalidates or treats a stale hit as a miss, 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-19](../spec/saas-api.md#api-19) | *Remaining Freshness*: … fresh service only). Because absence carries that meaning, a tier that implements this section **MUST NOT** produce it for a copy it cannot positively confirm has no expiry — the tier rule below. | Binds the server.
**Gap:** No public vector exercises a re-serving tier, so nothing here catches a tier that passes a pre-signal store's missing X-CacheKit-Fresh-For through, or omits it for a copy it populated without a hint. | n/a | n/a | n/a | -| [API-21](../spec/saas-api.md#api-21) | *Remaining Freshness*: … of the store's `2xx` from the response it relays, nor relay a value larger than the store's. It **MUST NOT** emit a value larger than that decayed remainder — its own copy TTL included — under any label. The deployed tiers … | Binds the server.
**Gap:** As API-19, for a tier that emits its own copy TTL when that is larger than the decayed remainder. | n/a | n/a | n/a | +| [API-20](../spec/saas-api.md#api-20) | *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-21](../spec/saas-api.md#api-21) | *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 — its own copy TTL included — under any label. A tier that forwards a … | Binds the server.
**Gap:** As API-19, for a tier that emits its own copy TTL when that is larger than the decayed remainder. | n/a | n/a | n/a | | [API-22](../spec/saas-api.md#api-22) | *Remaining Freshness*: … (a write without a TTL on a plan with a maximum TTL is bounded, and its `2xx` says so). A tier **MUST** determine a write's TTL with the store's own precedence (`X-CacheKit-TTL`, else `X-TTL`); the absence of … | Binds the server.
**Gap:** As API-19, for a tier that reads only X-CacheKit-TTL and so records a write that carried legacy X-TTL as unbounded. | n/a | n/a | n/a | | [API-23](../spec/saas-api.md#api-23) | *Remaining Freshness*: … or a store below that is pre-signal and therefore omits for bounded entries too — the tier **MUST** emit `X-CacheKit-Fresh-For: 0`. It MUST NOT omit a header it received, nor drop it for such a copy: omission would … | Binds the server.
**Gap:** As API-19, for a tier that omits the header, rather than sending 0, for a copy whose remainder it does not know. | n/a | n/a | n/a | | [API-24](../spec/saas-api.md#api-24) | *Remaining Freshness*: … and therefore omits for bounded entries too — the tier MUST emit `X-CacheKit-Fresh-For: 0`. It **MUST NOT** omit a header it received, nor drop it for such a copy: omission would claim "no bound" for an entry that may have one … | Binds the server.
**Gap:** As API-19, for a tier that strips a received X-CacheKit-Fresh-For, or drops it when it re-serves the copy. | n/a | n/a | n/a | | [API-25](../spec/saas-api.md#api-25) | *Remaining Freshness*: … that may have one and silently restore the unbounded backfill this header exists to kill. A tier **MUST** therefore record at populate whether a copy is unbounded or merely unhinted; a tier that cannot tell the two apart … | Binds the server.
**Gap:** As API-19, for a tier that keeps no record at populate of whether its copy was unbounded or merely unhinted. | n/a | n/a | n/a | | [API-26](../spec/saas-api.md#api-26) | *Remaining Freshness*: … at populate whether a copy is unbounded or merely unhinted; a tier that cannot tell the two apart **MUST** emit `0`. A tier fronting a pre-signal store is in exactly that position — it emits `0`, it does not pass the absence … | Binds the server.
**Gap:** As API-19, for a tier that cannot tell an unbounded copy from an unhinted one and omits the header for both. | n/a | n/a | n/a | +| [API-27](../spec/saas-api.md#api-27) | *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. | n/a | n/a | n/a | | [API-28](../spec/saas-api.md#api-28) | *Remaining Freshness*: … on `HEAD` remains informational, per Stale-While-Revalidate). Correspondingly, a `HEAD` response **MUST NOT** create, refresh, or extend any local entry's service bound. | **Gap:** No vector reaches an SDK's local cache, so nothing here catches one that creates a local entry, or extends its deadline, on a HEAD 200. | gap | gap | gap | | [API-29](../spec/saas-api.md#api-29) | *Remaining Freshness*: … `X-CacheKit-Freshness: fresh` (or unlabelled) with the header present, a local cache (L1) backfill **MUST** bound the entry's local lifetime to at most the header value: `min(local_ttl, fresh_for)`. A value of `0` means the … | **Gap:** No vector reaches an SDK's local cache, so nothing here catches one that backfills with its configured local TTL whatever X-CacheKit-Fresh-For says. | gap | gap | gap | | [API-30](../spec/saas-api.md#api-30) | *Remaining Freshness*: … lifetime to at most the header value: `min(local_ttl, fresh_for)`. A value of `0` means the entry **MUST NOT** be backfilled at all. A `stale` or unrecognized freshness label forbids backfill regardless of the number (Reading a … | `freshness-headers.json`: `fresh_for_zero`
`tools/freshness-headers-verify.py:verify`
**Gap:** The row reaches the parser an SDK's test hands the value: a parser that reads 0 as no value fails it, but no vector reaches the local cache, so nothing here catches an SDK that parses 0 and backfills anyway (API-29). | partial (gap) | partial (gap) | partial (gap) | | [API-31](../spec/saas-api.md#api-31) | *Remaining Freshness*: … value is a hard local service bound, not merely a freshness bound: once it elapses, the local copy **MUST NOT** be served in any form — including by client-side stale-while-revalidate or any local stale-grace policy. (Serving … | **Gap:** As API-29, for an SDK that serves a local copy past its bound through client-side stale-while-revalidate or a local stale-grace policy. | gap | gap | gap | -| [API-32](../spec/saas-api.md#api-32) | *Remaining Freshness*: The value **MUST** be 1–7 ASCII digits and at most `2,592,000` (30 days, itself seven digits: the header's ceiling, which emission above … | 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*: … TTL range). 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-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-85, 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-85, for an SDK that serves such a copy past its bound through a local stale-grace policy. | gap | gap | 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-83, 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-83, 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 | | [API-42](../spec/saas-api.md#api-42) | *PUT /v1/cache/{key}*: Round up to 1. Sub-second durations **MUST** be ceiled, never truncated to 0. | **Gap:** No vector maps a duration to a header, so nothing here catches a client that sends a sub-second TTL unrounded, or truncates it to 0 and then rejects it or sends 0. | gap | gap | gap | @@ -271,13 +273,12 @@ Marked not-a-requirement in `spec/intent-presets.md`: | [API-76](../spec/saas-api.md#api-76) | *Counting outcomes*: … caching wrapper (the cachekit-py decorators, cachekit-ts `wrap()`, cachekit-rs `#[cachekit]`) **MUST** count one outcome, however many reads it makes. A call that runs the wrapped function counts one miss, before its … | **Gap:** No vector covers request headers, so nothing here catches a client that counts a wrapped call that runs the function as anything but one miss, a joined in-process caller as anything but one L1 hit, or a miss on each single-flight re-poll or lock re-check; only the SDK's own counter tests do. | gap | gap | gap | | [API-77](../spec/saas-api.md#api-77) | *Plan maximum TTL*: … so a client can pre-validate every write. Then, on a plan with a maximum TTL `max_ttl`, the server **MUST** store the write with | Binds the server.
**Gap:** No public vector exercises the server, so nothing here catches one that rejects a TTL over the tenant's plan maximum, stores it unclamped, or keeps a stale window that carries the entry past the maximum. | n/a | n/a | n/a | | [API-78](../spec/saas-api.md#api-78) | *Plan maximum TTL*: … so a write whose TTL reaches the maximum stores no stale window. A `PATCH /v1/cache/{key}/ttl` **MUST** be clamped the same way, with its `ttl` in place of `effective_ttl` and the entry's stored stale window in place of … | Binds the server.
**Gap:** As API-77, for a PATCH /ttl handler that stores the requested ttl past the plan maximum, or shortens the fresh window to make room for the stored stale window. | n/a | n/a | n/a | -| [API-79](../spec/saas-api.md#api-79) | *PUT /v1/cache/{key}*: … a plan without one — see No-expiry contract below. The plan maximum is the only default: servers **MUST NOT** apply any other, because a client must be able to know what its write stores. The plan maximum is published in this … | Binds the server.
**Gap:** No public vector exercises the server, so nothing here catches one that stores a write without TTL headers under any default other than the tenant's plan maximum TTL. | n/a | n/a | n/a | +| [API-79](../spec/saas-api.md#api-79) | *PUT /v1/cache/{key}*: … a plan without one — see No-expiry contract below. The plan maximum is the only default: servers **MUST NOT** apply any other, because the only default a writer cannot see must be one this spec publishes and the `2xx` reports … | Binds the server.
**Gap:** No public vector exercises the server, so nothing here catches one that stores a write without TTL headers under any default other than the tenant's plan maximum TTL. | n/a | n/a | n/a | | [API-80](../spec/saas-api.md#api-80) | *Remaining Freshness*: Emitted on a write, too. A signal-capable server **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 store that answers a bounded PUT without X-CacheKit-Fresh-For, or reports the stated TTL instead of the one it applied. | n/a | n/a | n/a | | [API-81](../spec/saas-api.md#api-81) | *Remaining Freshness*: … TTL clamped the write (the writer's own copy below). A server that applies a plan maximum TTL **MUST** be signal-capable, so a `PUT` `2xx` without the header means the store applied exactly the write's effective TTL, or … | Binds the server.
**Gap:** As API-80, for a deployment that clamps writes to a plan maximum TTL before its store emits X-CacheKit-Fresh-For on PUT. | n/a | n/a | n/a | -| [API-82](../spec/saas-api.md#api-82) | *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-83](../spec/saas-api.md#api-83) | *Remaining Freshness*: … and one from the effective TTL ends less than one second after it. A tier that forwards a `PUT` **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 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-84](../spec/saas-api.md#api-84) | *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. | n/a | n/a | n/a | -| [API-85](../spec/saas-api.md#api-85) | *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-82](../spec/saas-api.md#api-82) | *Remaining Freshness*: … that decayed remainder — its own copy TTL included — under any label. A tier that forwards a `PUT` **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 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-83](../spec/saas-api.md#api-83) | *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-84](../spec/saas-api.md#api-84) | *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 | Marked not-a-requirement in `spec/saas-api.md`: diff --git a/conformance/requirements.json b/conformance/requirements.json index 3816d49..5cc1d31 100644 --- a/conformance/requirements.json +++ b/conformance/requirements.json @@ -1521,7 +1521,7 @@ }, "spec/saas-api.md": { "prefix": "API", - "next": 86, + "next": 85, "requirements": { "API-1": { "section": "Authentication", @@ -1763,6 +1763,11 @@ "binds": "server", "gap": "No public vector exercises a re-serving tier, so nothing here catches a tier that passes a pre-signal store's missing X-CacheKit-Fresh-For through, or omits it for a copy it populated without a hint." }, + "API-20": { + "section": "Remaining Freshness", + "binds": "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." + }, "API-21": { "section": "Remaining Freshness", "binds": "server", @@ -1793,6 +1798,11 @@ "binds": "server", "gap": "As API-19, for a tier that cannot tell an unbounded copy from an unhinted one and omits the header for both." }, + "API-27": { + "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." + }, "API-28": { "section": "Remaining Freshness", "binds": "sdk", @@ -1868,12 +1878,12 @@ "API-36": { "section": "Remaining Freshness", "binds": "sdk", - "gap": "As API-85, 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": "As API-83, 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." }, "API-37": { "section": "Remaining Freshness", "binds": "sdk", - "gap": "As API-85, for an SDK that serves such a copy past its bound through a local stale-grace policy." + "gap": "As API-83, for an SDK that serves such a copy past its bound through a local stale-grace policy." }, "API-38": { "section": "Remaining Freshness", @@ -2114,28 +2124,21 @@ "API-82": { "section": "Remaining Freshness", "binds": "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." + "gap": "As API-19, for a tier that drops the X-CacheKit-Fresh-For of the store's PUT 2xx, or relays a larger value." }, "API-83": { "section": "Remaining Freshness", - "binds": "server", - "gap": "As API-19, for a tier that drops the X-CacheKit-Fresh-For of the store's PUT 2xx, or relays a larger value." + "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." }, "API-84": { - "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." - }, - "API-85": { - "section": "Remaining Freshness", + "section": "PATCH /v1/cache/{key}/ttl", "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 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." } }, "retired": { - "API-20": "Replaced by API-82: a tier's copy of a forwarded write takes its bound from the X-CacheKit-Fresh-For on the store's PUT 2xx when that carries one.", - "API-27": "Replaced by API-84: an accepted write qualifies for a positive value by its bound, which the store's PUT 2xx can shorten, not by its stated TTL.", - "API-35": "Replaced by API-85: the writer's copy is also bounded by the X-CacheKit-Fresh-For on the PUT 2xx.", + "API-35": "Replaced by API-83: the writer's copy is also bounded by the X-CacheKit-Fresh-For on the PUT 2xx.", "API-40": "Merged with API-41 into API-79: a write without a TTL is stored with the plan maximum TTL on a plan that has one.", "API-41": "Merged with API-40 into API-79: a write without a TTL is stored with the plan maximum TTL on a plan that has one." } diff --git a/sdk-feature-matrix.md b/sdk-feature-matrix.md index 451130d..086ce30 100644 --- a/sdk-feature-matrix.md +++ b/sdk-feature-matrix.md @@ -186,7 +186,7 @@ The contract a storage backend must satisfy per SDK (bytes in / bytes out; seria | 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`) | ❌ | ❌ | ❌ | -| 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 where a [plan maximum TTL](spec/saas-api.md#plan-maximum-ttl) stored a shorter one ([API-85](spec/saas-api.md#api-85)) | ✅ `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 where a [plan maximum TTL](spec/saas-api.md#plan-maximum-ttl) stored a shorter one ([API-85](spec/saas-api.md#api-85)) | ✅ `@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 where a [plan maximum TTL](spec/saas-api.md#plan-maximum-ttl) stored a shorter one ([API-85](spec/saas-api.md#api-85)) | ❌ | +| 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-83](spec/saas-api.md#api-83) | ⚠️ `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-83](spec/saas-api.md#api-83) | ⚠️ `@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-83](spec/saas-api.md#api-83) | ❌ | > [!IMPORTANT] > ¹³ **The Rust reliability tier ships in `cachekit-rs` 0.6.0+ and is on by default.** Verified inside the published artifact, not the branch: the `cachekit-rs` 0.6.0 `.crate` from crates.io (published 2026-08-03T14:58:16Z) contains `src/reliability.rs`, `src/flight.rs`, `tests/reliability_tests.rs`, and `get_with_swr` in `src/l1/mod.rs`, and its `Cargo.toml` declares `default = ["cachekitio", "encryption", "l1", "reliability"]`. So a plain `cargo add cachekit-rs` gets **circuit breaker, retry, backpressure and L1 SWR** with no feature flags. Two of the six cells need an opt-in feature: macro-level graceful degradation and the automatic `#[cachekit]` single-flight wiring are emitted by the proc-macro, and `macros = ["dep:cachekit-macros"]` is **not** in `default` — add `--features macros`. Redis-backed presets likewise need the non-default `redis` feature (see [Developer Experience](#developer-experience) note ¹¹). diff --git a/spec/saas-api.md b/spec/saas-api.md index 4ff9244..9e6f4d4 100644 --- a/spec/saas-api.md +++ b/spec/saas-api.md @@ -158,14 +158,14 @@ X-CacheKit-L1-Status: miss **Server (emission):** - A **signal-capable server** is one that implements this section. It is independent of [stale-while-revalidate](#stale-while-revalidate) support: a bounded entry has a `fresh_until` whether or not it has a stale window, so a server may emit `X-CacheKit-Fresh-For` without offering one, and a pre-signal SWR server emits `X-CacheKit-Freshness` without it. -- Emitted on **every** `GET` `200 OK` for an entry that has a freshness bound. A **no-expiry** entry — one written without a TTL, neither `X-CacheKit-TTL` nor legacy `X-TTL`, on a plan without a [maximum TTL](#plan-maximum-ttl) ([PUT](#put-v1cachekey)) — has no `fresh_until`, no remainder to report, so the header is **omitted** — the server-side bound is unbounded, `min(local_ttl, ∞)` is the SDK's configured local TTL, and that is exactly the absent-header path below. When present, the value is a non-negative integer: `min(2592000, max(0, floor(fresh_until − now)))`, computed against the **server's clock** at response time — the client never compares server timestamps against its own clock. The `2,592,000` ceiling (30 days) is the header's grammar ([consumption](#remaining-freshness) below), lower than the 90-day [TTL range](#put-v1cachekey): an entry with more than 30 days of freshness left reports `2592000`, never a larger value, so a local copy is bounded short of the entry's true freshness, the conservative direction. +- Emitted on **every** `GET` `200 OK` for an entry that has a freshness bound. A **no-expiry** entry — one written without a TTL, neither `X-CacheKit-TTL` nor legacy `X-TTL`, on a plan without a [maximum TTL](#plan-maximum-ttl) ([PUT](#put-v1cachekey)) — has no `fresh_until`, no remainder to report, so the header is **omitted** — the server-side bound is unbounded, `min(local_ttl, ∞)` is the SDK's configured local TTL, and that is exactly the absent-header path below. When present, the value is a non-negative integer: `min(2592000, max(0, floor(fresh_until − now)))`, computed against the **server's clock** at response time — the client never compares server timestamps against its own clock. So an entry with more than 30 days of freshness left reports `2592000`, never a larger value: the header's cap stays below the 90-day [TTL range](#put-v1cachekey), and a local copy is bounded short of the entry's true freshness, the conservative direction. - Emitted on a **write**, too. A signal-capable server MUSTAPI-80 emit `X-CacheKit-Fresh-For` on every `PUT` `2xx` whose stored entry has a freshness bound, computed as above from the `fresh_until` it stamped, and omits it when the write stored a no-expiry entry. That is how a writer learns the bound the store applied, which is shorter than the one it stated when a [plan maximum TTL](#plan-maximum-ttl) clamped the write ([the writer's own copy](#remaining-freshness) below). A server that applies a plan maximum TTL MUSTAPI-81 be signal-capable, so a `PUT` `2xx` without the header means the store applied exactly the write's [effective TTL](#put-v1cachekey), or stored a no-expiry entry because the write carried none. - Stale-window responses (`X-CacheKit-Freshness: stale`) carry `X-CacheKit-Fresh-For: 0` — freshness is already exhausted. `X-CacheKit-Freshness: fresh` with `X-CacheKit-Fresh-For: 0` is also legal — an entry in its final sub-second of freshness floors to `0`. The response is served to the caller normally; the `0` governs only local caching (no backfill). - Omitted by the store for no-expiry entries, and by pre-signal servers for everything. The client cannot tell the two apart, by design: both mean "no server-side freshness bound applies to this read" and both lead to the same SDK action (configured local TTL, fresh service only). Because absence carries that meaning, a tier that implements this section MUST NOTAPI-19 produce it for a copy it cannot positively confirm has no expiry — the tier rule below. - **Re-serving tiers** — an edge or colo cache in front of the store that answers from a copy it read earlier — are part of the server. A tier's **coherence window** is the deployment-documented maximum time it may keep serving a copy after the store has changed or deleted the entry; a tier that refreshes its copy's lifetime from the tier below rather than from the store adds its window to the path's total, so windows **compound** along the serving path and a deployment's effective window is their sum, not its largest single tier. That governs the *bytes*. The *header* follows stricter, fail-closed rules: - - **Decay, never re-stamp.** A tier MUSTAPI-82 emit its copy's remaining bound minus the seconds elapsed since that bound was obtained, flooring at `0`. A copy has exactly one bound source: the response it was read from (the bound is that response's `X-CacheKit-Fresh-For`), or — for a copy the tier populated from a write it forwarded — that write, once the store has accepted it. The bound of a forwarded write is the `X-CacheKit-Fresh-For` on the store's `PUT` `2xx` when it carries one, otherwise the write's effective TTL (see [Migration](#put-v1cachekey)), and either is measured from when the tier dispatched the write. The store stamps `fresh_until` after that dispatch, from `floor(stored_at)` ([whole-second stamping](#entry-lifecycle)), and computes the header after stamping, so a bound from the header ends no later than `fresh_until`, and one from the effective TTL ends less than one second after it. A tier that forwards a `PUT` MUST NOTAPI-83 drop the `X-CacheKit-Fresh-For` of the store's `2xx` from the response it relays, nor relay a value larger than the store's. It MUST NOTAPI-21 emit a value larger than that decayed remainder — its own copy TTL included — under any label. The deployed tiers decay, so the rule costs a conforming implementation nothing, and it keeps a pre-`DELETE` copy's local service within one second of the entry's `fresh_until`. + - **Decay, never re-stamp.** A tier MUSTAPI-20 emit its copy's remaining bound minus the seconds elapsed since that bound was obtained, flooring at `0`. A copy has exactly one bound source: the response it was read from (the bound is that response's `X-CacheKit-Fresh-For`), or — for a copy the tier populated from a write it forwarded — that write, once the store has accepted it. The bound of a forwarded write is the `X-CacheKit-Fresh-For` on the store's `PUT` `2xx` when it carries one, read with the [consumption](#remaining-freshness) grammar below (a malformed value is `0`, never a fallback to the TTL the write stated), otherwise the write's effective TTL (see [Migration](#put-v1cachekey)), and either is measured from when the tier dispatched the write. The store stamps `fresh_until` after that dispatch, from `floor(stored_at)` ([whole-second stamping](#entry-lifecycle)), and computes the header after stamping, so a bound from the header ends no later than `fresh_until`, and one from the effective TTL ends less than one second after it. It MUST NOTAPI-21 emit a value larger than that decayed remainder — its own copy TTL included — under any label. A tier that forwards a `PUT` MUST NOTAPI-82 drop the `X-CacheKit-Fresh-For` of the store's `2xx` from the response it relays, nor relay a value larger than the store's. The deployed tiers decay, so the rule costs a conforming implementation nothing, and it keeps a pre-`DELETE` copy's local service within one second of the entry's `fresh_until`. - **Omit only on positive knowledge of no expiry; otherwise `0`.** A tier MAY omit the header only for a copy it positively knows has no expiry: a signal-capable store below omitted it (such a store omits only for no-expiry entries), or the tier populated the copy from an accepted write that carried no TTL at all — neither `X-CacheKit-TTL` nor legacy `X-TTL` — and whose `2xx` carried no `X-CacheKit-Fresh-For` (a write without a TTL on a plan with a [maximum TTL](#plan-maximum-ttl) is bounded, and its `2xx` says so). A tier MUSTAPI-22 determine a write's TTL with the store's own precedence (`X-CacheKit-TTL`, else `X-TTL`); the absence of `X-CacheKit-TTL` alone proves nothing, because a legacy `X-TTL: 60` write is bounded. For every other copy whose remainder it does not know — populated without a hint, a header lost in transit, or a store below that is pre-signal and therefore omits for bounded entries too — the tier MUSTAPI-23 emit `X-CacheKit-Fresh-For: 0`. It MUST NOTAPI-24 omit a header it received, nor drop it for such a copy: omission would claim "no bound" for an entry that may have one and silently restore the unbounded backfill this header exists to kill. A tier MUSTAPI-25 therefore record at populate whether a copy is unbounded or merely unhinted; a tier that cannot tell the two apart MUSTAPI-26 emit `0`. A tier fronting a pre-signal store is in exactly that position — it emits `0`, it does not pass the absence through; the cost is no local backfill behind that tier until the store signals, the fail-closed posture a mixed deployment should have. Whether the store below signals is a deployment fact the tier is configured with, never inferred per response. - - **Positive only for `fresh` + positive.** A tier MAY emit a positive value only when its copy's bound source qualifies, and then only the decayed remainder: a response the tier below labelled exactly `X-CacheKit-Freshness: fresh` (or left unlabelled — the pre-SWR `fresh` default) and stamped with a strictly positive `X-CacheKit-Fresh-For`; or an accepted write whose bound (above) is positive, whose copy is labelled `fresh` (a just-accepted write is fresh by construction). Every other shape — `stale`, `X-CacheKit-Fresh-For: 0` under any label, an unrecognized freshness token, or a missing `X-CacheKit-Fresh-For` on any entry not positively known to be no-expiry — MUSTAPI-84 be emitted with `X-CacheKit-Fresh-For: 0` and its freshness label passed through unchanged (a `fresh` `0` stays `fresh`; a `stale` stays `stale`). Stamping a positive bound onto a stale, exhausted, or unknown value resurrects an expired (or revoked) entry as locally-cacheable fresh — the exact hole this header closes. + - **Positive only for `fresh` + positive.** A tier MAY emit a positive value only when its copy's bound source qualifies, and then only the decayed remainder: a response the tier below labelled exactly `X-CacheKit-Freshness: fresh` (or left unlabelled — the pre-SWR `fresh` default) and stamped with a strictly positive `X-CacheKit-Fresh-For`; or an accepted write whose bound (above) is positive, whose copy is labelled `fresh` (a just-accepted write is fresh by construction). Every other shape — `stale`, `X-CacheKit-Fresh-For: 0` under any label, an unrecognized freshness token, or a missing `X-CacheKit-Fresh-For` on any entry not positively known to be no-expiry — MUSTAPI-27 be emitted with `X-CacheKit-Fresh-For: 0` and its freshness label passed through unchanged (a `fresh` `0` stays `fresh`; a `stale` stays `stale`). Stamping a positive bound onto a stale, exhausted, or unknown value resurrects an expired (or revoked) entry as locally-cacheable fresh — the exact hole this header closes. - `HEAD` does **not** carry this header — an existence check returns no payload, so there is nothing to backfill locally (the `X-CacheKit-Freshness` label on `HEAD` remains informational, per [Stale-While-Revalidate](#stale-while-revalidate)). Correspondingly, a `HEAD` response MUST NOTAPI-28 create, refresh, or extend any local entry's service bound. **SDK (consumption):** @@ -173,11 +173,11 @@ X-CacheKit-L1-Status: miss - On a `GET` `200 OK` labelled `X-CacheKit-Freshness: fresh` (or unlabelled) with the header present, a local cache (L1) backfill MUSTAPI-29 bound the entry's local lifetime to at most the header value: `min(local_ttl, fresh_for)`. A value of `0` means the entry MUST NOTAPI-30 be backfilled at all. A `stale` or unrecognized freshness label forbids backfill regardless of the number ([Reading a stale entry](#reading-a-stale-entry)) — a positive `X-CacheKit-Fresh-For` on a `stale` response is a server bug, not a license. - The header value is a hard local **service** bound, not merely a freshness bound: once it elapses, the local copy MUST NOTAPI-31 be served in any form — including by client-side stale-while-revalidate or any local stale-grace policy. (Serving server-returned stale bytes per [Reading a stale entry](#reading-a-stale-entry) is unaffected — this rule governs only the local copy.) **Why there is no local stale service, stated once:** the client receives no remaining-eviction signal, so a locally-stale copy could not honor the store's [`evict_at` bound](#reading-a-stale-entry) — and one is deliberately not provided, because it would let clients replicate the stale window locally, invisibly to server-side revalidation single-flight and metering. Stale service is the server's job: a subsequent read hits the server, which serves the stale window itself (`X-CacheKit-Freshness: stale`, `X-CacheKit-Fresh-For: 0`) until `evict_at`. Rules elsewhere refer back here rather than restating this. - Absent header on a `GET` `200 OK` = 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` (30 days, itself seven digits: the header's ceiling, which [emission](#remaining-freshness) above keeps below the 90-day [TTL range](#put-v1cachekey)). 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`). +- 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 store's answer to it: the store can apply a shorter TTL than the write stated ([plan maximum TTL](#plan-maximum-ttl)), and reports the bound it applied as `X-CacheKit-Fresh-For` on the `PUT` `2xx` ([emission](#remaining-freshness) above). Once the store accepts the `PUT`, the SDK MUST NOTAPI-85 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 `2xx` without the header means the store applied the effective TTL, or stored a no-expiry entry for a write without one, so a write with neither a TTL nor the header keeps the configured local TTL, as for an absent header on a read. An SDK that predates the header on `PUT` bounds its copy by `min(local_ttl, effective_ttl)` alone, so on a plan with a maximum TTL its copy can outlive `fresh_until` by up to that bound less the plan's maximum TTL, on top of the write-path tolerance below. 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. On a plan with a maximum TTL it can outlive `fresh_until` by up to `min(local_ttl, effective_ttl)` less that maximum on top of this, because without a `2xx` the SDK cannot learn the bound the store applied. 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`. +- **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-83 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). An SDK that predates the header on `PUT` bounds its copy by `min(local_ttl, effective_ttl)` alone, so on a plan with a maximum TTL its copy can outlive `fresh_until` by up to that bound less the plan's maximum TTL, on top of the write-path tolerance below. 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. On a plan with a maximum TTL it can outlive `fresh_until` by up to `min(local_ttl, effective_ttl)` less that maximum on top of this, because without a `2xx` the SDK cannot learn the bound the store applied. 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 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` (on a plan with a [maximum TTL](#plan-maximum-ttl), plus the amount by which that copy's `min(local_ttl, effective_ttl)` exceeds the maximum), the same excess for a writer that predates `X-CacheKit-Fresh-For` on `PUT`, 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 any plan-maximum excess ([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** (on a plan with a [maximum TTL](#plan-maximum-ttl), the amount by which `min(local_ttl, effective_ttl)` exceeds the maximum, for a writer's copy bounded without the `PUT` `2xx`'s header: kept without a `2xx`, or kept by an SDK that predates the header), 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. --- @@ -218,7 +218,7 @@ X-CacheKit-L1-Status: miss > > | Condition | SDK Behavior | Server Behavior | > | :--- | :--- | :--- | -> | TTL omitted | Use client default TTL. If no client default, omit `X-CacheKit-TTL` header. | When the request carries neither `X-CacheKit-TTL` nor `X-TTL`: store with the tenant's [plan maximum TTL](#plan-maximum-ttl), or with **no expiry** on a plan without one — see **No-expiry contract** below. The plan maximum is the only default: servers MUST NOTAPI-79 apply any other, because a client must be able to know what its write stores. The plan maximum is published in this spec, and the `2xx` reports the bound it produced ([Remaining Freshness](#remaining-freshness)). | +> | TTL omitted | Use client default TTL. If no client default, omit `X-CacheKit-TTL` header. | When the request carries neither `X-CacheKit-TTL` nor `X-TTL`: store with the tenant's [plan maximum TTL](#plan-maximum-ttl), or with **no expiry** on a plan without one — see **No-expiry contract** below. The plan maximum is the only default: servers MUST NOTAPI-79 apply any other, because the only default a writer cannot see must be one this spec publishes and the `2xx` reports ([Remaining Freshness](#remaining-freshness)). | > | TTL = 0 | **Reject** — return error to caller. Zero is not a valid TTL. | **Reject** — return `400 Bad Request`. | > | TTL < 1 second | **Round up to 1.** Sub-second durations MUSTAPI-42 be ceiled, never truncated to 0. | N/A (header is integer seconds). | > | TTL > 7,776,000 | **Reject** — return error to caller. | **Reject** — return `400 Bad Request`. | @@ -226,15 +226,15 @@ X-CacheKit-L1-Status: miss > | TTL negative | **Reject** — return error to caller. | **Reject** — return `400 Bad Request`. | > | TTL non-integer | N/A (SDK converts duration to integer seconds). | **Reject** — return `400 Bad Request`. | > -> **No-expiry contract.** An entry written without a TTL — neither `X-CacheKit-TTL` nor legacy `X-TTL` (see **Migration** below) — on a plan without a [maximum TTL](#plan-maximum-ttl) has no `fresh_until` and no `evict_at`. On a plan with a maximum TTL such a write is bounded instead, so no-expiry entries exist only on a plan without one, or from before the tenant's plan gained one: +> **No-expiry contract.** An entry written without a TTL — neither `X-CacheKit-TTL` nor legacy `X-TTL` (see **Migration** below) — on a plan without a [maximum TTL](#plan-maximum-ttl) has no `fresh_until` and no `evict_at`. On a plan with a maximum TTL such a write is bounded instead, so no-expiry entries exist only on a plan without one, from a write whose plan the server could not determine ([Plan maximum TTL](#plan-maximum-ttl)), or from before the tenant's plan gained one: > - **Reads:** permanently **fresh** — while the entry is present, `GET` and `HEAD` return `200 OK` with `X-CacheKit-Freshness: fresh`, and `GET` carries no [`X-CacheKit-Fresh-For`](#remaining-freshness). It never enters a stale window. Presence ends only on explicit `DELETE`, overwrite, or capacity eviction (below). > - **Storage bound: deployment-configured.** No-expiry entries are never age-evicted. Whether, and at what ceiling, a deployment applies capacity eviction is deployment configuration outside this protocol; no-expiry entries are eligible victims of whatever capacity eviction the deployment applies, so "no expiry" is **not** a durability guarantee, and an evicted entry gives no client-visible signal beyond a subsequent miss. A deployment that needs a specific storage ceiling must provision a tenant or namespace quota. > - **`GET /v1/cache/{key}/ttl`:** see the [endpoint](#get-v1cachekeyttl). > - **`PATCH /v1/cache/{key}/ttl`:** `200`; gives the entry its first expiry (`fresh_until = floor(now) + ttl`, [whole-second stamping](#entry-lifecycle), with `ttl` clamped to the [plan maximum TTL](#plan-maximum-ttl) on a plan with one) — the one way to bound an existing no-expiry entry without rewriting it. That `200` is indistinguishable from the [absent-key no-op](#patch-v1cachekeyttl); confirm presence separately if it matters. > -> **Rationale:** TTL=0 is ambiguous across cache systems (Redis rejects it, Memcached treats it as "never expire", HTTP treats it as "immediately stale"). CacheKit defines TTL=0 as an error to prevent silent data loss (the Redis and HTTP readings) or an unintended no-expiry entry (the Memcached reading) — no expiry is requested by omitting the header, never by `0`. Sub-second durations are ceiled to 1 rather than truncated to 0 to avoid the same ambiguity. The 90-day maximum bounds the value range of a **stated** TTL; it is not a storage-lifetime ceiling. The [plan maximum TTL](#plan-maximum-ttl) is one, on a plan that has it, and clamps rather than rejects, so a write that passes these plan-independent rules never fails on a plan the client cannot see. The [`X-CacheKit-Fresh-For`](#remaining-freshness) grammar keeps its 30-day ceiling, which predates the 90-day range. Entries that need a long but bounded life should renew explicitly via `PATCH /v1/cache/{key}/ttl`. +> **Rationale:** TTL=0 is ambiguous across cache systems (Redis rejects it, Memcached treats it as "never expire", HTTP treats it as "immediately stale"). CacheKit defines TTL=0 as an error to prevent silent data loss (the Redis and HTTP readings) or an unintended no-expiry entry (the Memcached reading) — no expiry is requested by omitting the header, never by `0`. Sub-second durations are ceiled to 1 rather than truncated to 0 to avoid the same ambiguity. The 90-day maximum bounds the value range of a **stated** TTL; it is not a storage-lifetime ceiling. The [plan maximum TTL](#plan-maximum-ttl) bounds each stamp on a plan that has it, and clamps rather than rejects, so a write that passes these plan-independent rules never fails on a plan the client cannot see. Entries that need a long but bounded life should renew explicitly via `PATCH /v1/cache/{key}/ttl`. > -> **Migration:** The `X-TTL` header is deprecated. The server MUSTAPI-43 accept both `X-CacheKit-TTL` and `X-TTL` during the transition period, preferring `X-CacheKit-TTL` when both are present. A write's **effective TTL** is therefore `X-CacheKit-TTL` when present, otherwise `X-TTL`; a write has no TTL only when it carries **neither**, and then stores a no-expiry entry, or one bounded by the [plan maximum TTL](#plan-maximum-ttl) on a plan with one. The effective TTL is what the write states; the TTL the store applies is the effective TTL clamped to the plan maximum. Every rule in this spec that depends on whether a write carried a TTL reads the effective TTL: the no-expiry rule, the stale-window requirement, and the re-serving-tier rules in [Remaining Freshness](#remaining-freshness). SDKs MUSTAPI-44 send `X-CacheKit-TTL` only. The `X-TTL` header will be removed in protocol version 2.0 (targeted at SDK 1.0 milestone). +> **Migration:** The `X-TTL` header is deprecated. The server MUSTAPI-43 accept both `X-CacheKit-TTL` and `X-TTL` during the transition period, preferring `X-CacheKit-TTL` when both are present. A write's **effective TTL** is therefore `X-CacheKit-TTL` when present, otherwise `X-TTL`; a write has no TTL only when it carries **neither**. The effective TTL is what the write states; the TTL the store applies is the effective TTL clamped to the plan maximum. Every rule in this spec that depends on whether a write carried a TTL reads the effective TTL: the no-expiry rule, the stale-window requirement, and the re-serving-tier rules in [Remaining Freshness](#remaining-freshness). SDKs MUSTAPI-44 send `X-CacheKit-TTL` only. The `X-TTL` header will be removed in protocol version 2.0 (targeted at SDK 1.0 milestone). > [!IMPORTANT] > **Maximum value size:** A single cache value may be at most **25 MB**. Larger values are rejected with `413 Payload Too Large` — a **permanent** error: SDKs MUST NOTAPI-45 retry and SHOULD surface "value too large" to the caller. This ceiling MAY change, so SDKs MUSTAPI-46 treat any `413` as "value too large" regardless of the exact byte count. It is unrelated to the SDK serializer's 512 MiB in-memory safety bound (see `wire-format.md`) — that bound governs what the SDK will serialize, not what the service will store. @@ -252,7 +252,7 @@ X-CacheKit-L1-Status: miss #### Plan maximum TTL -Each plan has a maximum TTL: the longest an entry written under it lives in the store, its stale window included. +Each plan has a maximum TTL: the longest a write or `PATCH` under it can set an entry's `evict_at` from its stamp, the stale window included. A later `PATCH` can renew the entry within the same maximum. | Plan | Maximum TTL | | :--- | :--- | @@ -268,9 +268,9 @@ applied_ttl = min(effective_ttl, max_ttl), or max_ttl for a write without applied_stale_ttl = min(stale_ttl, max_ttl − applied_ttl) ``` -so that `evict_at − floor(stored_at) ≤ max_ttl`. The fresh window is kept first: the stale window gets only what the maximum leaves, so a write whose TTL reaches the maximum stores no stale window. A [`PATCH /v1/cache/{key}/ttl`](#patch-v1cachekeyttl) MUSTAPI-78 be clamped the same way, with its `ttl` in place of `effective_ttl` and the entry's stored stale window in place of `stale_ttl`, so that `evict_at − floor(now) ≤ max_ttl`. On a plan without a maximum TTL, writes and `PATCH`es are stored as stated, and a write without a TTL stores a no-expiry entry. +so that `evict_at − floor(stored_at) ≤ max_ttl`. The fresh window is kept first: the stale window gets only what the maximum leaves, so a write whose TTL reaches the maximum stores no stale window. A [`PATCH /v1/cache/{key}/ttl`](#patch-v1cachekeyttl) MUSTAPI-78 be clamped the same way, with its `ttl` in place of `effective_ttl` and the entry's stored stale window in place of `stale_ttl`, so that `evict_at − floor(now) ≤ max_ttl`. On a plan without a maximum TTL, writes and `PATCH`es are stored as stated, and a write without a TTL stores a no-expiry entry. A server that cannot determine the tenant's plan for a write or `PATCH` applies that rule too; its `2xx` reports what it stored, so every copy stays bounded by the stored entry. -The maximum applies when a write or `PATCH` is stamped. An entry keeps the timing it was stored with when the tenant's plan changes later, a no-expiry entry included. The store reports the bound it applied on the `PUT` `2xx` as `X-CacheKit-Fresh-For` ([Remaining Freshness](#remaining-freshness)), and `GET /v1/cache/{key}/ttl` reads the remaining lifetime at any time. +The maximum applies when a write or `PATCH` is stamped. An entry keeps the timing it was stored with when the tenant's plan changes later, a no-expiry entry included, until its next write or `PATCH`. --- @@ -344,7 +344,7 @@ All lifecycle times are computed against the **store's clock**; SDKs MUST NOTAPI-55 re-send both the `X-CacheKit-TTL` and the stale window it intends to keep — the previous bound is never carried forward, so a recompute that carries neither `X-CacheKit-TTL` nor legacy `X-TTL` stores a **no-expiry** entry, or one bounded by the [plan maximum TTL](#plan-maximum-ttl) on a plan with one ([PUT](#put-v1cachekey)). -- **`PATCH /v1/cache/{key}/ttl` within the fresh window** resets `fresh_until = floor(now) + ttl` ([whole-second stamping](#entry-lifecycle)) and preserves the entry's stored stale window; the combined total is re-validated against the 90-day TTL range, then clamped to the [plan maximum TTL](#plan-maximum-ttl) on a plan with one, which can shorten the stale window. +- **`PATCH /v1/cache/{key}/ttl` within the fresh window** resets `fresh_until = floor(now) + ttl` ([whole-second stamping](#entry-lifecycle)) and preserves the entry's stored stale window; the combined total is re-validated against the 90-day TTL range, then clamped ([API-78](#api-78)), which can shorten the stale window. - **`PATCH /v1/cache/{key}/ttl` on an entry past `fresh_until` MUSTAPI-56 return `409 Conflict`.** A stale entry regains freshness only via a `PUT` of recomputed bytes — otherwise a routine TTL-renewal job could indefinitely resurrect stale data without revalidation, defeating the `evict_at` bound. ### Reading a stale entry @@ -417,7 +417,7 @@ After a project is deleted, its API keys stop working within about 40 seconds wh Not-found results are held for at most 5 seconds or the namespace's budget, whichever is lower, and values read in their `stale_ttl` window are never held as edge copies. Every `GET` of a cache key that returns a value or a not-found says where it came from (`X-CacheKit-Store-Source`) and, for an edge copy, how old it is (`Age`). -Your SDK's in-process L1 cache is separate and is not told about other processes' writes. When a response carries `X-CacheKit-Fresh-For`, SDKs that honour it (cachekit-py 0.19.0 and later, cachekit-rs 0.9.0 and later, cachekit-ts 0.2.1 and later) keep a copy for at most that many seconds; otherwise the L1 copy keeps its own configured TTL. Edge age and L1 life add up. +Your SDK's in-process L1 cache is separate and is not told about other processes' writes. When a read's response carries `X-CacheKit-Fresh-For`, SDKs that honour it (cachekit-py 0.19.0 and later, cachekit-rs 0.9.0 and later, cachekit-ts 0.2.1 and later) keep a copy for at most that many seconds; otherwise the L1 copy keeps its own configured TTL. Edge age and L1 life add up. --- @@ -511,12 +511,12 @@ Content-Type: application/json {"ttl": 7200} ``` -The `ttl` field follows the same validation rules as `X-CacheKit-TTL`: positive integer, minimum 1, maximum 7,776,000. On a plan with a [maximum TTL](#plan-maximum-ttl) the store clamps it, and the stored stale window, to that maximum; the `200` does not report the applied value, so a client that needs it reads `GET /v1/cache/{key}/ttl`. For entries stored with a stale-grace window, see [SWR write semantics](#write-semantics): a PATCH within the fresh window renews it; a PATCH on a stale entry MUSTAPI-73 return `409 Conflict`. A `PATCH` on a no-expiry entry is within the fresh window by definition and bounds it: `fresh_until = floor(now) + ttl` ([whole-second stamping](#entry-lifecycle)), no stale window. +The `ttl` field follows the same validation rules as `X-CacheKit-TTL`: positive integer, minimum 1, maximum 7,776,000. On a plan with a [maximum TTL](#plan-maximum-ttl) the store clamps it, and the stored stale window, to that maximum. The `200` does not report the applied value (`GET /v1/cache/{key}/ttl` reports the remaining time to `evict_at`, stale window included), so a `PATCH` response MUST NOTAPI-84 create, refresh or extend any local entry's service bound. For entries stored with a stale-grace window, see [SWR write semantics](#write-semantics): a PATCH within the fresh window renews it; a PATCH on a stale entry MUSTAPI-73 return `409 Conflict`. A `PATCH` on a no-expiry entry is within the fresh window by definition and bounds it: `fresh_until = floor(now) + ttl` ([whole-second stamping](#entry-lifecycle)), no stale window. | Status | Meaning | | :---: | :--- | | `200 OK` | TTL updated. Also returned for an absent or evicted key — the request is a no-op; this endpoint never returns `404` | -| `400 Bad Request` | Invalid TTL (zero, negative, exceeds maximum), or `ttl` plus the entry's stored stale window exceeds 7,776,000 (the 90-day TTL range). The client cannot pre-validate the second case: the stale window is server-side state. A `PATCH` with a smaller `ttl` can succeed | +| `400 Bad Request` | Invalid TTL (zero, negative, over 7,776,000), or `ttl` plus the entry's stored stale window exceeds 7,776,000 (the 90-day TTL range). The client cannot pre-validate the second case: the stale window is server-side state. A `PATCH` with a smaller `ttl` can succeed | | `409 Conflict` | Entry is past `fresh_until` ([SWR write semantics](#write-semantics)) — refresh requires a `PUT` of recomputed bytes | --- From 1f1efab8baf81ac3e88c44ad51a830f92115c1bd Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 31 Oct 2026 21:16:12 +1100 Subject: [PATCH 3/8] =?UTF-8?q?fix:=20address=20coderabbit=20review=20?= =?UTF-8?q?=E2=80=94=20qualify=20PUT=20header=20and=20fallback,=20cap=20ti?= =?UTF-8?q?er=20emission?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit-Resolved: changelog.d/20261031_plan-maximum-ttl.md:6:Document the unresolved-plan CodeRabbit-Resolved: changelog.d/20261031_plan-maximum-ttl.md:18:Qualify the PUT response hea CodeRabbit-Resolved: sdk-feature-matrix.md:302:Update the legacy TypeScript server CodeRabbit-Resolved: spec/saas-api.md:166:Cap the forwarded-write remainder --- changelog.d/20261031_plan-maximum-ttl.md | 13 ++++++++----- conformance/coverage.md | 2 +- sdk-feature-matrix.md | 2 +- spec/saas-api.md | 2 +- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/changelog.d/20261031_plan-maximum-ttl.md b/changelog.d/20261031_plan-maximum-ttl.md index d350afe..ba3a2c6 100644 --- a/changelog.d/20261031_plan-maximum-ttl.md +++ b/changelog.d/20261031_plan-maximum-ttl.md @@ -4,8 +4,10 @@ (24 hours), Startup 2,592,000 s (30 days), Professional 7,776,000 s (90 days), Enterprise none. On a plan with a maximum, a stated TTL above it is stored with the maximum, not rejected, and a write without a TTL is stored with the maximum instead of with no expiry - ([API-77](spec/saas-api.md#api-77)). The maximum bounds `evict_at` from each write's stamp: the - fresh window is kept first and the stale window gets what is left. `PATCH /ttl` is clamped the + ([API-77](spec/saas-api.md#api-77)). A server that cannot determine the tenant's plan stores the + write as stated, and with no expiry when it carries no TTL, so the plan maximum is not a + retention guarantee; the `2xx` reports what was stored. The maximum bounds `evict_at` from each + write's stamp: the fresh window is kept first and the stale window gets what is left. `PATCH /ttl` is clamped the same way ([API-78](spec/saas-api.md#api-78)), and its response must not extend a local copy ([API-84](spec/saas-api.md#api-84)). Validation stays plan-independent, so a client can still pre-validate every write. On Enterprise nothing is clamped, and entries stored before the change @@ -14,9 +16,10 @@ 2,592,000 to 7,776,000 (`stale_ttl` alone to 7,775,999). A larger value is still a `400`. - The plan maximum is the only default for a write without a TTL ([API-79](spec/saas-api.md#api-79), which merges and retires API-40 and API-41). -- A `PUT` `2xx` now carries `X-CacheKit-Fresh-For`: the freshness bound the store applied - ([API-80](spec/saas-api.md#api-80)), and a server that clamps must send it - ([API-81](spec/saas-api.md#api-81)). An SDK bounds its copy of its own write by +- A signal-capable server's `PUT` `2xx` now carries `X-CacheKit-Fresh-For` for a bounded entry: the + freshness bound the store applied ([API-80](spec/saas-api.md#api-80)); it is omitted for a + no-expiry entry, and a pre-signal server omits it for every write. A server that clamps must + send it ([API-81](spec/saas-api.md#api-81)). An SDK bounds its copy of its own write by `min(local_ttl, effective_ttl, fresh_for)` ([API-83](spec/saas-api.md#api-83), replacing API-35). A re-serving tier takes its copy of a forwarded write's bound from the same header ([API-20](spec/saas-api.md#api-20)) and relays it unraised ([API-82](spec/saas-api.md#api-82)). diff --git a/conformance/coverage.md b/conformance/coverage.md index 6e0d44b..4e9c6a6 100644 --- a/conformance/coverage.md +++ b/conformance/coverage.md @@ -218,7 +218,7 @@ Marked not-a-requirement in `spec/intent-presets.md`: | [API-18](../spec/saas-api.md#api-18) | *GET /v1/cache/{key}*: … on every `GET` `200 OK` by servers implementing stale-while-revalidate. For `GET` responses, SDKs **MUST** treat an absent header as `fresh` (pre-SWR servers do not emit it) and an unrecognized value as `stale` (revalidation … | `freshness-headers.json`: `freshness_absent`, `freshness_fresh`, `freshness_stale`, `freshness_capitalised`, `freshness_uppercase_stale`, `freshness_empty`, `freshness_nbsp_suffix`, `freshness_list_fresh_first`, `freshness_list_fresh_last`
`tools/freshness-headers-verify.py:verify`
**Gap:** The rows reach the parser an SDK's test hands each value, not its read path: whether it applies the result to the GET 200 it received where it backfills a local cache (API-58), revalidates or treats a stale hit as a miss, 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-19](../spec/saas-api.md#api-19) | *Remaining Freshness*: … fresh service only). Because absence carries that meaning, a tier that implements this section **MUST NOT** produce it for a copy it cannot positively confirm has no expiry — the tier rule below. | Binds the server.
**Gap:** No public vector exercises a re-serving tier, so nothing here catches a tier that passes a pre-signal store's missing X-CacheKit-Fresh-For through, or omits it for a copy it populated without a hint. | n/a | n/a | n/a | | [API-20](../spec/saas-api.md#api-20) | *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-21](../spec/saas-api.md#api-21) | *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 — its own copy TTL included — under any label. A tier that forwards a … | Binds the server.
**Gap:** As API-19, for a tier that emits its own copy TTL when that is larger than the decayed remainder. | n/a | n/a | n/a | +| [API-21](../spec/saas-api.md#api-21) | *Remaining Freshness*: … the header's maximum (API-32), as the store's is: a copy with more left reports `2592000`. It **MUST NOT** emit a value larger than that decayed remainder — its own copy TTL included — under any label. A tier that forwards a … | Binds the server.
**Gap:** As API-19, for a tier that emits its own copy TTL when that is larger than the decayed remainder. | n/a | n/a | n/a | | [API-22](../spec/saas-api.md#api-22) | *Remaining Freshness*: … (a write without a TTL on a plan with a maximum TTL is bounded, and its `2xx` says so). A tier **MUST** determine a write's TTL with the store's own precedence (`X-CacheKit-TTL`, else `X-TTL`); the absence of … | Binds the server.
**Gap:** As API-19, for a tier that reads only X-CacheKit-TTL and so records a write that carried legacy X-TTL as unbounded. | n/a | n/a | n/a | | [API-23](../spec/saas-api.md#api-23) | *Remaining Freshness*: … or a store below that is pre-signal and therefore omits for bounded entries too — the tier **MUST** emit `X-CacheKit-Fresh-For: 0`. It MUST NOT omit a header it received, nor drop it for such a copy: omission would … | Binds the server.
**Gap:** As API-19, for a tier that omits the header, rather than sending 0, for a copy whose remainder it does not know. | n/a | n/a | n/a | | [API-24](../spec/saas-api.md#api-24) | *Remaining Freshness*: … and therefore omits for bounded entries too — the tier MUST emit `X-CacheKit-Fresh-For: 0`. It **MUST NOT** omit a header it received, nor drop it for such a copy: omission would claim "no bound" for an entry that may have one … | Binds the server.
**Gap:** As API-19, for a tier that strips a received X-CacheKit-Fresh-For, or drops it when it re-serves the copy. | n/a | n/a | n/a | diff --git a/sdk-feature-matrix.md b/sdk-feature-matrix.md index 086ce30..c3d26e3 100644 --- a/sdk-feature-matrix.md +++ b/sdk-feature-matrix.md @@ -299,7 +299,7 @@ its spec: | Temporal sentinels ([spec](spec/wire-format.md#datetime-extension-format)) | ✅ PyPI 0.22.0 (wheel run 2026-10-07): the default path revives `__datetime__`, `__date__` and `__time__` (each `temporal_sentinel_vectors` payload under `StandardSerializer`), as do `AutoSerializer` and interop mode, and all three write them; the three `wire-format.json` 1.4.0 `temporal_sentinel_vectors` run in CI since 0.23.0 ([cachekit-py#550](https://github.com/cachekit-io/cachekit-py/pull/550)) | ❌ crates.io 0.10.0 (`.crate` inspected 2026-10-07): revives none and writes none, in either mode; interop's value profile refuses a datetime; `main` at `b5fb2a6` likewise (no sentinel reviver in `src/`). cachekit-rs vendors no `wire-format.json` | ❌ npm 0.2.1 (`.tgz` inspected 2026-10-24; 0.1.5, inspected 2026-10-07, behaves the same): the default path revives none, and writes a `Date` as its ISO string (`dist/serialization/serializer.js`); interop mode revives only `__datetime__`, leaving `__date__` and `__time__` as maps, since JavaScript has no date-only or time-only type. Since 0.2.1 the `wire-format.json` 1.4.0 `temporal_sentinel_vectors` run in both lanes as expected failures naming [WIRE-20](spec/wire-format.md#wire-20), each pinning the map the read returns ([cachekit-ts#229](https://github.com/cachekit-io/cachekit-ts/pull/229)) | ⚠️ Untested | | Encryption (AES-256-GCM) | ✅ Compliant | ✅ Canonical (cachekit-core) | ✅ Compliant | ⚠️ Untested | | AAD v0x03 | ✅ Compliant 0.23.0+ (5 components — every auto serializer appends `original_type`; interop mode is the sole 4-component path; wheels tested 2026-10-19). Earlier releases fall short: through 0.22.0 the `cache_key` component omits a prefixing backend's key prefix, short of the [logical backend key](spec/encryption.md#additional-authenticated-data-aad) ([cachekit-py#518](https://github.com/cachekit-io/cachekit-py/pull/518); see the Encryption table); and, against [ENC-2](spec/encryption.md#enc-2)/[ENC-3](spec/encryption.md#enc-3), 0.9.0 through 0.22.0 accept bare Arrow IPC on an encrypted Arrow read ([cachekit-py#517](https://github.com/cachekit-io/cachekit-py/pull/517)), 0.9.0 through 0.22.0 sniff the decrypted plaintext on a config-drift read by an `auto` handler with encryption off of an entry stored under the `auto` serializer name ([cachekit-py#521](https://github.com/cachekit-io/cachekit-py/pull/521)), 0.1.0 through 0.22.0 sniff it in a directly built `EncryptionWrapper(serializer=AutoSerializer())` ([cachekit-py#521](https://github.com/cachekit-io/cachekit-py/pull/521)), and 0.1.0 through 0.4.0 also sniff it with the default `EncryptionWrapper` constructor and on every encrypted read by a cache handler built with `encryption=True`, not from 0.6.1 (see the Encryption section) | ✅ Compliant (4 components) | ✅ Compliant (4 components) | ❌ Not implemented | -| SaaS API | ⚠️ Compliant (CachekitIO backend) except, through `cachekit` 0.22.0 (wheel inspected 2026-10-05): a PUT with a TTL also sends the legacy `X-TTL` ([API-44](spec/saas-api.md#api-44)); any fractional `ttl`, the window `@cache.io` derives from it, and a fractional `refresh_ttl` value go on the wire unrounded, so the server answers `400` and the write is lost ([API-42](spec/saas-api.md#api-42), [API-54](spec/saas-api.md#api-54)); both fixed in 0.23.0 ([cachekit-py#520](https://github.com/cachekit-io/cachekit-py/pull/520)). Where the [TTL rules](spec/saas-api.md#put-v1cachekey) have the SDK reject a TTL with an error to the caller, `ttl=0` and a TTL over 7,776,000 are sent instead, so the server answers `400` and the write is lost. Through 0.25.1 (wheel inspected 2026-10-31) a decorator refuses a `ttl + stale_ttl` over 2,592,000 with `ConfigurationError`, though the [stale-window range](spec/saas-api.md#validation) now runs to 7,776,000 (`_max_total_ttl` in `decorators/wrapper.py`) | ⚠️ Compliant (CachekitIO backend) except, through `cachekit-rs` 0.10.0 (`.crate` inspected 2026-10-05): `refresh_ttl` on the CachekitIO and Workers backends rejects a sub-second TTL instead of sending 1 ([API-42](spec/saas-api.md#api-42); `PATCH /ttl` takes the `X-CacheKit-TTL` rules), fixed in 0.11.0 ([cachekit-rs#142](https://github.com/cachekit-io/cachekit-rs/pull/142)); and a TTL over 7,776,000 is sent, where the [TTL rules](spec/saas-api.md#put-v1cachekey) have the SDK reject it, so the server answers `400` and the write is lost (`validate_ttl` rejects only zero) | ⚠️ Compliant (CachekitIO backend) from npm 0.2.1 on the [TTL rules](spec/saas-api.md#put-v1cachekey), [API-44](spec/saas-api.md#api-44) and [API-65](spec/saas-api.md#api-65) (`.tgz` inspected 2026-10-24): a PUT sends only `X-CacheKit-TTL`, ceiled to whole seconds, and refuses a zero, negative, non-finite or over-2,592,000 TTL with `ConfigurationError` before sending. That last bound predates the 90-day TTL range, so through 0.2.3 (`.tgz` inspected 2026-10-31) it refuses a TTL from 2,592,001 to 7,776,000 that the server accepts (`MAX_TTL_SECONDS` in `dist/backends/cachekitio.js`) ([cachekit-ts#110](https://github.com/cachekit-io/cachekit-ts/pull/110); `validateTtl` in `dist/backends/cachekitio.js`); 🚧 unreleased, on `main` for npm 0.3.0 ([cachekit-ts#274](https://github.com/cachekit-io/cachekit-ts/pull/274)): a `wrap()` with `staleTtl` also sends `X-CacheKit-Stale-TTL`, ceiled to whole seconds and checked against [API-54](spec/saas-api.md#api-54) and a 30-day cap before sending (`validateStaleTtl`), where the [stale-window range](spec/saas-api.md#validation) now runs to 7,776,000, and every write of that key sends it again ([API-55](spec/saas-api.md#api-55)); a lock response is a lease only when its `lock_id` is a non-empty string ([API-65](spec/saas-api.md#api-65); [cachekit-ts#215](https://github.com/cachekit-io/cachekit-ts/pull/215); `dist/backends/cachekitio-lockable.js`); `src/backends/cachekitio-ttl.test.ts` and `src/backends/cachekitio-lockable.test.ts` pass at tag `cachekit-v0.2.1`. ⚠️ Through 0.1.5 (`.tgz` inspected 2026-10-05) a PUT sends only the legacy `X-TTL` ([API-44](spec/saas-api.md#api-44)), unrounded, omits it for `ttl` 0, storing the entry with no expiry, and sends a TTL over 2,592,000 for the server to reject with `400`; and a `200` lock response whose `lock_id` is absent, empty or not a string counts as a lease, so single-flight is off and the release sends `undefined` as the lock id | ❌ Not implemented | +| SaaS API | ⚠️ Compliant (CachekitIO backend) except, through `cachekit` 0.22.0 (wheel inspected 2026-10-05): a PUT with a TTL also sends the legacy `X-TTL` ([API-44](spec/saas-api.md#api-44)); any fractional `ttl`, the window `@cache.io` derives from it, and a fractional `refresh_ttl` value go on the wire unrounded, so the server answers `400` and the write is lost ([API-42](spec/saas-api.md#api-42), [API-54](spec/saas-api.md#api-54)); both fixed in 0.23.0 ([cachekit-py#520](https://github.com/cachekit-io/cachekit-py/pull/520)). Where the [TTL rules](spec/saas-api.md#put-v1cachekey) have the SDK reject a TTL with an error to the caller, `ttl=0` and a TTL over 7,776,000 are sent instead, so the server answers `400` and the write is lost. Through 0.25.1 (wheel inspected 2026-10-31) a decorator refuses a `ttl + stale_ttl` over 2,592,000 with `ConfigurationError`, though the [stale-window range](spec/saas-api.md#validation) now runs to 7,776,000 (`_max_total_ttl` in `decorators/wrapper.py`) | ⚠️ Compliant (CachekitIO backend) except, through `cachekit-rs` 0.10.0 (`.crate` inspected 2026-10-05): `refresh_ttl` on the CachekitIO and Workers backends rejects a sub-second TTL instead of sending 1 ([API-42](spec/saas-api.md#api-42); `PATCH /ttl` takes the `X-CacheKit-TTL` rules), fixed in 0.11.0 ([cachekit-rs#142](https://github.com/cachekit-io/cachekit-rs/pull/142)); and a TTL over 7,776,000 is sent, where the [TTL rules](spec/saas-api.md#put-v1cachekey) have the SDK reject it, so the server answers `400` and the write is lost (`validate_ttl` rejects only zero) | ⚠️ Compliant (CachekitIO backend) from npm 0.2.1 on the [TTL rules](spec/saas-api.md#put-v1cachekey), [API-44](spec/saas-api.md#api-44) and [API-65](spec/saas-api.md#api-65) (`.tgz` inspected 2026-10-24): a PUT sends only `X-CacheKit-TTL`, ceiled to whole seconds, and refuses a zero, negative, non-finite or over-2,592,000 TTL with `ConfigurationError` before sending. That last bound predates the 90-day TTL range, so through 0.2.3 (`.tgz` inspected 2026-10-31) it refuses a TTL from 2,592,001 to 7,776,000 that the server accepts (`MAX_TTL_SECONDS` in `dist/backends/cachekitio.js`) ([cachekit-ts#110](https://github.com/cachekit-io/cachekit-ts/pull/110); `validateTtl` in `dist/backends/cachekitio.js`); 🚧 unreleased, on `main` for npm 0.3.0 ([cachekit-ts#274](https://github.com/cachekit-io/cachekit-ts/pull/274)): a `wrap()` with `staleTtl` also sends `X-CacheKit-Stale-TTL`, ceiled to whole seconds and checked against [API-54](spec/saas-api.md#api-54) and a 30-day cap before sending (`validateStaleTtl`), where the [stale-window range](spec/saas-api.md#validation) now runs to 7,776,000, and every write of that key sends it again ([API-55](spec/saas-api.md#api-55)); a lock response is a lease only when its `lock_id` is a non-empty string ([API-65](spec/saas-api.md#api-65); [cachekit-ts#215](https://github.com/cachekit-io/cachekit-ts/pull/215); `dist/backends/cachekitio-lockable.js`); `src/backends/cachekitio-ttl.test.ts` and `src/backends/cachekitio-lockable.test.ts` pass at tag `cachekit-v0.2.1`. ⚠️ Through 0.1.5 (`.tgz` inspected 2026-10-05) a PUT sends only the legacy `X-TTL` ([API-44](spec/saas-api.md#api-44)), unrounded, omits it for `ttl` 0, so the entry is stored with no expiry, or with the [plan maximum TTL](spec/saas-api.md#plan-maximum-ttl) on a plan with one, and sends a TTL over 7,776,000 for the server to reject with `400`; and a `200` lock response whose `lock_id` is absent, empty or not a string counts as a lease, so single-flight is off and the release sends `undefined` as the lock id | ❌ Not implemented | | SaaS API — cache-key path encoding ([spec](spec/saas-api.md#cache-key-path-encoding)) | ✅ Compliant — rejects the empty key since 0.21.0 ([cachekit-py#397](https://github.com/cachekit-io/cachekit-py/pull/397)); `.`, `..`, `health`, `ttl` and `lock` since 0.20.0 ([cachekit-py#364](https://github.com/cachekit-io/cachekit-py/pull/364)); rules 1/3/4 since 0.18.0 ([cachekit-py#279](https://github.com/cachekit-io/cachekit-py/pull/279)) | ✅ Compliant — rejects the empty key since `cachekit-rs` 0.9.0 ([cachekit-rs#100](https://github.com/cachekit-io/cachekit-rs/pull/100); `.crate` inspected 2026-10-10); rejects `.`, `..`, `health`, `ttl` and `lock` since `cachekit-rs` 0.8.0 ([cachekit-rs#76](https://github.com/cachekit-io/cachekit-rs/pull/76); `.crate` inspected 2026-09-30) | ✅ Compliant — `@cachekit-io/cachekit` 0.2.1+ ([cachekit-ts#118](https://github.com/cachekit-io/cachekit-ts/pull/118); `encodeKey` in `dist/backends/cachekitio.js`, `.tgz` inspected 2026-10-24; `test/protocol/path-encoding.protocol.test.ts` passes at tag `cachekit-v0.2.1`); absent from 0.1.5 | ❌ Not implemented | | Test vectors in CI¹⁶ | ✅ interop/v1 (full set, incl. AAD + encryption through the real stack) — fixture 1.1.0 (`ns`/`nsapi` namespace reservation) since PyPI 0.20.0 ([cachekit-py#350](https://github.com/cachekit-io/cachekit-py/pull/350)); fixture 1.2.0 (no `..` in a segment) since 0.21.0 ([cachekit-py#391](https://github.com/cachekit-io/cachekit-py/pull/391)); fixture 1.3.0 vendored byte for byte (sha256-pinned) + CI-executed since 0.23.0 ([cachekit-py#537](https://github.com/cachekit-io/cachekit-py/pull/537)), its key, value and error vectors and both reader groups through the interop key derivation and value reader; `decode-bounds.json` vendored + CI-executed since [cachekit-py#276](https://github.com/cachekit-io/cachekit-py/pull/276) (LAB-2503); `1.1.0` since 0.20.0 ([cachekit-py#363](https://github.com/cachekit-io/cachekit-py/pull/363)); `1.2.0` (the `incomplete` reason) vendored byte for byte (sha256-pinned) + CI-executed since 0.23.0 ([cachekit-py#537](https://github.com/cachekit-io/cachekit-py/pull/537)), both `incomplete` vectors included; every version asserting the structural guard's own error for every reject vector at every payload read path (the [Decode bounds](spec/interop-mode.md#decode-bounds) MUST); at the envelope entry point (`ByteStorage.retrieve`), every `1.2.0` reject vector asserts cachekit-core 0.7.0's pre-scan error, with the reason for each depth-only and overclaim-only vector, and every accept vector gets past the pre-scan, in [cachekit-py#627](https://github.com/cachekit-io/cachekit-py/pull/627) (unreleased; see the Wire format row); `path-encoding.json` vendored + CI-executed since 0.20.0 ([cachekit-py#364](https://github.com/cachekit-io/cachekit-py/pull/364)); fixture 1.1.0 (empty-key row) since 0.21.0 ([cachekit-py#397](https://github.com/cachekit-io/cachekit-py/pull/397)); `path-encoding.json` 1.2.0 and `freshness-headers.json` 1.0.0 vendored byte for byte (sha256-pinned) + CI-executed since 0.23.0 ([cachekit-py#522](https://github.com/cachekit-io/cachekit-py/pull/522)); `cache-keys.json` 1.1.0 ([cachekit-py#460](https://github.com/cachekit-io/cachekit-py/pull/460)), `file-backend.json` 1.1.0 ([cachekit-py#431](https://github.com/cachekit-io/cachekit-py/pull/431)) and `wire-format.json` 1.3.0 ([cachekit-py#448](https://github.com/cachekit-io/cachekit-py/pull/448); its reject vectors: see the Wire format row) vendored byte for byte (sha256-pinned) + CI-executed, each since 0.21.0; `wire-format.json` 1.4.0 (every new accept, constructed, reject, payload-reject and temporal vector), `file-backend.json` 1.2.0 (every `miss_preserve` vector on all five read paths, the expired flagged entries included, and every `return_payload` vector) and `python-frame.json` (first vendor: every frame vector through `SerializationWrapper.unwrap`, every error vector at the check its `rejected_by` names, the CK frame through the interop reader, and every `encrypted_read_vectors` frame failing closed under both tamper policies with the tenant resolved by the reader) vendored byte for byte (sha256-pinned) + CI-executed since 0.23.0 ([cachekit-py#550](https://github.com/cachekit-io/cachekit-py/pull/550)); `encryption.json` `1.5.0` (first vendor: both `master_key_input` accept rows and every reject row, every `keyring.configuration` row, every `aad_reject_vectors` row failing authentication, and the `decrypted_container` rows for its checksummed Arrow and orjson readers returning no value), `cache-keys.json` `1.3.0` (every key, error and serializer-object vector) and the `python-frame.json` revision of [protocol#180](https://github.com/cachekit-io/protocol/pull/180) (11 frame vectors: not yet the [protocol#193](https://github.com/cachekit-io/protocol/pull/193) revision, which adds `padded_header_frame`), each vendored byte for byte (sha256-pinned) + CI-executed since 0.23.0 ([cachekit-py#561](https://github.com/cachekit-io/cachekit-py/pull/561)) — ⚠️ except two `keyring.configuration` cases, `keyring_current_key_decrypt_only` and its uppercase twin, each with the current key given as `master_key=`, which run as strict expected failures naming [ENC-9](spec/encryption.md#enc-9): cachekit-py refuses the current key repeated in `CACHEKIT_PREVIOUS_MASTER_KEYS` only on the first call, not at load | ✅ interop/v1 (full set) since [#33](https://github.com/cachekit-io/cachekit-rs/pull/33) — fixture 1.1.0 since `cachekit-rs` 0.8.0 ([cachekit-rs#89](https://github.com/cachekit-io/cachekit-rs/pull/89); `.crate` inspected 2026-09-30); fixture 1.2.0 since 0.9.0 ([cachekit-rs#98](https://github.com/cachekit-io/cachekit-rs/pull/98); `.crate` inspected 2026-10-06); fixture 1.3.0 vendored byte for byte (sha256-pinned) + CI-executed in [cachekit-rs#152](https://github.com/cachekit-io/cachekit-rs/pull/152), since 0.11.0, both reader groups through `interop::deserialize` and every value vector read back through it; `decode-bounds.json` vendored + CI-executed since [cachekit-rs#73](https://github.com/cachekit-io/cachekit-rs/pull/73) (LAB-2503; default CI green on `main`); `1.1.0` since 0.8.0 ([cachekit-rs#93](https://github.com/cachekit-io/cachekit-rs/pull/93)); `1.2.0` (the `incomplete` reason) in [cachekit-rs#152](https://github.com/cachekit-io/cachekit-rs/pull/152), since 0.11.0, asserting the structural guard's own `decode bound:` error for every reject vector through both decoders and `get` / `interop_get` / `interop_get_swr` — the guard enforces depth itself since [cachekit-rs#93](https://github.com/cachekit-io/cachekit-rs/pull/93) (no envelope entry point¹⁵); `path-encoding.json` 1.1.0 vendored byte for byte (sha256-pinned) + CI-executed since 0.9.0 ([cachekit-rs#100](https://github.com/cachekit-io/cachekit-rs/pull/100); `.crate` inspected 2026-10-10), driving both the `encode_key` and the URL-builder tests; fixture 1.2.0 (encoder-mistake rows) and `freshness-headers.json` 1.0.0, vendored byte for byte (sha256-pinned) + CI-executed in [cachekit-rs#146](https://github.com/cachekit-io/cachekit-rs/pull/146), since 0.11.0, every freshness row run through the `GET` response's header reader (`freshness_from_headers`); `file-backend.json` 1.1.0 vendored byte for byte + CI-executed since 0.9.0 ([cachekit-rs#107](https://github.com/cachekit-io/cachekit-rs/pull/107); `.crate` inspected 2026-10-05), by tests in `src/backend/file.rs` behind the `file` feature, which default CI enables; `1.2.0` re-vendored byte for byte (sha256-pinned) + CI-executed in [cachekit-rs#158](https://github.com/cachekit-io/cachekit-rs/pull/158), since 0.11.0, every vector through both header read paths (`read_entry` behind `get`, `probe_header` behind `exists` and `ttl`), the expired flagged entries included, and the five vectors a version-1 writer can produce through `write_entry`; `python-frame.json` first vendored byte for byte (sha256-pinned) + CI-executed in [cachekit-rs#158](https://github.com/cachekit-io/cachekit-rs/pull/158) and re-vendored in [cachekit-rs#160](https://github.com/cachekit-io/cachekit-rs/pull/160), both since 0.11.0, its 29 CK-prefixed vectors refused by both decoders and `get` / `interop_get` / `interop_get_swr`, on a client without encryption (auto mode by the trailing bytes, interop mode by naming the frame) — ⚠️ except `bare_envelope_fed_to_frame_reader`, which decodes¹⁵; `encryption.json` first vendored byte for byte (sha256-pinned) + CI-executed at `1.3.0` in [cachekit-rs#150](https://github.com/cachekit-io/cachekit-rs/pull/150) and re-vendored at `1.5.0` in [cachekit-rs#160](https://github.com/cachekit-io/cachekit-rs/pull/160), both since 0.11.0: both master key input accept rows accepted by every hex and raw-bytes entry point and decrypted through the `secure` hex and `CACHEKIT_MASTER_KEY` paths, the `keyring.configuration` rows at load, and the `aad_reject_vectors` and `decrypted_container` rows whose AAD this reader builds, each through the reads of `get` / `interop_get` / `interop_get_swr` that can present it (`get` only for the namespaced-prefix row, the interop reads only for the two interop-reader container rows) | ✅ interop/v1 (full set, incl. its key vectors) + inline Python-generated AAD-construction and encryption (decrypt-Python-ciphertext) vectors — every "since 0.2.1" below is a PR in the `cachekit-v0.2.1` tag's history (checked 2026-10-24; the Node lane's `test/protocol` suite passes at that tag against the published `cachekit-core-ts` 0.2.0 binding); fixture 1.1.0 since npm 0.2.1 ([cachekit-ts#143](https://github.com/cachekit-io/cachekit-ts/pull/143); `'nsapi'` in `dist/serialization/interop.js` of the 0.2.1 `.tgz`, absent from 0.1.5's `validateInteropSegment`); fixture 1.2.0 since 0.2.1 ([cachekit-ts#164](https://github.com/cachekit-io/cachekit-ts/pull/164); `must not contain '..'` in `dist/serialization/interop.js`, absent from 0.1.5); fixture 1.3.0 vendored byte for byte (sha256-pinned) + CI-executed in [cachekit-ts#224](https://github.com/cachekit-io/cachekit-ts/pull/224), since 0.2.1: the key, value and error vectors and both reader groups, every value vector read back through `decodeInteropValue` as its value, with the reader fixes two of them needed (a `__proto__` map key reads as an ordinary key, and a leading U+FEFF is kept); decode bounds enforced ([#112](https://github.com/cachekit-io/cachekit-ts/pull/112)); `decode-bounds.json` vendored + CI-executed since [cachekit-ts#121](https://github.com/cachekit-io/cachekit-ts/pull/121) (LAB-2737), `1.1.0` in [cachekit-ts#152](https://github.com/cachekit-io/cachekit-ts/pull/152), since 0.2.1; `1.2.0` (the `incomplete` reason) in [cachekit-ts#224](https://github.com/cachekit-io/cachekit-ts/pull/224), since 0.2.1, both incomplete vectors through all three untrusted decode entry points (`MessagePackSerializer.decode`, `decodeInteropValue`, `deserializeEvent`), asserting the pre-scan's own error; every reject vector asserts the guard error it trips (pre-scan, or the event size cap ahead of it) — ⚠️ except, through npm 0.2.1, the envelope entry point (`unpack` in `cachekit-core-ts` and `cachekit-core-wasm`), whose bindings embed `cachekit-core` 0.6.0 and so have no guard to assert; 🚧 unreleased — on `main`, `unpack` runs every vector too, in the Node lane and inside workerd, in [cachekit-ts#245](https://github.com/cachekit-io/cachekit-ts/pull/245): each reject vector fails with `cachekit-core` 0.7.0's envelope pre-scan error (both bindings on core 0.7.0 since [cachekit-ts#240](https://github.com/cachekit-io/cachekit-ts/pull/240)), pinning the depth bound on depth-only vectors and the byte or slot budget on overclaim-only ones, and each accept vector gets past the pre-scan to core's typed decode; it stays unreleased until a published `@cachekit-io/cachekit` depends on `cachekit-core-ts` and `cachekit-core-wasm` releases that embed core 0.7.0, because npm 0.2.1 pins both at exactly 0.2.0 (see the Wire format row); for the three reject vectors over the invalidation event entry point's (`deserializeEvent`) 4096 B size cap, the test also calls that site's structural guard (`assertDecodeDepth`, at depth 3) directly and asserts its rejection, as [Decode bounds](spec/interop-mode.md#decode-bounds) requires where a size cap rejects first, in [cachekit-ts#182](https://github.com/cachekit-io/cachekit-ts/pull/182), since 0.2.1; `path-encoding.json` vendored + CI-executed since [cachekit-ts#118](https://github.com/cachekit-io/cachekit-ts/pull/118), since 0.2.1; fixture 1.1.0 (empty-key row) in [cachekit-ts#165](https://github.com/cachekit-io/cachekit-ts/pull/165), since 0.2.1; fixture 1.2.0 (encoder-mistake rows) in [cachekit-ts#217](https://github.com/cachekit-io/cachekit-ts/pull/217), since 0.2.1, every row through `encodeKey` and all eight request-path operations; `freshness-headers.json` 1.0.0 vendored byte for byte (sha256-pinned) + CI-executed in [cachekit-ts#217](https://github.com/cachekit-io/cachekit-ts/pull/217), since 0.2.1, each row through a real `Headers` and `freshnessFromHeaders`; `wire-format.json` 1.3.0 vendored byte for byte + CI-executed in the Node and Workers lanes in [cachekit-ts#184](https://github.com/cachekit-io/cachekit-ts/pull/184), since 0.2.1 (its reject vectors: see the Wire format row); `1.4.0` re-vendored byte for byte (sha256-pinned) in [cachekit-ts#229](https://github.com/cachekit-io/cachekit-ts/pull/229), since 0.2.1, every group in both lanes (reject and payload reject vectors: see the Wire format row; temporal sentinels: see their row); `file-backend.json` 1.2.0 in [cachekit-ts#229](https://github.com/cachekit-io/cachekit-ts/pull/229) (footnote ⁴); `python-frame.json` vendored byte for byte (sha256-pinned) + CI-executed in [cachekit-ts#229](https://github.com/cachekit-io/cachekit-ts/pull/229), since 0.2.1, for its container vectors ([WIRE-21](spec/wire-format.md#wire-21)): the envelope read path and the interop reader refuse `ck_frame_fed_to_interop_reader`, the envelope read path refuses `plain_msgpack_fed_to_frame_reader`, and `bare_envelope_fed_to_frame_reader`, cachekit-ts's own default container, is read by every cachekit-ts reader; the [protocol#180](https://github.com/cachekit-io/protocol/pull/180) revision re-vendored byte for byte (sha256-pinned) in [cachekit-ts#230](https://github.com/cachekit-io/cachekit-ts/pull/230), since 0.2.1, where both readers also refuse the error vectors `serializer_name_missing` and `serializer_name_empty`, whole CK v3 frames. Its frame-check and `encrypted_read_vectors` groups target cachekit-py's CK frame reader, which cachekit-ts does not have; `encryption.json` vendored + CI-executed in the Workers lane since 0.1.4 ([cachekit-ts#78](https://github.com/cachekit-io/cachekit-ts/pull/78)); `1.2.0`, with its `keyring` and `default_tenant` groups, re-vendored byte for byte (sha256-pinned) in [cachekit-ts#211](https://github.com/cachekit-io/cachekit-ts/pull/211), since 0.2.1; `1.3.0` re-vendored byte for byte (sha256-pinned) in [cachekit-ts#222](https://github.com/cachekit-io/cachekit-ts/pull/222), since 0.2.1, its accept and eleven hex reject master key input rows driven through `createCache.secure` with the key passed both as `masterKey` and through `CACHEKIT_MASTER_KEY` (cachekit-ts has no raw-key entry point, so the raw-bytes rows do not apply); `1.5.0` re-vendored byte for byte (sha256-pinned) in [cachekit-ts#230](https://github.com/cachekit-io/cachekit-ts/pull/230), since 0.2.1: both master key accept rows, `master_key_first_byte_80` included, through `createCache.secure`; each `keyring.configuration` row accepted or refused as its `verdict` says, through `encryption.previousMasterKeys`, `createCache.secure`'s `previousMasterKeys` and `CACHEKIT_PREVIOUS_MASTER_KEYS`; the five `aad_reject_vectors` rows whose AAD inputs a cachekit-ts reader builds, each failing authentication with degradation off (the other five need an `original_type` or a format no cachekit-ts reader builds); the four `decrypted_container` rows that name a cachekit-ts reader (the Arrow and orjson rows name cachekit-py's); and the raw wasm bindings decrypt `standard_serializer_default` and `standard_serializer_integrity_off` in the Workers lane, each under its own `aad_hex` (no cachekit-ts reader builds their five-component AAD) | ⚠️ Pending | | Interop mode ([spec](spec/interop-mode.md), opt-in) | ✅ Released — PyPI 0.14.0+¹⁷ ([#220](https://github.com/cachekit-io/cachekit-py/pull/220)) | ✅ Released — crates.io 0.4.0+ ([#33](https://github.com/cachekit-io/cachekit-rs/pull/33)) | ✅ Released — npm 0.1.3+ ([#71](https://github.com/cachekit-io/cachekit-ts/pull/71)) | ❌ Not implemented | diff --git a/spec/saas-api.md b/spec/saas-api.md index 9e6f4d4..7fefc8d 100644 --- a/spec/saas-api.md +++ b/spec/saas-api.md @@ -163,7 +163,7 @@ X-CacheKit-L1-Status: miss - Stale-window responses (`X-CacheKit-Freshness: stale`) carry `X-CacheKit-Fresh-For: 0` — freshness is already exhausted. `X-CacheKit-Freshness: fresh` with `X-CacheKit-Fresh-For: 0` is also legal — an entry in its final sub-second of freshness floors to `0`. The response is served to the caller normally; the `0` governs only local caching (no backfill). - Omitted by the store for no-expiry entries, and by pre-signal servers for everything. The client cannot tell the two apart, by design: both mean "no server-side freshness bound applies to this read" and both lead to the same SDK action (configured local TTL, fresh service only). Because absence carries that meaning, a tier that implements this section MUST NOTAPI-19 produce it for a copy it cannot positively confirm has no expiry — the tier rule below. - **Re-serving tiers** — an edge or colo cache in front of the store that answers from a copy it read earlier — are part of the server. A tier's **coherence window** is the deployment-documented maximum time it may keep serving a copy after the store has changed or deleted the entry; a tier that refreshes its copy's lifetime from the tier below rather than from the store adds its window to the path's total, so windows **compound** along the serving path and a deployment's effective window is their sum, not its largest single tier. That governs the *bytes*. The *header* follows stricter, fail-closed rules: - - **Decay, never re-stamp.** A tier MUSTAPI-20 emit its copy's remaining bound minus the seconds elapsed since that bound was obtained, flooring at `0`. A copy has exactly one bound source: the response it was read from (the bound is that response's `X-CacheKit-Fresh-For`), or — for a copy the tier populated from a write it forwarded — that write, once the store has accepted it. The bound of a forwarded write is the `X-CacheKit-Fresh-For` on the store's `PUT` `2xx` when it carries one, read with the [consumption](#remaining-freshness) grammar below (a malformed value is `0`, never a fallback to the TTL the write stated), otherwise the write's effective TTL (see [Migration](#put-v1cachekey)), and either is measured from when the tier dispatched the write. The store stamps `fresh_until` after that dispatch, from `floor(stored_at)` ([whole-second stamping](#entry-lifecycle)), and computes the header after stamping, so a bound from the header ends no later than `fresh_until`, and one from the effective TTL ends less than one second after it. It MUST NOTAPI-21 emit a value larger than that decayed remainder — its own copy TTL included — under any label. A tier that forwards a `PUT` MUST NOTAPI-82 drop the `X-CacheKit-Fresh-For` of the store's `2xx` from the response it relays, nor relay a value larger than the store's. The deployed tiers decay, so the rule costs a conforming implementation nothing, and it keeps a pre-`DELETE` copy's local service within one second of the entry's `fresh_until`. + - **Decay, never re-stamp.** A tier MUSTAPI-20 emit its copy's remaining bound minus the seconds elapsed since that bound was obtained, flooring at `0`. A copy has exactly one bound source: the response it was read from (the bound is that response's `X-CacheKit-Fresh-For`), or — for a copy the tier populated from a write it forwarded — that write, once the store has accepted it. The bound of a forwarded write is the `X-CacheKit-Fresh-For` on the store's `PUT` `2xx` when it carries one, read with the [consumption](#remaining-freshness) grammar below (a malformed value is `0`, never a fallback to the TTL the write stated), otherwise the write's effective TTL (see [Migration](#put-v1cachekey)), and either is measured from when the tier dispatched the write. The store stamps `fresh_until` after that dispatch, from `floor(stored_at)` ([whole-second stamping](#entry-lifecycle)), and computes the header after stamping, so a bound from the header ends no later than `fresh_until`, and one from the effective TTL ends less than one second after it. The value a tier emits is also capped at `2592000`, the header's maximum ([API-32](#api-32)), as the store's is: a copy with more left reports `2592000`. It MUST NOTAPI-21 emit a value larger than that decayed remainder — its own copy TTL included — under any label. A tier that forwards a `PUT` MUST NOTAPI-82 drop the `X-CacheKit-Fresh-For` of the store's `2xx` from the response it relays, nor relay a value larger than the store's. The deployed tiers decay, so the rule costs a conforming implementation nothing, and it keeps a pre-`DELETE` copy's local service within one second of the entry's `fresh_until`. - **Omit only on positive knowledge of no expiry; otherwise `0`.** A tier MAY omit the header only for a copy it positively knows has no expiry: a signal-capable store below omitted it (such a store omits only for no-expiry entries), or the tier populated the copy from an accepted write that carried no TTL at all — neither `X-CacheKit-TTL` nor legacy `X-TTL` — and whose `2xx` carried no `X-CacheKit-Fresh-For` (a write without a TTL on a plan with a [maximum TTL](#plan-maximum-ttl) is bounded, and its `2xx` says so). A tier MUSTAPI-22 determine a write's TTL with the store's own precedence (`X-CacheKit-TTL`, else `X-TTL`); the absence of `X-CacheKit-TTL` alone proves nothing, because a legacy `X-TTL: 60` write is bounded. For every other copy whose remainder it does not know — populated without a hint, a header lost in transit, or a store below that is pre-signal and therefore omits for bounded entries too — the tier MUSTAPI-23 emit `X-CacheKit-Fresh-For: 0`. It MUST NOTAPI-24 omit a header it received, nor drop it for such a copy: omission would claim "no bound" for an entry that may have one and silently restore the unbounded backfill this header exists to kill. A tier MUSTAPI-25 therefore record at populate whether a copy is unbounded or merely unhinted; a tier that cannot tell the two apart MUSTAPI-26 emit `0`. A tier fronting a pre-signal store is in exactly that position — it emits `0`, it does not pass the absence through; the cost is no local backfill behind that tier until the store signals, the fail-closed posture a mixed deployment should have. Whether the store below signals is a deployment fact the tier is configured with, never inferred per response. - **Positive only for `fresh` + positive.** A tier MAY emit a positive value only when its copy's bound source qualifies, and then only the decayed remainder: a response the tier below labelled exactly `X-CacheKit-Freshness: fresh` (or left unlabelled — the pre-SWR `fresh` default) and stamped with a strictly positive `X-CacheKit-Fresh-For`; or an accepted write whose bound (above) is positive, whose copy is labelled `fresh` (a just-accepted write is fresh by construction). Every other shape — `stale`, `X-CacheKit-Fresh-For: 0` under any label, an unrecognized freshness token, or a missing `X-CacheKit-Fresh-For` on any entry not positively known to be no-expiry — MUSTAPI-27 be emitted with `X-CacheKit-Fresh-For: 0` and its freshness label passed through unchanged (a `fresh` `0` stays `fresh`; a `stale` stays `stale`). Stamping a positive bound onto a stale, exhausted, or unknown value resurrects an expired (or revoked) entry as locally-cacheable fresh — the exact hole this header closes. - `HEAD` does **not** carry this header — an existence check returns no payload, so there is nothing to backfill locally (the `X-CacheKit-Freshness` label on `HEAD` remains informational, per [Stale-While-Revalidate](#stale-while-revalidate)). Correspondingly, a `HEAD` response MUST NOTAPI-28 create, refresh, or extend any local entry's service bound. From fee24db07346c965a5c74ec59ddc30597377e232 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 31 Oct 2026 21:46:06 +1100 Subject: [PATCH 4/8] docs(saas-api): retire the tier rules whose demand changed, gate clamping on relay Retire API-20, API-21 and API-27 for API-85 to API-87: the bound source of a forwarded write changed what they demand. Keep "signal-capable" meaning GET emission, require the PUT header only of a clamping store, and forbid clamping until every forwarding tier relays and honours it. Scope the clamp to a determined plan with the fallback stated apart, define a no-expiry entry by its stored state (glossary included), and trim restated text. --- CONTEXT.md | 10 +++++- changelog.d/20261031_plan-maximum-ttl.md | 44 ++++++++++++++---------- conformance/coverage.md | 14 ++++---- conformance/requirements.json | 43 ++++++++++++----------- sdk-feature-matrix.md | 2 +- spec/saas-api.md | 32 +++++++++-------- 6 files changed, 82 insertions(+), 63 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 2a39101..2adeb4d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -129,9 +129,17 @@ The period after the freshness window during which CachekitIO still serves an en _Avoid_: grace period, stale TTL (that is the window's length) **No-expiry entry**: -An entry written without a TTL, which never enters a stale window and stays until it is deleted, overwritten or evicted for capacity. +An entry stored with no end to its freshness window, which never enters a stale window and stays until it is deleted, overwritten or evicted for capacity. Only a write without a TTL that no plan maximum TTL bounded stores one: on a plan without a maximum, when CachekitIO could not determine the plan, or before the plan gained a maximum. _Avoid_: permanent entry, infinite TTL +**Plan maximum TTL**: +The longest a write or TTL update under a tenant's plan can make an entry live in CachekitIO, its stale window included: 24 hours on Free, 30 days on Startup, 90 days on Professional, none on Enterprise. A longer TTL, or none, is shortened to it, never rejected. +_Avoid_: TTL cap, max TTL (on their own) + +**TTL range**: +The values a stated TTL may take, 1 to 7,776,000 seconds (90 days); a value outside it is rejected. It bounds what a client may ask for, not how long an entry lives. +_Avoid_: maximum TTL + **Revalidation**: The client recomputing a stale entry in the background and writing the result back, while callers are served the stale value. _Avoid_: refresh (on its own), server-side recompute diff --git a/changelog.d/20261031_plan-maximum-ttl.md b/changelog.d/20261031_plan-maximum-ttl.md index ba3a2c6..9979ea0 100644 --- a/changelog.d/20261031_plan-maximum-ttl.md +++ b/changelog.d/20261031_plan-maximum-ttl.md @@ -4,31 +4,37 @@ (24 hours), Startup 2,592,000 s (30 days), Professional 7,776,000 s (90 days), Enterprise none. On a plan with a maximum, a stated TTL above it is stored with the maximum, not rejected, and a write without a TTL is stored with the maximum instead of with no expiry - ([API-77](spec/saas-api.md#api-77)). A server that cannot determine the tenant's plan stores the - write as stated, and with no expiry when it carries no TTL, so the plan maximum is not a - retention guarantee; the `2xx` reports what was stored. The maximum bounds `evict_at` from each - write's stamp: the fresh window is kept first and the stale window gets what is left. `PATCH /ttl` is clamped the + ([API-77](spec/saas-api.md#api-77)). The maximum bounds `evict_at` from each write's stamp: the + fresh window is kept first and the stale window gets what is left. `PATCH /ttl` is clamped the same way ([API-78](spec/saas-api.md#api-78)), and its response must not extend a local copy ([API-84](spec/saas-api.md#api-84)). Validation stays plan-independent, so a client can still - pre-validate every write. On Enterprise nothing is clamped, and entries stored before the change - keep their timing until their next write or `PATCH`. -- The value range of `X-CacheKit-TTL`, of `PATCH /ttl`'s `ttl` and of `ttl + stale_ttl` rises from + pre-validate every write. A server that cannot determine the tenant's plan stores the write as + stated, so the plan maximum is not a guaranteed ceiling on an entry's lifetime. Entries stored + before the change keep their timing until their next write or `PATCH`. +- The TTL range of `X-CacheKit-TTL`, of `PATCH /ttl`'s `ttl` and of `ttl + stale_ttl` rises from 2,592,000 to 7,776,000 (`stale_ttl` alone to 7,775,999). A larger value is still a `400`. - The plan maximum is the only default for a write without a TTL ([API-79](spec/saas-api.md#api-79), which merges and retires API-40 and API-41). -- A signal-capable server's `PUT` `2xx` now carries `X-CacheKit-Fresh-For` for a bounded entry: the - freshness bound the store applied ([API-80](spec/saas-api.md#api-80)); it is omitted for a - no-expiry entry, and a pre-signal server omits it for every write. A server that clamps must - send it ([API-81](spec/saas-api.md#api-81)). An SDK bounds its copy of its own write by - `min(local_ttl, effective_ttl, fresh_for)` ([API-83](spec/saas-api.md#api-83), replacing API-35). - A re-serving tier takes its copy of a forwarded write's bound from the same header - ([API-20](spec/saas-api.md#api-20)) and relays it unraised ([API-82](spec/saas-api.md#api-82)). -- The `X-CacheKit-Fresh-For` grammar keeps its 2,592,000 maximum: a server reports an entry with - more than 30 days of freshness left as `2592000`. +- A store that clamps sends `X-CacheKit-Fresh-For` on every `PUT` `2xx` for a bounded entry: the + stored entry's remaining freshness ([API-80](spec/saas-api.md#api-80)). No write is clamped until + the store sends it and every tier that forwards `PUT`s relays it and bounds its copy by it + ([API-81](spec/saas-api.md#api-81)), so a `PUT` `2xx` without it means the stated TTL was + applied. "Signal-capable" still means emitting the header on `GET`. An SDK bounds its copy of its + own write by `min(local_ttl, effective_ttl, fresh_for)` ([API-83](spec/saas-api.md#api-83), + replacing API-35). A re-serving tier bounds its copy of a forwarded write by the same header + ([API-85](spec/saas-api.md#api-85), [API-86](spec/saas-api.md#api-86) and + [API-87](spec/saas-api.md#api-87), replacing API-20, API-21 and API-27) and relays it unraised + ([API-82](spec/saas-api.md#api-82)). +- The `X-CacheKit-Fresh-For` grammar keeps its 2,592,000 maximum: a server or tier reports an + entry with more than 30 days of freshness left as `2592000`. +- [`CONTEXT.md`](CONTEXT.md): **No-expiry entry** is defined by its stored state, and **Plan + maximum TTL** and **TTL range** are added. - **Breaking for callers on a plan with a maximum TTL:** once the server applies this, a write without a TTL is no longer stored with no expiry, and a TTL above the plan's maximum is shortened to it. -- **Breaking for SDKs:** one that refuses a TTL over 2,592,000, or a `ttl + stale_ttl` over it, - refuses writes the server accepts. One that ignores `X-CacheKit-Fresh-For` on the `PUT` response - keeps its copy of a clamped write past the stored entry's `fresh_until`. The +- **Breaking for SDKs, in order:** the server takes the 90-day range first; until it does, a TTL + over 2,592,000 is still a `400`, so an SDK must not widen its own TTL check before that. After it, + an SDK that refuses a TTL over 2,592,000, or a `ttl + stale_ttl` over it, refuses writes the + server accepts. An SDK that ignores `X-CacheKit-Fresh-For` on the `PUT` response keeps its copy + of a clamped write past the stored entry's `fresh_until`. The [feature matrix](sdk-feature-matrix.md) records each SDK's state. diff --git a/conformance/coverage.md b/conformance/coverage.md index 4e9c6a6..71f29e5 100644 --- a/conformance/coverage.md +++ b/conformance/coverage.md @@ -217,14 +217,11 @@ Marked not-a-requirement in `spec/intent-presets.md`: | [API-17](../spec/saas-api.md#api-17) | *Encoding rules*: Clients **MUST NOT** double-encode. A literal `%` in a key is sent as `%25` once; `%2525` decodes to `%25`, a different key. | `path-encoding.json`: `100%`, `ns:key`, `%2e%2e`, `User%3AKey`
`tools/path-encoding-verify.py:verify`
**Gap:** The rows pin one call of the encoder: a client that hands the encoded segment to a URL builder that escapes '%' again, turning ns%3Akey into ns%253Akey, passes them unless its test asserts on the parsed request path (API-15). | partial (gap) | partial (gap) | partial (gap) | | [API-18](../spec/saas-api.md#api-18) | *GET /v1/cache/{key}*: … on every `GET` `200 OK` by servers implementing stale-while-revalidate. For `GET` responses, SDKs **MUST** treat an absent header as `fresh` (pre-SWR servers do not emit it) and an unrecognized value as `stale` (revalidation … | `freshness-headers.json`: `freshness_absent`, `freshness_fresh`, `freshness_stale`, `freshness_capitalised`, `freshness_uppercase_stale`, `freshness_empty`, `freshness_nbsp_suffix`, `freshness_list_fresh_first`, `freshness_list_fresh_last`
`tools/freshness-headers-verify.py:verify`
**Gap:** The rows reach the parser an SDK's test hands each value, not its read path: whether it applies the result to the GET 200 it received where it backfills a local cache (API-58), revalidates or treats a stale hit as a miss, 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-19](../spec/saas-api.md#api-19) | *Remaining Freshness*: … fresh service only). Because absence carries that meaning, a tier that implements this section **MUST NOT** produce it for a copy it cannot positively confirm has no expiry — the tier rule below. | Binds the server.
**Gap:** No public vector exercises a re-serving tier, so nothing here catches a tier that passes a pre-signal store's missing X-CacheKit-Fresh-For through, or omits it for a copy it populated without a hint. | n/a | n/a | n/a | -| [API-20](../spec/saas-api.md#api-20) | *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-21](../spec/saas-api.md#api-21) | *Remaining Freshness*: … the header's maximum (API-32), as the store's is: a copy with more left reports `2592000`. It **MUST NOT** emit a value larger than that decayed remainder — its own copy TTL included — under any label. A tier that forwards a … | Binds the server.
**Gap:** As API-19, for a tier that emits its own copy TTL when that is larger than the decayed remainder. | n/a | n/a | n/a | | [API-22](../spec/saas-api.md#api-22) | *Remaining Freshness*: … (a write without a TTL on a plan with a maximum TTL is bounded, and its `2xx` says so). A tier **MUST** determine a write's TTL with the store's own precedence (`X-CacheKit-TTL`, else `X-TTL`); the absence of … | Binds the server.
**Gap:** As API-19, for a tier that reads only X-CacheKit-TTL and so records a write that carried legacy X-TTL as unbounded. | n/a | n/a | n/a | | [API-23](../spec/saas-api.md#api-23) | *Remaining Freshness*: … or a store below that is pre-signal and therefore omits for bounded entries too — the tier **MUST** emit `X-CacheKit-Fresh-For: 0`. It MUST NOT omit a header it received, nor drop it for such a copy: omission would … | Binds the server.
**Gap:** As API-19, for a tier that omits the header, rather than sending 0, for a copy whose remainder it does not know. | n/a | n/a | n/a | | [API-24](../spec/saas-api.md#api-24) | *Remaining Freshness*: … and therefore omits for bounded entries too — the tier MUST emit `X-CacheKit-Fresh-For: 0`. It **MUST NOT** omit a header it received, nor drop it for such a copy: omission would claim "no bound" for an entry that may have one … | Binds the server.
**Gap:** As API-19, for a tier that strips a received X-CacheKit-Fresh-For, or drops it when it re-serves the copy. | n/a | n/a | n/a | | [API-25](../spec/saas-api.md#api-25) | *Remaining Freshness*: … that may have one and silently restore the unbounded backfill this header exists to kill. A tier **MUST** therefore record at populate whether a copy is unbounded or merely unhinted; a tier that cannot tell the two apart … | Binds the server.
**Gap:** As API-19, for a tier that keeps no record at populate of whether its copy was unbounded or merely unhinted. | n/a | n/a | n/a | | [API-26](../spec/saas-api.md#api-26) | *Remaining Freshness*: … at populate whether a copy is unbounded or merely unhinted; a tier that cannot tell the two apart **MUST** emit `0`. A tier fronting a pre-signal store is in exactly that position — it emits `0`, it does not pass the absence … | Binds the server.
**Gap:** As API-19, for a tier that cannot tell an unbounded copy from an unhinted one and omits the header for both. | n/a | n/a | n/a | -| [API-27](../spec/saas-api.md#api-27) | *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. | n/a | n/a | n/a | | [API-28](../spec/saas-api.md#api-28) | *Remaining Freshness*: … on `HEAD` remains informational, per Stale-While-Revalidate). Correspondingly, a `HEAD` response **MUST NOT** create, refresh, or extend any local entry's service bound. | **Gap:** No vector reaches an SDK's local cache, so nothing here catches one that creates a local entry, or extends its deadline, on a HEAD 200. | gap | gap | gap | | [API-29](../spec/saas-api.md#api-29) | *Remaining Freshness*: … `X-CacheKit-Freshness: fresh` (or unlabelled) with the header present, a local cache (L1) backfill **MUST** bound the entry's local lifetime to at most the header value: `min(local_ttl, fresh_for)`. A value of `0` means the … | **Gap:** No vector reaches an SDK's local cache, so nothing here catches one that backfills with its configured local TTL whatever X-CacheKit-Fresh-For says. | gap | gap | gap | | [API-30](../spec/saas-api.md#api-30) | *Remaining Freshness*: … lifetime to at most the header value: `min(local_ttl, fresh_for)`. A value of `0` means the entry **MUST NOT** be backfilled at all. A `stale` or unrecognized freshness label forbids backfill regardless of the number (Reading a … | `freshness-headers.json`: `fresh_for_zero`
`tools/freshness-headers-verify.py:verify`
**Gap:** The row reaches the parser an SDK's test hands the value: a parser that reads 0 as no value fails it, but no vector reaches the local cache, so nothing here catches an SDK that parses 0 and backfills anyway (API-29). | partial (gap) | partial (gap) | partial (gap) | @@ -271,14 +268,17 @@ Marked not-a-requirement in `spec/intent-presets.md`: | [API-74](../spec/saas-api.md#api-74) | *SSRF Protection*: All SDKs **MUST** enforce SSRF protection when accepting the API URL. The following rules apply: | **Gap:** No vector holds an API URL, so nothing here checks HTTPS-only, the listed private ranges or the hostname allowlist. | gap | gap | gap | | [API-75](../spec/saas-api.md#api-75) | *Counting outcomes*: Plain read. A value read **MUST** count one outcome: an L1 hit when the in-memory layer served it, an L2 hit when a backend read did, or a miss when … | **Gap:** No vector covers request headers, so nothing here catches a client that counts a plain read twice, or counts a read that failed at the backend; only the SDK's own counter tests do. | gap | gap | gap | | [API-76](../spec/saas-api.md#api-76) | *Counting outcomes*: … caching wrapper (the cachekit-py decorators, cachekit-ts `wrap()`, cachekit-rs `#[cachekit]`) **MUST** count one outcome, however many reads it makes. A call that runs the wrapped function counts one miss, before its … | **Gap:** No vector covers request headers, so nothing here catches a client that counts a wrapped call that runs the function as anything but one miss, a joined in-process caller as anything but one L1 hit, or a miss on each single-flight re-poll or lock re-check; only the SDK's own counter tests do. | gap | gap | gap | -| [API-77](../spec/saas-api.md#api-77) | *Plan maximum TTL*: … so a client can pre-validate every write. Then, on a plan with a maximum TTL `max_ttl`, the server **MUST** store the write with | Binds the server.
**Gap:** No public vector exercises the server, so nothing here catches one that rejects a TTL over the tenant's plan maximum, stores it unclamped, or keeps a stale window that carries the entry past the maximum. | n/a | n/a | n/a | -| [API-78](../spec/saas-api.md#api-78) | *Plan maximum TTL*: … so a write whose TTL reaches the maximum stores no stale window. A `PATCH /v1/cache/{key}/ttl` **MUST** be clamped the same way, with its `ttl` in place of `effective_ttl` and the entry's stored stale window in place of … | Binds the server.
**Gap:** As API-77, for a PATCH /ttl handler that stores the requested ttl past the plan maximum, or shortens the fresh window to make room for the stored stale window. | n/a | n/a | n/a | +| [API-77](../spec/saas-api.md#api-77) | *Plan maximum TTL*: … Then, on a plan with a maximum TTL `max_ttl` that the server has determined for the write, it **MUST** store the write with | Binds the server.
**Gap:** No public vector exercises the server, so nothing here catches one that rejects a TTL over the tenant's plan maximum, stores it unclamped, or keeps a stale window that carries the entry past the maximum. | n/a | n/a | n/a | +| [API-78](../spec/saas-api.md#api-78) | *Plan maximum TTL*: … whose TTL reaches the maximum stores no stale window. A `PATCH /v1/cache/{key}/ttl` on such a plan **MUST** be clamped the same way, with its `ttl` in place of `effective_ttl` and the entry's stored stale window in place of … | Binds the server.
**Gap:** As API-77, for a PATCH /ttl handler that stores the requested ttl past the plan maximum, or shortens the fresh window to make room for the stored stale window. | n/a | n/a | n/a | | [API-79](../spec/saas-api.md#api-79) | *PUT /v1/cache/{key}*: … a plan without one — see No-expiry contract below. The plan maximum is the only default: servers **MUST NOT** apply any other, because the only default a writer cannot see must be one this spec publishes and the `2xx` reports … | Binds the server.
**Gap:** No public vector exercises the server, so nothing here catches one that stores a write without TTL headers under any default other than the tenant's plan maximum TTL. | n/a | n/a | n/a | -| [API-80](../spec/saas-api.md#api-80) | *Remaining Freshness*: Emitted on a write, too. A signal-capable server **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 store that answers a bounded PUT without X-CacheKit-Fresh-For, or reports the stated TTL instead of the one it applied. | n/a | n/a | n/a | -| [API-81](../spec/saas-api.md#api-81) | *Remaining Freshness*: … TTL clamped the write (the writer's own copy below). A server that applies a plan maximum TTL **MUST** be signal-capable, so a `PUT` `2xx` without the header means the store applied exactly the write's effective TTL, or … | Binds the server.
**Gap:** As API-80, for a deployment that clamps writes to a plan maximum TTL before its store emits X-CacheKit-Fresh-For on PUT. | n/a | n/a | n/a | +| [API-80](../spec/saas-api.md#api-80) | *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-81](../spec/saas-api.md#api-81) | *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` and every tier that forwards `PUT`s to that store … | Binds the server.
**Gap:** As API-80, for a deployment that clamps writes before its store emits X-CacheKit-Fresh-For on PUT, or while a tier that forwards PUTs drops the header or bounds its copy by the stated TTL. | n/a | n/a | n/a | | [API-82](../spec/saas-api.md#api-82) | *Remaining Freshness*: … that decayed remainder — its own copy TTL included — under any label. A tier that forwards a `PUT` **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 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-83](../spec/saas-api.md#api-83) | *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-84](../spec/saas-api.md#api-84) | *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-85](../spec/saas-api.md#api-85) | *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-86](../spec/saas-api.md#api-86) | *Remaining Freshness*: … than one second after it. A tier caps what it emits at `2592000` (API-32), as the store does. It **MUST NOT** emit a value larger than that decayed remainder — its own copy TTL included — under any label. A tier that forwards a … | 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-87](../spec/saas-api.md#api-87) | *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 emits 0 for a copy of an accepted write whose PUT 2xx reported a positive bound. | n/a | n/a | n/a | Marked not-a-requirement in `spec/saas-api.md`: diff --git a/conformance/requirements.json b/conformance/requirements.json index 5cc1d31..3e5e1e2 100644 --- a/conformance/requirements.json +++ b/conformance/requirements.json @@ -1521,7 +1521,7 @@ }, "spec/saas-api.md": { "prefix": "API", - "next": 85, + "next": 88, "requirements": { "API-1": { "section": "Authentication", @@ -1763,16 +1763,6 @@ "binds": "server", "gap": "No public vector exercises a re-serving tier, so nothing here catches a tier that passes a pre-signal store's missing X-CacheKit-Fresh-For through, or omits it for a copy it populated without a hint." }, - "API-20": { - "section": "Remaining Freshness", - "binds": "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." - }, - "API-21": { - "section": "Remaining Freshness", - "binds": "server", - "gap": "As API-19, for a tier that emits its own copy TTL when that is larger than the decayed remainder." - }, "API-22": { "section": "Remaining Freshness", "binds": "server", @@ -1798,11 +1788,6 @@ "binds": "server", "gap": "As API-19, for a tier that cannot tell an unbounded copy from an unhinted one and omits the header for both." }, - "API-27": { - "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." - }, "API-28": { "section": "Remaining Freshness", "binds": "sdk", @@ -2114,12 +2099,12 @@ "API-80": { "section": "Remaining Freshness", "binds": "server", - "gap": "No public vector exercises the server's responses, so nothing here catches a store that answers a bounded PUT without X-CacheKit-Fresh-For, or reports the stated TTL instead of the one it applied." + "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." }, "API-81": { "section": "Remaining Freshness", "binds": "server", - "gap": "As API-80, for a deployment that clamps writes to a plan maximum TTL before its store emits X-CacheKit-Fresh-For on PUT." + "gap": "As API-80, for a deployment that clamps writes before its store emits X-CacheKit-Fresh-For on PUT, or while a tier that forwards PUTs drops the header or bounds its copy by the stated TTL." }, "API-82": { "section": "Remaining Freshness", @@ -2135,12 +2120,30 @@ "section": "PATCH /v1/cache/{key}/ttl", "binds": "sdk", "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." + }, + "API-85": { + "section": "Remaining Freshness", + "binds": "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." + }, + "API-86": { + "section": "Remaining Freshness", + "binds": "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." + }, + "API-87": { + "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 emits 0 for a copy of an accepted write whose PUT 2xx reported a positive bound." } }, "retired": { + "API-20": "Replaced by API-85: a tier's copy of a forwarded write takes its bound from the X-CacheKit-Fresh-For on the store's PUT 2xx when that carries one, and a malformed value is 0.", + "API-21": "Replaced by API-86: the decayed remainder it bounds now comes from API-85's bound source, capped at 2,592,000.", + "API-27": "Replaced by API-87: an accepted write qualifies for a positive value by its bound, which the store's PUT 2xx can set, not by its stated TTL.", "API-35": "Replaced by API-83: the writer's copy is also bounded by the X-CacheKit-Fresh-For on the PUT 2xx.", - "API-40": "Merged with API-41 into API-79: a write without a TTL is stored with the plan maximum TTL on a plan that has one.", - "API-41": "Merged with API-40 into API-79: a write without a TTL is stored with the plan maximum TTL on a plan that has one." + "API-40": "Merged with API-41 into API-79: the plan maximum TTL is the only default.", + "API-41": "Merged with API-40 into API-79: the plan maximum TTL is the only default." } }, "spec/wire-format.md": { diff --git a/sdk-feature-matrix.md b/sdk-feature-matrix.md index c3d26e3..97e61bd 100644 --- a/sdk-feature-matrix.md +++ b/sdk-feature-matrix.md @@ -299,7 +299,7 @@ its spec: | Temporal sentinels ([spec](spec/wire-format.md#datetime-extension-format)) | ✅ PyPI 0.22.0 (wheel run 2026-10-07): the default path revives `__datetime__`, `__date__` and `__time__` (each `temporal_sentinel_vectors` payload under `StandardSerializer`), as do `AutoSerializer` and interop mode, and all three write them; the three `wire-format.json` 1.4.0 `temporal_sentinel_vectors` run in CI since 0.23.0 ([cachekit-py#550](https://github.com/cachekit-io/cachekit-py/pull/550)) | ❌ crates.io 0.10.0 (`.crate` inspected 2026-10-07): revives none and writes none, in either mode; interop's value profile refuses a datetime; `main` at `b5fb2a6` likewise (no sentinel reviver in `src/`). cachekit-rs vendors no `wire-format.json` | ❌ npm 0.2.1 (`.tgz` inspected 2026-10-24; 0.1.5, inspected 2026-10-07, behaves the same): the default path revives none, and writes a `Date` as its ISO string (`dist/serialization/serializer.js`); interop mode revives only `__datetime__`, leaving `__date__` and `__time__` as maps, since JavaScript has no date-only or time-only type. Since 0.2.1 the `wire-format.json` 1.4.0 `temporal_sentinel_vectors` run in both lanes as expected failures naming [WIRE-20](spec/wire-format.md#wire-20), each pinning the map the read returns ([cachekit-ts#229](https://github.com/cachekit-io/cachekit-ts/pull/229)) | ⚠️ Untested | | Encryption (AES-256-GCM) | ✅ Compliant | ✅ Canonical (cachekit-core) | ✅ Compliant | ⚠️ Untested | | AAD v0x03 | ✅ Compliant 0.23.0+ (5 components — every auto serializer appends `original_type`; interop mode is the sole 4-component path; wheels tested 2026-10-19). Earlier releases fall short: through 0.22.0 the `cache_key` component omits a prefixing backend's key prefix, short of the [logical backend key](spec/encryption.md#additional-authenticated-data-aad) ([cachekit-py#518](https://github.com/cachekit-io/cachekit-py/pull/518); see the Encryption table); and, against [ENC-2](spec/encryption.md#enc-2)/[ENC-3](spec/encryption.md#enc-3), 0.9.0 through 0.22.0 accept bare Arrow IPC on an encrypted Arrow read ([cachekit-py#517](https://github.com/cachekit-io/cachekit-py/pull/517)), 0.9.0 through 0.22.0 sniff the decrypted plaintext on a config-drift read by an `auto` handler with encryption off of an entry stored under the `auto` serializer name ([cachekit-py#521](https://github.com/cachekit-io/cachekit-py/pull/521)), 0.1.0 through 0.22.0 sniff it in a directly built `EncryptionWrapper(serializer=AutoSerializer())` ([cachekit-py#521](https://github.com/cachekit-io/cachekit-py/pull/521)), and 0.1.0 through 0.4.0 also sniff it with the default `EncryptionWrapper` constructor and on every encrypted read by a cache handler built with `encryption=True`, not from 0.6.1 (see the Encryption section) | ✅ Compliant (4 components) | ✅ Compliant (4 components) | ❌ Not implemented | -| SaaS API | ⚠️ Compliant (CachekitIO backend) except, through `cachekit` 0.22.0 (wheel inspected 2026-10-05): a PUT with a TTL also sends the legacy `X-TTL` ([API-44](spec/saas-api.md#api-44)); any fractional `ttl`, the window `@cache.io` derives from it, and a fractional `refresh_ttl` value go on the wire unrounded, so the server answers `400` and the write is lost ([API-42](spec/saas-api.md#api-42), [API-54](spec/saas-api.md#api-54)); both fixed in 0.23.0 ([cachekit-py#520](https://github.com/cachekit-io/cachekit-py/pull/520)). Where the [TTL rules](spec/saas-api.md#put-v1cachekey) have the SDK reject a TTL with an error to the caller, `ttl=0` and a TTL over 7,776,000 are sent instead, so the server answers `400` and the write is lost. Through 0.25.1 (wheel inspected 2026-10-31) a decorator refuses a `ttl + stale_ttl` over 2,592,000 with `ConfigurationError`, though the [stale-window range](spec/saas-api.md#validation) now runs to 7,776,000 (`_max_total_ttl` in `decorators/wrapper.py`) | ⚠️ Compliant (CachekitIO backend) except, through `cachekit-rs` 0.10.0 (`.crate` inspected 2026-10-05): `refresh_ttl` on the CachekitIO and Workers backends rejects a sub-second TTL instead of sending 1 ([API-42](spec/saas-api.md#api-42); `PATCH /ttl` takes the `X-CacheKit-TTL` rules), fixed in 0.11.0 ([cachekit-rs#142](https://github.com/cachekit-io/cachekit-rs/pull/142)); and a TTL over 7,776,000 is sent, where the [TTL rules](spec/saas-api.md#put-v1cachekey) have the SDK reject it, so the server answers `400` and the write is lost (`validate_ttl` rejects only zero) | ⚠️ Compliant (CachekitIO backend) from npm 0.2.1 on the [TTL rules](spec/saas-api.md#put-v1cachekey), [API-44](spec/saas-api.md#api-44) and [API-65](spec/saas-api.md#api-65) (`.tgz` inspected 2026-10-24): a PUT sends only `X-CacheKit-TTL`, ceiled to whole seconds, and refuses a zero, negative, non-finite or over-2,592,000 TTL with `ConfigurationError` before sending. That last bound predates the 90-day TTL range, so through 0.2.3 (`.tgz` inspected 2026-10-31) it refuses a TTL from 2,592,001 to 7,776,000 that the server accepts (`MAX_TTL_SECONDS` in `dist/backends/cachekitio.js`) ([cachekit-ts#110](https://github.com/cachekit-io/cachekit-ts/pull/110); `validateTtl` in `dist/backends/cachekitio.js`); 🚧 unreleased, on `main` for npm 0.3.0 ([cachekit-ts#274](https://github.com/cachekit-io/cachekit-ts/pull/274)): a `wrap()` with `staleTtl` also sends `X-CacheKit-Stale-TTL`, ceiled to whole seconds and checked against [API-54](spec/saas-api.md#api-54) and a 30-day cap before sending (`validateStaleTtl`), where the [stale-window range](spec/saas-api.md#validation) now runs to 7,776,000, and every write of that key sends it again ([API-55](spec/saas-api.md#api-55)); a lock response is a lease only when its `lock_id` is a non-empty string ([API-65](spec/saas-api.md#api-65); [cachekit-ts#215](https://github.com/cachekit-io/cachekit-ts/pull/215); `dist/backends/cachekitio-lockable.js`); `src/backends/cachekitio-ttl.test.ts` and `src/backends/cachekitio-lockable.test.ts` pass at tag `cachekit-v0.2.1`. ⚠️ Through 0.1.5 (`.tgz` inspected 2026-10-05) a PUT sends only the legacy `X-TTL` ([API-44](spec/saas-api.md#api-44)), unrounded, omits it for `ttl` 0, so the entry is stored with no expiry, or with the [plan maximum TTL](spec/saas-api.md#plan-maximum-ttl) on a plan with one, and sends a TTL over 7,776,000 for the server to reject with `400`; and a `200` lock response whose `lock_id` is absent, empty or not a string counts as a lease, so single-flight is off and the release sends `undefined` as the lock id | ❌ Not implemented | +| SaaS API | ⚠️ Compliant (CachekitIO backend) except, through `cachekit` 0.22.0 (wheel inspected 2026-10-05): a PUT with a TTL also sends the legacy `X-TTL` ([API-44](spec/saas-api.md#api-44)); any fractional `ttl`, the window `@cache.io` derives from it, and a fractional `refresh_ttl` value go on the wire unrounded, so the server answers `400` and the write is lost ([API-42](spec/saas-api.md#api-42), [API-54](spec/saas-api.md#api-54)); both fixed in 0.23.0 ([cachekit-py#520](https://github.com/cachekit-io/cachekit-py/pull/520)). Where the [TTL rules](spec/saas-api.md#put-v1cachekey) have the SDK reject a TTL with an error to the caller, `ttl=0` and a TTL over 7,776,000 are sent instead, so the server answers `400` and the write is lost. Through 0.25.1 (wheel inspected 2026-10-31) a decorator refuses a `ttl + stale_ttl` over 2,592,000 with `ConfigurationError`, though the [stale-window range](spec/saas-api.md#validation) now runs to 7,776,000 (`_max_total_ttl` in `decorators/wrapper.py`) | ⚠️ Compliant (CachekitIO backend) except, through `cachekit-rs` 0.10.0 (`.crate` inspected 2026-10-05): `refresh_ttl` on the CachekitIO and Workers backends rejects a sub-second TTL instead of sending 1 ([API-42](spec/saas-api.md#api-42); `PATCH /ttl` takes the `X-CacheKit-TTL` rules), fixed in 0.11.0 ([cachekit-rs#142](https://github.com/cachekit-io/cachekit-rs/pull/142)); and a TTL over 7,776,000 is sent, where the [TTL rules](spec/saas-api.md#put-v1cachekey) have the SDK reject it, so the server answers `400` and the write is lost (`validate_ttl` rejects only zero) | ⚠️ Compliant (CachekitIO backend) from npm 0.2.1 on the [TTL rules](spec/saas-api.md#put-v1cachekey), [API-44](spec/saas-api.md#api-44) and [API-65](spec/saas-api.md#api-65) (`.tgz` inspected 2026-10-24): a PUT sends only `X-CacheKit-TTL`, ceiled to whole seconds, and refuses a zero, negative, non-finite or over-2,592,000 TTL with `ConfigurationError` before sending. That last bound predates the 90-day TTL range, so through 0.2.3 (`.tgz` inspected 2026-10-31) it refuses a TTL from 2,592,001 to 7,776,000 that the spec's TTL range allows (the server takes them once it implements the 90-day range) (`MAX_TTL_SECONDS` in `dist/backends/cachekitio.js`) ([cachekit-ts#110](https://github.com/cachekit-io/cachekit-ts/pull/110); `validateTtl` in `dist/backends/cachekitio.js`); 🚧 unreleased, on `main` for npm 0.3.0 ([cachekit-ts#274](https://github.com/cachekit-io/cachekit-ts/pull/274)): a `wrap()` with `staleTtl` also sends `X-CacheKit-Stale-TTL`, ceiled to whole seconds and checked against [API-54](spec/saas-api.md#api-54) and a 30-day cap before sending (`validateStaleTtl`), where the [stale-window range](spec/saas-api.md#validation) now runs to 7,776,000, and every write of that key sends it again ([API-55](spec/saas-api.md#api-55)); a lock response is a lease only when its `lock_id` is a non-empty string ([API-65](spec/saas-api.md#api-65); [cachekit-ts#215](https://github.com/cachekit-io/cachekit-ts/pull/215); `dist/backends/cachekitio-lockable.js`); `src/backends/cachekitio-ttl.test.ts` and `src/backends/cachekitio-lockable.test.ts` pass at tag `cachekit-v0.2.1`. ⚠️ Through 0.1.5 (`.tgz` inspected 2026-10-05) a PUT sends only the legacy `X-TTL` ([API-44](spec/saas-api.md#api-44)), unrounded, omits it for `ttl` 0, so the entry is stored with no expiry, or with the [plan maximum TTL](spec/saas-api.md#plan-maximum-ttl) on a plan with one, and sends a TTL over 7,776,000 for the server to reject with `400`; and a `200` lock response whose `lock_id` is absent, empty or not a string counts as a lease, so single-flight is off and the release sends `undefined` as the lock id | ❌ Not implemented | | SaaS API — cache-key path encoding ([spec](spec/saas-api.md#cache-key-path-encoding)) | ✅ Compliant — rejects the empty key since 0.21.0 ([cachekit-py#397](https://github.com/cachekit-io/cachekit-py/pull/397)); `.`, `..`, `health`, `ttl` and `lock` since 0.20.0 ([cachekit-py#364](https://github.com/cachekit-io/cachekit-py/pull/364)); rules 1/3/4 since 0.18.0 ([cachekit-py#279](https://github.com/cachekit-io/cachekit-py/pull/279)) | ✅ Compliant — rejects the empty key since `cachekit-rs` 0.9.0 ([cachekit-rs#100](https://github.com/cachekit-io/cachekit-rs/pull/100); `.crate` inspected 2026-10-10); rejects `.`, `..`, `health`, `ttl` and `lock` since `cachekit-rs` 0.8.0 ([cachekit-rs#76](https://github.com/cachekit-io/cachekit-rs/pull/76); `.crate` inspected 2026-09-30) | ✅ Compliant — `@cachekit-io/cachekit` 0.2.1+ ([cachekit-ts#118](https://github.com/cachekit-io/cachekit-ts/pull/118); `encodeKey` in `dist/backends/cachekitio.js`, `.tgz` inspected 2026-10-24; `test/protocol/path-encoding.protocol.test.ts` passes at tag `cachekit-v0.2.1`); absent from 0.1.5 | ❌ Not implemented | | Test vectors in CI¹⁶ | ✅ interop/v1 (full set, incl. AAD + encryption through the real stack) — fixture 1.1.0 (`ns`/`nsapi` namespace reservation) since PyPI 0.20.0 ([cachekit-py#350](https://github.com/cachekit-io/cachekit-py/pull/350)); fixture 1.2.0 (no `..` in a segment) since 0.21.0 ([cachekit-py#391](https://github.com/cachekit-io/cachekit-py/pull/391)); fixture 1.3.0 vendored byte for byte (sha256-pinned) + CI-executed since 0.23.0 ([cachekit-py#537](https://github.com/cachekit-io/cachekit-py/pull/537)), its key, value and error vectors and both reader groups through the interop key derivation and value reader; `decode-bounds.json` vendored + CI-executed since [cachekit-py#276](https://github.com/cachekit-io/cachekit-py/pull/276) (LAB-2503); `1.1.0` since 0.20.0 ([cachekit-py#363](https://github.com/cachekit-io/cachekit-py/pull/363)); `1.2.0` (the `incomplete` reason) vendored byte for byte (sha256-pinned) + CI-executed since 0.23.0 ([cachekit-py#537](https://github.com/cachekit-io/cachekit-py/pull/537)), both `incomplete` vectors included; every version asserting the structural guard's own error for every reject vector at every payload read path (the [Decode bounds](spec/interop-mode.md#decode-bounds) MUST); at the envelope entry point (`ByteStorage.retrieve`), every `1.2.0` reject vector asserts cachekit-core 0.7.0's pre-scan error, with the reason for each depth-only and overclaim-only vector, and every accept vector gets past the pre-scan, in [cachekit-py#627](https://github.com/cachekit-io/cachekit-py/pull/627) (unreleased; see the Wire format row); `path-encoding.json` vendored + CI-executed since 0.20.0 ([cachekit-py#364](https://github.com/cachekit-io/cachekit-py/pull/364)); fixture 1.1.0 (empty-key row) since 0.21.0 ([cachekit-py#397](https://github.com/cachekit-io/cachekit-py/pull/397)); `path-encoding.json` 1.2.0 and `freshness-headers.json` 1.0.0 vendored byte for byte (sha256-pinned) + CI-executed since 0.23.0 ([cachekit-py#522](https://github.com/cachekit-io/cachekit-py/pull/522)); `cache-keys.json` 1.1.0 ([cachekit-py#460](https://github.com/cachekit-io/cachekit-py/pull/460)), `file-backend.json` 1.1.0 ([cachekit-py#431](https://github.com/cachekit-io/cachekit-py/pull/431)) and `wire-format.json` 1.3.0 ([cachekit-py#448](https://github.com/cachekit-io/cachekit-py/pull/448); its reject vectors: see the Wire format row) vendored byte for byte (sha256-pinned) + CI-executed, each since 0.21.0; `wire-format.json` 1.4.0 (every new accept, constructed, reject, payload-reject and temporal vector), `file-backend.json` 1.2.0 (every `miss_preserve` vector on all five read paths, the expired flagged entries included, and every `return_payload` vector) and `python-frame.json` (first vendor: every frame vector through `SerializationWrapper.unwrap`, every error vector at the check its `rejected_by` names, the CK frame through the interop reader, and every `encrypted_read_vectors` frame failing closed under both tamper policies with the tenant resolved by the reader) vendored byte for byte (sha256-pinned) + CI-executed since 0.23.0 ([cachekit-py#550](https://github.com/cachekit-io/cachekit-py/pull/550)); `encryption.json` `1.5.0` (first vendor: both `master_key_input` accept rows and every reject row, every `keyring.configuration` row, every `aad_reject_vectors` row failing authentication, and the `decrypted_container` rows for its checksummed Arrow and orjson readers returning no value), `cache-keys.json` `1.3.0` (every key, error and serializer-object vector) and the `python-frame.json` revision of [protocol#180](https://github.com/cachekit-io/protocol/pull/180) (11 frame vectors: not yet the [protocol#193](https://github.com/cachekit-io/protocol/pull/193) revision, which adds `padded_header_frame`), each vendored byte for byte (sha256-pinned) + CI-executed since 0.23.0 ([cachekit-py#561](https://github.com/cachekit-io/cachekit-py/pull/561)) — ⚠️ except two `keyring.configuration` cases, `keyring_current_key_decrypt_only` and its uppercase twin, each with the current key given as `master_key=`, which run as strict expected failures naming [ENC-9](spec/encryption.md#enc-9): cachekit-py refuses the current key repeated in `CACHEKIT_PREVIOUS_MASTER_KEYS` only on the first call, not at load | ✅ interop/v1 (full set) since [#33](https://github.com/cachekit-io/cachekit-rs/pull/33) — fixture 1.1.0 since `cachekit-rs` 0.8.0 ([cachekit-rs#89](https://github.com/cachekit-io/cachekit-rs/pull/89); `.crate` inspected 2026-09-30); fixture 1.2.0 since 0.9.0 ([cachekit-rs#98](https://github.com/cachekit-io/cachekit-rs/pull/98); `.crate` inspected 2026-10-06); fixture 1.3.0 vendored byte for byte (sha256-pinned) + CI-executed in [cachekit-rs#152](https://github.com/cachekit-io/cachekit-rs/pull/152), since 0.11.0, both reader groups through `interop::deserialize` and every value vector read back through it; `decode-bounds.json` vendored + CI-executed since [cachekit-rs#73](https://github.com/cachekit-io/cachekit-rs/pull/73) (LAB-2503; default CI green on `main`); `1.1.0` since 0.8.0 ([cachekit-rs#93](https://github.com/cachekit-io/cachekit-rs/pull/93)); `1.2.0` (the `incomplete` reason) in [cachekit-rs#152](https://github.com/cachekit-io/cachekit-rs/pull/152), since 0.11.0, asserting the structural guard's own `decode bound:` error for every reject vector through both decoders and `get` / `interop_get` / `interop_get_swr` — the guard enforces depth itself since [cachekit-rs#93](https://github.com/cachekit-io/cachekit-rs/pull/93) (no envelope entry point¹⁵); `path-encoding.json` 1.1.0 vendored byte for byte (sha256-pinned) + CI-executed since 0.9.0 ([cachekit-rs#100](https://github.com/cachekit-io/cachekit-rs/pull/100); `.crate` inspected 2026-10-10), driving both the `encode_key` and the URL-builder tests; fixture 1.2.0 (encoder-mistake rows) and `freshness-headers.json` 1.0.0, vendored byte for byte (sha256-pinned) + CI-executed in [cachekit-rs#146](https://github.com/cachekit-io/cachekit-rs/pull/146), since 0.11.0, every freshness row run through the `GET` response's header reader (`freshness_from_headers`); `file-backend.json` 1.1.0 vendored byte for byte + CI-executed since 0.9.0 ([cachekit-rs#107](https://github.com/cachekit-io/cachekit-rs/pull/107); `.crate` inspected 2026-10-05), by tests in `src/backend/file.rs` behind the `file` feature, which default CI enables; `1.2.0` re-vendored byte for byte (sha256-pinned) + CI-executed in [cachekit-rs#158](https://github.com/cachekit-io/cachekit-rs/pull/158), since 0.11.0, every vector through both header read paths (`read_entry` behind `get`, `probe_header` behind `exists` and `ttl`), the expired flagged entries included, and the five vectors a version-1 writer can produce through `write_entry`; `python-frame.json` first vendored byte for byte (sha256-pinned) + CI-executed in [cachekit-rs#158](https://github.com/cachekit-io/cachekit-rs/pull/158) and re-vendored in [cachekit-rs#160](https://github.com/cachekit-io/cachekit-rs/pull/160), both since 0.11.0, its 29 CK-prefixed vectors refused by both decoders and `get` / `interop_get` / `interop_get_swr`, on a client without encryption (auto mode by the trailing bytes, interop mode by naming the frame) — ⚠️ except `bare_envelope_fed_to_frame_reader`, which decodes¹⁵; `encryption.json` first vendored byte for byte (sha256-pinned) + CI-executed at `1.3.0` in [cachekit-rs#150](https://github.com/cachekit-io/cachekit-rs/pull/150) and re-vendored at `1.5.0` in [cachekit-rs#160](https://github.com/cachekit-io/cachekit-rs/pull/160), both since 0.11.0: both master key input accept rows accepted by every hex and raw-bytes entry point and decrypted through the `secure` hex and `CACHEKIT_MASTER_KEY` paths, the `keyring.configuration` rows at load, and the `aad_reject_vectors` and `decrypted_container` rows whose AAD this reader builds, each through the reads of `get` / `interop_get` / `interop_get_swr` that can present it (`get` only for the namespaced-prefix row, the interop reads only for the two interop-reader container rows) | ✅ interop/v1 (full set, incl. its key vectors) + inline Python-generated AAD-construction and encryption (decrypt-Python-ciphertext) vectors — every "since 0.2.1" below is a PR in the `cachekit-v0.2.1` tag's history (checked 2026-10-24; the Node lane's `test/protocol` suite passes at that tag against the published `cachekit-core-ts` 0.2.0 binding); fixture 1.1.0 since npm 0.2.1 ([cachekit-ts#143](https://github.com/cachekit-io/cachekit-ts/pull/143); `'nsapi'` in `dist/serialization/interop.js` of the 0.2.1 `.tgz`, absent from 0.1.5's `validateInteropSegment`); fixture 1.2.0 since 0.2.1 ([cachekit-ts#164](https://github.com/cachekit-io/cachekit-ts/pull/164); `must not contain '..'` in `dist/serialization/interop.js`, absent from 0.1.5); fixture 1.3.0 vendored byte for byte (sha256-pinned) + CI-executed in [cachekit-ts#224](https://github.com/cachekit-io/cachekit-ts/pull/224), since 0.2.1: the key, value and error vectors and both reader groups, every value vector read back through `decodeInteropValue` as its value, with the reader fixes two of them needed (a `__proto__` map key reads as an ordinary key, and a leading U+FEFF is kept); decode bounds enforced ([#112](https://github.com/cachekit-io/cachekit-ts/pull/112)); `decode-bounds.json` vendored + CI-executed since [cachekit-ts#121](https://github.com/cachekit-io/cachekit-ts/pull/121) (LAB-2737), `1.1.0` in [cachekit-ts#152](https://github.com/cachekit-io/cachekit-ts/pull/152), since 0.2.1; `1.2.0` (the `incomplete` reason) in [cachekit-ts#224](https://github.com/cachekit-io/cachekit-ts/pull/224), since 0.2.1, both incomplete vectors through all three untrusted decode entry points (`MessagePackSerializer.decode`, `decodeInteropValue`, `deserializeEvent`), asserting the pre-scan's own error; every reject vector asserts the guard error it trips (pre-scan, or the event size cap ahead of it) — ⚠️ except, through npm 0.2.1, the envelope entry point (`unpack` in `cachekit-core-ts` and `cachekit-core-wasm`), whose bindings embed `cachekit-core` 0.6.0 and so have no guard to assert; 🚧 unreleased — on `main`, `unpack` runs every vector too, in the Node lane and inside workerd, in [cachekit-ts#245](https://github.com/cachekit-io/cachekit-ts/pull/245): each reject vector fails with `cachekit-core` 0.7.0's envelope pre-scan error (both bindings on core 0.7.0 since [cachekit-ts#240](https://github.com/cachekit-io/cachekit-ts/pull/240)), pinning the depth bound on depth-only vectors and the byte or slot budget on overclaim-only ones, and each accept vector gets past the pre-scan to core's typed decode; it stays unreleased until a published `@cachekit-io/cachekit` depends on `cachekit-core-ts` and `cachekit-core-wasm` releases that embed core 0.7.0, because npm 0.2.1 pins both at exactly 0.2.0 (see the Wire format row); for the three reject vectors over the invalidation event entry point's (`deserializeEvent`) 4096 B size cap, the test also calls that site's structural guard (`assertDecodeDepth`, at depth 3) directly and asserts its rejection, as [Decode bounds](spec/interop-mode.md#decode-bounds) requires where a size cap rejects first, in [cachekit-ts#182](https://github.com/cachekit-io/cachekit-ts/pull/182), since 0.2.1; `path-encoding.json` vendored + CI-executed since [cachekit-ts#118](https://github.com/cachekit-io/cachekit-ts/pull/118), since 0.2.1; fixture 1.1.0 (empty-key row) in [cachekit-ts#165](https://github.com/cachekit-io/cachekit-ts/pull/165), since 0.2.1; fixture 1.2.0 (encoder-mistake rows) in [cachekit-ts#217](https://github.com/cachekit-io/cachekit-ts/pull/217), since 0.2.1, every row through `encodeKey` and all eight request-path operations; `freshness-headers.json` 1.0.0 vendored byte for byte (sha256-pinned) + CI-executed in [cachekit-ts#217](https://github.com/cachekit-io/cachekit-ts/pull/217), since 0.2.1, each row through a real `Headers` and `freshnessFromHeaders`; `wire-format.json` 1.3.0 vendored byte for byte + CI-executed in the Node and Workers lanes in [cachekit-ts#184](https://github.com/cachekit-io/cachekit-ts/pull/184), since 0.2.1 (its reject vectors: see the Wire format row); `1.4.0` re-vendored byte for byte (sha256-pinned) in [cachekit-ts#229](https://github.com/cachekit-io/cachekit-ts/pull/229), since 0.2.1, every group in both lanes (reject and payload reject vectors: see the Wire format row; temporal sentinels: see their row); `file-backend.json` 1.2.0 in [cachekit-ts#229](https://github.com/cachekit-io/cachekit-ts/pull/229) (footnote ⁴); `python-frame.json` vendored byte for byte (sha256-pinned) + CI-executed in [cachekit-ts#229](https://github.com/cachekit-io/cachekit-ts/pull/229), since 0.2.1, for its container vectors ([WIRE-21](spec/wire-format.md#wire-21)): the envelope read path and the interop reader refuse `ck_frame_fed_to_interop_reader`, the envelope read path refuses `plain_msgpack_fed_to_frame_reader`, and `bare_envelope_fed_to_frame_reader`, cachekit-ts's own default container, is read by every cachekit-ts reader; the [protocol#180](https://github.com/cachekit-io/protocol/pull/180) revision re-vendored byte for byte (sha256-pinned) in [cachekit-ts#230](https://github.com/cachekit-io/cachekit-ts/pull/230), since 0.2.1, where both readers also refuse the error vectors `serializer_name_missing` and `serializer_name_empty`, whole CK v3 frames. Its frame-check and `encrypted_read_vectors` groups target cachekit-py's CK frame reader, which cachekit-ts does not have; `encryption.json` vendored + CI-executed in the Workers lane since 0.1.4 ([cachekit-ts#78](https://github.com/cachekit-io/cachekit-ts/pull/78)); `1.2.0`, with its `keyring` and `default_tenant` groups, re-vendored byte for byte (sha256-pinned) in [cachekit-ts#211](https://github.com/cachekit-io/cachekit-ts/pull/211), since 0.2.1; `1.3.0` re-vendored byte for byte (sha256-pinned) in [cachekit-ts#222](https://github.com/cachekit-io/cachekit-ts/pull/222), since 0.2.1, its accept and eleven hex reject master key input rows driven through `createCache.secure` with the key passed both as `masterKey` and through `CACHEKIT_MASTER_KEY` (cachekit-ts has no raw-key entry point, so the raw-bytes rows do not apply); `1.5.0` re-vendored byte for byte (sha256-pinned) in [cachekit-ts#230](https://github.com/cachekit-io/cachekit-ts/pull/230), since 0.2.1: both master key accept rows, `master_key_first_byte_80` included, through `createCache.secure`; each `keyring.configuration` row accepted or refused as its `verdict` says, through `encryption.previousMasterKeys`, `createCache.secure`'s `previousMasterKeys` and `CACHEKIT_PREVIOUS_MASTER_KEYS`; the five `aad_reject_vectors` rows whose AAD inputs a cachekit-ts reader builds, each failing authentication with degradation off (the other five need an `original_type` or a format no cachekit-ts reader builds); the four `decrypted_container` rows that name a cachekit-ts reader (the Arrow and orjson rows name cachekit-py's); and the raw wasm bindings decrypt `standard_serializer_default` and `standard_serializer_integrity_off` in the Workers lane, each under its own `aad_hex` (no cachekit-ts reader builds their five-component AAD) | ⚠️ Pending | | Interop mode ([spec](spec/interop-mode.md), opt-in) | ✅ Released — PyPI 0.14.0+¹⁷ ([#220](https://github.com/cachekit-io/cachekit-py/pull/220)) | ✅ Released — crates.io 0.4.0+ ([#33](https://github.com/cachekit-io/cachekit-rs/pull/33)) | ✅ Released — npm 0.1.3+ ([#71](https://github.com/cachekit-io/cachekit-ts/pull/71)) | ❌ Not implemented | diff --git a/spec/saas-api.md b/spec/saas-api.md index 7fefc8d..4cc2917 100644 --- a/spec/saas-api.md +++ b/spec/saas-api.md @@ -157,15 +157,15 @@ X-CacheKit-L1-Status: miss **Server (emission):** -- A **signal-capable server** is one that implements this section. It is independent of [stale-while-revalidate](#stale-while-revalidate) support: a bounded entry has a `fresh_until` whether or not it has a stale window, so a server may emit `X-CacheKit-Fresh-For` without offering one, and a pre-signal SWR server emits `X-CacheKit-Freshness` without it. -- Emitted on **every** `GET` `200 OK` for an entry that has a freshness bound. A **no-expiry** entry — one written without a TTL, neither `X-CacheKit-TTL` nor legacy `X-TTL`, on a plan without a [maximum TTL](#plan-maximum-ttl) ([PUT](#put-v1cachekey)) — has no `fresh_until`, no remainder to report, so the header is **omitted** — the server-side bound is unbounded, `min(local_ttl, ∞)` is the SDK's configured local TTL, and that is exactly the absent-header path below. When present, the value is a non-negative integer: `min(2592000, max(0, floor(fresh_until − now)))`, computed against the **server's clock** at response time — the client never compares server timestamps against its own clock. So an entry with more than 30 days of freshness left reports `2592000`, never a larger value: the header's cap stays below the 90-day [TTL range](#put-v1cachekey), and a local copy is bounded short of the entry's true freshness, the conservative direction. -- Emitted on a **write**, too. A signal-capable server MUSTAPI-80 emit `X-CacheKit-Fresh-For` on every `PUT` `2xx` whose stored entry has a freshness bound, computed as above from the `fresh_until` it stamped, and omits it when the write stored a no-expiry entry. That is how a writer learns the bound the store applied, which is shorter than the one it stated when a [plan maximum TTL](#plan-maximum-ttl) clamped the write ([the writer's own copy](#remaining-freshness) below). A server that applies a plan maximum TTL MUSTAPI-81 be signal-capable, so a `PUT` `2xx` without the header means the store applied exactly the write's [effective TTL](#put-v1cachekey), or stored a no-expiry entry because the write carried none. +- A **signal-capable server** is one that emits `X-CacheKit-Fresh-For` on `GET` as this section describes; the `PUT` emission below is a separate obligation. It is independent of [stale-while-revalidate](#stale-while-revalidate) support: a bounded entry has a `fresh_until` whether or not it has a stale window, so a server may emit `X-CacheKit-Fresh-For` without offering one, and a pre-signal SWR server emits `X-CacheKit-Freshness` without it. +- Emitted on **every** `GET` `200 OK` for an entry that has a freshness bound. A **no-expiry** entry ([no-expiry contract](#put-v1cachekey)) has no `fresh_until`, no remainder to report, so the header is **omitted** — the server-side bound is unbounded, `min(local_ttl, ∞)` is the SDK's configured local TTL, and that is exactly the absent-header path below. When present, the value is a non-negative integer: `min(2592000, max(0, floor(fresh_until − now)))`, computed against the **server's clock** at response time — the client never compares server timestamps against its own clock. The `2592000` cap sits below the 90-day [TTL range](#put-v1cachekey), so it only ever shortens a local copy's bound. +- Emitted on a **write**, too. A store that applies a [plan maximum TTL](#plan-maximum-ttl) MUSTAPI-80 emit `X-CacheKit-Fresh-For` on every `PUT` `2xx` whose stored entry has a freshness bound, computed as above from the `fresh_until` it stamped, and omits it for a no-expiry entry; any other store may. That is how a writer learns the bound the store applied, which is shorter than the one it stated when the plan maximum clamped the write ([the writer's own copy](#remaining-freshness) below). A server MUST NOTAPI-81 clamp any write or `PATCH` until its store emits the header on `PUT` and every tier that forwards `PUT`s to that store relays it ([API-82](#api-82)) and bounds its copy by it ([API-85](#api-85)). So a `PUT` `2xx` without the header means the store applied exactly the write's [effective TTL](#put-v1cachekey), or stored a no-expiry entry because the write carried none. - Stale-window responses (`X-CacheKit-Freshness: stale`) carry `X-CacheKit-Fresh-For: 0` — freshness is already exhausted. `X-CacheKit-Freshness: fresh` with `X-CacheKit-Fresh-For: 0` is also legal — an entry in its final sub-second of freshness floors to `0`. The response is served to the caller normally; the `0` governs only local caching (no backfill). - Omitted by the store for no-expiry entries, and by pre-signal servers for everything. The client cannot tell the two apart, by design: both mean "no server-side freshness bound applies to this read" and both lead to the same SDK action (configured local TTL, fresh service only). Because absence carries that meaning, a tier that implements this section MUST NOTAPI-19 produce it for a copy it cannot positively confirm has no expiry — the tier rule below. - **Re-serving tiers** — an edge or colo cache in front of the store that answers from a copy it read earlier — are part of the server. A tier's **coherence window** is the deployment-documented maximum time it may keep serving a copy after the store has changed or deleted the entry; a tier that refreshes its copy's lifetime from the tier below rather than from the store adds its window to the path's total, so windows **compound** along the serving path and a deployment's effective window is their sum, not its largest single tier. That governs the *bytes*. The *header* follows stricter, fail-closed rules: - - **Decay, never re-stamp.** A tier MUSTAPI-20 emit its copy's remaining bound minus the seconds elapsed since that bound was obtained, flooring at `0`. A copy has exactly one bound source: the response it was read from (the bound is that response's `X-CacheKit-Fresh-For`), or — for a copy the tier populated from a write it forwarded — that write, once the store has accepted it. The bound of a forwarded write is the `X-CacheKit-Fresh-For` on the store's `PUT` `2xx` when it carries one, read with the [consumption](#remaining-freshness) grammar below (a malformed value is `0`, never a fallback to the TTL the write stated), otherwise the write's effective TTL (see [Migration](#put-v1cachekey)), and either is measured from when the tier dispatched the write. The store stamps `fresh_until` after that dispatch, from `floor(stored_at)` ([whole-second stamping](#entry-lifecycle)), and computes the header after stamping, so a bound from the header ends no later than `fresh_until`, and one from the effective TTL ends less than one second after it. The value a tier emits is also capped at `2592000`, the header's maximum ([API-32](#api-32)), as the store's is: a copy with more left reports `2592000`. It MUST NOTAPI-21 emit a value larger than that decayed remainder — its own copy TTL included — under any label. A tier that forwards a `PUT` MUST NOTAPI-82 drop the `X-CacheKit-Fresh-For` of the store's `2xx` from the response it relays, nor relay a value larger than the store's. The deployed tiers decay, so the rule costs a conforming implementation nothing, and it keeps a pre-`DELETE` copy's local service within one second of the entry's `fresh_until`. + - **Decay, never re-stamp.** A tier MUSTAPI-85 emit its copy's remaining bound minus the seconds elapsed since that bound was obtained, flooring at `0`. A copy has exactly one bound source: the response it was read from (the bound is that response's `X-CacheKit-Fresh-For`), or — for a copy the tier populated from a write it forwarded — that write, once the store has accepted it. The bound of a forwarded write is the `X-CacheKit-Fresh-For` on the store's `PUT` `2xx` when it carries one, read with the [consumption](#remaining-freshness) grammar below (a malformed value is `0`, never a fallback to the TTL the write stated), otherwise the write's effective TTL (see [Migration](#put-v1cachekey)), and either is measured from when the tier dispatched the write. The store stamps `fresh_until` after that dispatch, from `floor(stored_at)` ([whole-second stamping](#entry-lifecycle)), and computes the header after stamping, so a bound from the header ends no later than `fresh_until`, and one from the effective TTL ends less than one second after it. A tier caps what it emits at `2592000` ([API-32](#api-32)), as the store does. It MUST NOTAPI-86 emit a value larger than that decayed remainder — its own copy TTL included — under any label. A tier that forwards a `PUT` MUST NOTAPI-82 drop the `X-CacheKit-Fresh-For` of the store's `2xx` from the response it relays, nor relay a value larger than the store's. The deployed tiers decay, so the rule costs a conforming implementation nothing, and it keeps a pre-`DELETE` copy's local service within one second of the entry's `fresh_until`. - **Omit only on positive knowledge of no expiry; otherwise `0`.** A tier MAY omit the header only for a copy it positively knows has no expiry: a signal-capable store below omitted it (such a store omits only for no-expiry entries), or the tier populated the copy from an accepted write that carried no TTL at all — neither `X-CacheKit-TTL` nor legacy `X-TTL` — and whose `2xx` carried no `X-CacheKit-Fresh-For` (a write without a TTL on a plan with a [maximum TTL](#plan-maximum-ttl) is bounded, and its `2xx` says so). A tier MUSTAPI-22 determine a write's TTL with the store's own precedence (`X-CacheKit-TTL`, else `X-TTL`); the absence of `X-CacheKit-TTL` alone proves nothing, because a legacy `X-TTL: 60` write is bounded. For every other copy whose remainder it does not know — populated without a hint, a header lost in transit, or a store below that is pre-signal and therefore omits for bounded entries too — the tier MUSTAPI-23 emit `X-CacheKit-Fresh-For: 0`. It MUST NOTAPI-24 omit a header it received, nor drop it for such a copy: omission would claim "no bound" for an entry that may have one and silently restore the unbounded backfill this header exists to kill. A tier MUSTAPI-25 therefore record at populate whether a copy is unbounded or merely unhinted; a tier that cannot tell the two apart MUSTAPI-26 emit `0`. A tier fronting a pre-signal store is in exactly that position — it emits `0`, it does not pass the absence through; the cost is no local backfill behind that tier until the store signals, the fail-closed posture a mixed deployment should have. Whether the store below signals is a deployment fact the tier is configured with, never inferred per response. - - **Positive only for `fresh` + positive.** A tier MAY emit a positive value only when its copy's bound source qualifies, and then only the decayed remainder: a response the tier below labelled exactly `X-CacheKit-Freshness: fresh` (or left unlabelled — the pre-SWR `fresh` default) and stamped with a strictly positive `X-CacheKit-Fresh-For`; or an accepted write whose bound (above) is positive, whose copy is labelled `fresh` (a just-accepted write is fresh by construction). Every other shape — `stale`, `X-CacheKit-Fresh-For: 0` under any label, an unrecognized freshness token, or a missing `X-CacheKit-Fresh-For` on any entry not positively known to be no-expiry — MUSTAPI-27 be emitted with `X-CacheKit-Fresh-For: 0` and its freshness label passed through unchanged (a `fresh` `0` stays `fresh`; a `stale` stays `stale`). Stamping a positive bound onto a stale, exhausted, or unknown value resurrects an expired (or revoked) entry as locally-cacheable fresh — the exact hole this header closes. + - **Positive only for `fresh` + positive.** A tier MAY emit a positive value only when its copy's bound source qualifies, and then only the decayed remainder: a response the tier below labelled exactly `X-CacheKit-Freshness: fresh` (or left unlabelled — the pre-SWR `fresh` default) and stamped with a strictly positive `X-CacheKit-Fresh-For`; or an accepted write whose bound (above) is positive, whose copy is labelled `fresh` (a just-accepted write is fresh by construction). Every other shape — `stale`, `X-CacheKit-Fresh-For: 0` under any label, an unrecognized freshness token, or a missing `X-CacheKit-Fresh-For` on any entry not positively known to be no-expiry — MUSTAPI-87 be emitted with `X-CacheKit-Fresh-For: 0` and its freshness label passed through unchanged (a `fresh` `0` stays `fresh`; a `stale` stays `stale`). Stamping a positive bound onto a stale, exhausted, or unknown value resurrects an expired (or revoked) entry as locally-cacheable fresh — the exact hole this header closes. - `HEAD` does **not** carry this header — an existence check returns no payload, so there is nothing to backfill locally (the `X-CacheKit-Freshness` label on `HEAD` remains informational, per [Stale-While-Revalidate](#stale-while-revalidate)). Correspondingly, a `HEAD` response MUST NOTAPI-28 create, refresh, or extend any local entry's service bound. **SDK (consumption):** @@ -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-83 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). An SDK that predates the header on `PUT` bounds its copy by `min(local_ttl, effective_ttl)` alone, so on a plan with a maximum TTL its copy can outlive `fresh_until` by up to that bound less the plan's maximum TTL, on top of the write-path tolerance below. 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. On a plan with a maximum TTL it can outlive `fresh_until` by up to `min(local_ttl, effective_ttl)` less that maximum on top of this, because without a `2xx` the SDK cannot learn the bound the store applied. 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-83 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. Add the plan-maximum excess (above): without a `2xx` the SDK cannot learn the bound the store applied. 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 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** (on a plan with a [maximum TTL](#plan-maximum-ttl), the amount by which `min(local_ttl, effective_ttl)` exceeds the maximum, for a writer's copy bounded without the `PUT` `2xx`'s header: kept without a `2xx`, or kept by an SDK that predates the header), 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 ([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. --- @@ -210,7 +210,7 @@ X-CacheKit-L1-Status: miss | Header | Required | Description | | :--- | :---: | :--- | -| `X-CacheKit-TTL` | No | Time-to-live in seconds. Positive integer, minimum 1, maximum 7,776,000 (90 days). The store applies at most the tenant's [plan maximum TTL](#plan-maximum-ttl). Omit — together with legacy `X-TTL` — to store the entry with the plan maximum TTL, or with **no expiry** on a plan without one (see the **No-expiry contract** under TTL Validation Rules). | +| `X-CacheKit-TTL` | No | Time-to-live in seconds. Positive integer, minimum 1, maximum 7,776,000 (90 days, the **TTL range**). The store applies at most the tenant's [plan maximum TTL](#plan-maximum-ttl). Omit — together with legacy `X-TTL` — to store the entry with the plan maximum TTL, or with **no expiry** on a plan without one (see the **No-expiry contract** under TTL Validation Rules). | | `X-CacheKit-Stale-TTL` | No | Stale-grace window in seconds after freshness expiry. Requires an explicit TTL (`X-CacheKit-TTL`, or legacy `X-TTL`) on the same request. Validation and semantics: [Stale-While-Revalidate](#stale-while-revalidate). Pre-SWR servers ignore this header. | > [!IMPORTANT] @@ -226,13 +226,13 @@ X-CacheKit-L1-Status: miss > | TTL negative | **Reject** — return error to caller. | **Reject** — return `400 Bad Request`. | > | TTL non-integer | N/A (SDK converts duration to integer seconds). | **Reject** — return `400 Bad Request`. | > -> **No-expiry contract.** An entry written without a TTL — neither `X-CacheKit-TTL` nor legacy `X-TTL` (see **Migration** below) — on a plan without a [maximum TTL](#plan-maximum-ttl) has no `fresh_until` and no `evict_at`. On a plan with a maximum TTL such a write is bounded instead, so no-expiry entries exist only on a plan without one, from a write whose plan the server could not determine ([Plan maximum TTL](#plan-maximum-ttl)), or from before the tenant's plan gained one: +> **No-expiry contract.** A **no-expiry entry** is one stored with no `fresh_until` and no `evict_at`. It comes only from a write without a TTL — neither `X-CacheKit-TTL` nor legacy `X-TTL` (see **Migration** below) — that no [plan maximum TTL](#plan-maximum-ttl) bounded: on a plan without one, when the server could not determine the plan, or before the tenant's plan gained one. On a plan with a maximum TTL such a write is bounded instead: > - **Reads:** permanently **fresh** — while the entry is present, `GET` and `HEAD` return `200 OK` with `X-CacheKit-Freshness: fresh`, and `GET` carries no [`X-CacheKit-Fresh-For`](#remaining-freshness). It never enters a stale window. Presence ends only on explicit `DELETE`, overwrite, or capacity eviction (below). > - **Storage bound: deployment-configured.** No-expiry entries are never age-evicted. Whether, and at what ceiling, a deployment applies capacity eviction is deployment configuration outside this protocol; no-expiry entries are eligible victims of whatever capacity eviction the deployment applies, so "no expiry" is **not** a durability guarantee, and an evicted entry gives no client-visible signal beyond a subsequent miss. A deployment that needs a specific storage ceiling must provision a tenant or namespace quota. > - **`GET /v1/cache/{key}/ttl`:** see the [endpoint](#get-v1cachekeyttl). > - **`PATCH /v1/cache/{key}/ttl`:** `200`; gives the entry its first expiry (`fresh_until = floor(now) + ttl`, [whole-second stamping](#entry-lifecycle), with `ttl` clamped to the [plan maximum TTL](#plan-maximum-ttl) on a plan with one) — the one way to bound an existing no-expiry entry without rewriting it. That `200` is indistinguishable from the [absent-key no-op](#patch-v1cachekeyttl); confirm presence separately if it matters. > -> **Rationale:** TTL=0 is ambiguous across cache systems (Redis rejects it, Memcached treats it as "never expire", HTTP treats it as "immediately stale"). CacheKit defines TTL=0 as an error to prevent silent data loss (the Redis and HTTP readings) or an unintended no-expiry entry (the Memcached reading) — no expiry is requested by omitting the header, never by `0`. Sub-second durations are ceiled to 1 rather than truncated to 0 to avoid the same ambiguity. The 90-day maximum bounds the value range of a **stated** TTL; it is not a storage-lifetime ceiling. The [plan maximum TTL](#plan-maximum-ttl) bounds each stamp on a plan that has it, and clamps rather than rejects, so a write that passes these plan-independent rules never fails on a plan the client cannot see. Entries that need a long but bounded life should renew explicitly via `PATCH /v1/cache/{key}/ttl`. +> **Rationale:** TTL=0 is ambiguous across cache systems (Redis rejects it, Memcached treats it as "never expire", HTTP treats it as "immediately stale"). CacheKit defines TTL=0 as an error to prevent silent data loss (the Redis and HTTP readings) or an unintended no-expiry entry (the Memcached reading) — no expiry is requested by omitting the header, never by `0`. Sub-second durations are ceiled to 1 rather than truncated to 0 to avoid the same ambiguity. The 90-day TTL range bounds the value of a **stated** TTL; it is not a ceiling on an entry's lifetime. The [plan maximum TTL](#plan-maximum-ttl) bounds each stamp on a plan that has it. Entries that need a long but bounded life should renew explicitly via `PATCH /v1/cache/{key}/ttl`. > > **Migration:** The `X-TTL` header is deprecated. The server MUSTAPI-43 accept both `X-CacheKit-TTL` and `X-TTL` during the transition period, preferring `X-CacheKit-TTL` when both are present. A write's **effective TTL** is therefore `X-CacheKit-TTL` when present, otherwise `X-TTL`; a write has no TTL only when it carries **neither**. The effective TTL is what the write states; the TTL the store applies is the effective TTL clamped to the plan maximum. Every rule in this spec that depends on whether a write carried a TTL reads the effective TTL: the no-expiry rule, the stale-window requirement, and the re-serving-tier rules in [Remaining Freshness](#remaining-freshness). SDKs MUSTAPI-44 send `X-CacheKit-TTL` only. The `X-TTL` header will be removed in protocol version 2.0 (targeted at SDK 1.0 milestone). @@ -248,11 +248,11 @@ X-CacheKit-L1-Status: miss | Header | Description | | :--- | :--- | -| `X-CacheKit-Fresh-For` | On a `2xx`: the freshness bound the store applied, in whole seconds, capped at 2,592,000; omitted for a no-expiry entry. Semantics: [Remaining Freshness](#remaining-freshness), including [the writer's own copy](#remaining-freshness). | +| `X-CacheKit-Fresh-For` | On a `2xx`: the stored entry's remaining freshness at response time, `min(2592000, max(0, floor(fresh_until − now)))` ([API-80](#api-80)); omitted for a no-expiry entry. Semantics: [Remaining Freshness](#remaining-freshness), including [the writer's own copy](#remaining-freshness). | #### Plan maximum TTL -Each plan has a maximum TTL: the longest a write or `PATCH` under it can set an entry's `evict_at` from its stamp, the stale window included. A later `PATCH` can renew the entry within the same maximum. +Each plan has a maximum TTL: the longest a write or `PATCH` under it can set an entry's `evict_at` from its stamp, the stale window included. | Plan | Maximum TTL | | :--- | :--- | @@ -261,14 +261,16 @@ Each plan has a maximum TTL: the longest a write or `PATCH` under it can set an | Professional | 7,776,000 seconds (90 days) | | Enterprise | None | -The maximum clamps a write; it never rejects one. The [TTL validation rules](#put-v1cachekey) and the [stale-window validation](#validation) run first and do not depend on the plan, so a client can pre-validate every write. Then, on a plan with a maximum TTL `max_ttl`, the server MUSTAPI-77 store the write with +The maximum clamps a write; it never rejects one. The [TTL validation rules](#put-v1cachekey) and the [stale-window validation](#validation) run first and do not depend on the plan, so a client can pre-validate every write. Then, on a plan with a maximum TTL `max_ttl` that the server has determined for the write, it MUSTAPI-77 store the write with ``` applied_ttl = min(effective_ttl, max_ttl), or max_ttl for a write without a TTL applied_stale_ttl = min(stale_ttl, max_ttl − applied_ttl) ``` -so that `evict_at − floor(stored_at) ≤ max_ttl`. The fresh window is kept first: the stale window gets only what the maximum leaves, so a write whose TTL reaches the maximum stores no stale window. A [`PATCH /v1/cache/{key}/ttl`](#patch-v1cachekeyttl) MUSTAPI-78 be clamped the same way, with its `ttl` in place of `effective_ttl` and the entry's stored stale window in place of `stale_ttl`, so that `evict_at − floor(now) ≤ max_ttl`. On a plan without a maximum TTL, writes and `PATCH`es are stored as stated, and a write without a TTL stores a no-expiry entry. A server that cannot determine the tenant's plan for a write or `PATCH` applies that rule too; its `2xx` reports what it stored, so every copy stays bounded by the stored entry. +so that `evict_at − floor(stored_at) ≤ max_ttl`. The fresh window is kept first: the stale window gets only what the maximum leaves, so a write whose TTL reaches the maximum stores no stale window. A [`PATCH /v1/cache/{key}/ttl`](#patch-v1cachekeyttl) on such a plan MUSTAPI-78 be clamped the same way, with its `ttl` in place of `effective_ttl` and the entry's stored stale window in place of `stale_ttl`, so that `evict_at − floor(now) ≤ max_ttl`. On a plan without a maximum TTL, writes and `PATCH`es are stored as stated, and a write without a TTL stores a no-expiry entry. + +A server that cannot determine the tenant's plan for a write or `PATCH` stores it as stated, as on a plan without a maximum. Its `2xx` reports what it stored, so every copy stays bounded by the stored entry, but the plan maximum is then not a guaranteed ceiling on that entry's lifetime. The maximum applies when a write or `PATCH` is stamped. An entry keeps the timing it was stored with when the tenant's plan changes later, a no-expiry entry included, until its next write or `PATCH`. @@ -344,7 +346,7 @@ All lifecycle times are computed against the **store's clock**; SDKs MUST NOT Date: Sat, 31 Oct 2026 22:00:55 +1100 Subject: [PATCH 5/8] docs(saas-api): one fallback for the clamp, GET-only omit evidence for tiers The clamp rules and the API-81 gate could not both be met before the gate held; one fallback paragraph now scopes the clamp. A tier may treat an absent header as no-expiry only on a GET from a signal-capable store, never on a PUT 2xx. The relay rule covers every hop that relays a PUT 2xx, the 2,592,000 cap sits inside the tier's MUST NOT, and restated text is cut. --- CONTEXT.md | 4 ++-- changelog.d/20261031_plan-maximum-ttl.md | 16 +++++++--------- conformance/coverage.md | 12 ++++++------ conformance/requirements.json | 6 +++--- spec/saas-api.md | 22 +++++++++++----------- tools/freshness-headers-verify.py | 3 ++- 6 files changed, 31 insertions(+), 32 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 2adeb4d..8c2efa9 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -129,11 +129,11 @@ The period after the freshness window during which CachekitIO still serves an en _Avoid_: grace period, stale TTL (that is the window's length) **No-expiry entry**: -An entry stored with no end to its freshness window, which never enters a stale window and stays until it is deleted, overwritten or evicted for capacity. Only a write without a TTL that no plan maximum TTL bounded stores one: on a plan without a maximum, when CachekitIO could not determine the plan, or before the plan gained a maximum. +An entry stored with no end to its freshness window, which never enters a stale window and stays until it is deleted, overwritten or evicted for capacity. Only a write without a TTL that no plan maximum TTL bounded stores one. _Avoid_: permanent entry, infinite TTL **Plan maximum TTL**: -The longest a write or TTL update under a tenant's plan can make an entry live in CachekitIO, its stale window included: 24 hours on Free, 30 days on Startup, 90 days on Professional, none on Enterprise. A longer TTL, or none, is shortened to it, never rejected. +The longest a write or TTL update under a tenant's plan can make an entry live in CachekitIO, its stale window included; each plan's figure is in the SaaS API spec. A longer TTL, or none, is shortened to it, never rejected. _Avoid_: TTL cap, max TTL (on their own) **TTL range**: diff --git a/changelog.d/20261031_plan-maximum-ttl.md b/changelog.d/20261031_plan-maximum-ttl.md index 9979ea0..4d12a01 100644 --- a/changelog.d/20261031_plan-maximum-ttl.md +++ b/changelog.d/20261031_plan-maximum-ttl.md @@ -9,26 +9,24 @@ same way ([API-78](spec/saas-api.md#api-78)), and its response must not extend a local copy ([API-84](spec/saas-api.md#api-84)). Validation stays plan-independent, so a client can still pre-validate every write. A server that cannot determine the tenant's plan stores the write as - stated, so the plan maximum is not a guaranteed ceiling on an entry's lifetime. Entries stored - before the change keep their timing until their next write or `PATCH`. + stated, and so does a deployment that does not yet meet API-81, so the plan maximum is not a + guaranteed ceiling on an entry's lifetime. Entries stored before the change keep their timing + until their next write or `PATCH`. - The TTL range of `X-CacheKit-TTL`, of `PATCH /ttl`'s `ttl` and of `ttl + stale_ttl` rises from 2,592,000 to 7,776,000 (`stale_ttl` alone to 7,775,999). A larger value is still a `400`. - The plan maximum is the only default for a write without a TTL ([API-79](spec/saas-api.md#api-79), which merges and retires API-40 and API-41). - A store that clamps sends `X-CacheKit-Fresh-For` on every `PUT` `2xx` for a bounded entry: the stored entry's remaining freshness ([API-80](spec/saas-api.md#api-80)). No write is clamped until - the store sends it and every tier that forwards `PUT`s relays it and bounds its copy by it - ([API-81](spec/saas-api.md#api-81)), so a `PUT` `2xx` without it means the stated TTL was - applied. "Signal-capable" still means emitting the header on `GET`. An SDK bounds its copy of its - own write by `min(local_ttl, effective_ttl, fresh_for)` ([API-83](spec/saas-api.md#api-83), - replacing API-35). A re-serving tier bounds its copy of a forwarded write by the same header + the store sends it, every server-side hop that relays the `PUT` `2xx` passes it through, and + every tier that keeps a copy of a forwarded write bounds that copy by it + ([API-81](spec/saas-api.md#api-81)). An SDK bounds its copy of its own write by + `min(local_ttl, effective_ttl, fresh_for)` ([API-83](spec/saas-api.md#api-83), replacing API-35). A re-serving tier bounds its copy of a forwarded write by the same header ([API-85](spec/saas-api.md#api-85), [API-86](spec/saas-api.md#api-86) and [API-87](spec/saas-api.md#api-87), replacing API-20, API-21 and API-27) and relays it unraised ([API-82](spec/saas-api.md#api-82)). - The `X-CacheKit-Fresh-For` grammar keeps its 2,592,000 maximum: a server or tier reports an entry with more than 30 days of freshness left as `2592000`. -- [`CONTEXT.md`](CONTEXT.md): **No-expiry entry** is defined by its stored state, and **Plan - maximum TTL** and **TTL range** are added. - **Breaking for callers on a plan with a maximum TTL:** once the server applies this, a write without a TTL is no longer stored with no expiry, and a TTL above the plan's maximum is shortened to it. diff --git a/conformance/coverage.md b/conformance/coverage.md index 71f29e5..ce0f6a2 100644 --- a/conformance/coverage.md +++ b/conformance/coverage.md @@ -217,7 +217,7 @@ Marked not-a-requirement in `spec/intent-presets.md`: | [API-17](../spec/saas-api.md#api-17) | *Encoding rules*: Clients **MUST NOT** double-encode. A literal `%` in a key is sent as `%25` once; `%2525` decodes to `%25`, a different key. | `path-encoding.json`: `100%`, `ns:key`, `%2e%2e`, `User%3AKey`
`tools/path-encoding-verify.py:verify`
**Gap:** The rows pin one call of the encoder: a client that hands the encoded segment to a URL builder that escapes '%' again, turning ns%3Akey into ns%253Akey, passes them unless its test asserts on the parsed request path (API-15). | partial (gap) | partial (gap) | partial (gap) | | [API-18](../spec/saas-api.md#api-18) | *GET /v1/cache/{key}*: … on every `GET` `200 OK` by servers implementing stale-while-revalidate. For `GET` responses, SDKs **MUST** treat an absent header as `fresh` (pre-SWR servers do not emit it) and an unrecognized value as `stale` (revalidation … | `freshness-headers.json`: `freshness_absent`, `freshness_fresh`, `freshness_stale`, `freshness_capitalised`, `freshness_uppercase_stale`, `freshness_empty`, `freshness_nbsp_suffix`, `freshness_list_fresh_first`, `freshness_list_fresh_last`
`tools/freshness-headers-verify.py:verify`
**Gap:** The rows reach the parser an SDK's test hands each value, not its read path: whether it applies the result to the GET 200 it received where it backfills a local cache (API-58), revalidates or treats a stale hit as a miss, 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-19](../spec/saas-api.md#api-19) | *Remaining Freshness*: … fresh service only). Because absence carries that meaning, a tier that implements this section **MUST NOT** produce it for a copy it cannot positively confirm has no expiry — the tier rule below. | Binds the server.
**Gap:** No public vector exercises a re-serving tier, so nothing here catches a tier that passes a pre-signal store's missing X-CacheKit-Fresh-For through, or omits it for a copy it populated without a hint. | n/a | n/a | n/a | -| [API-22](../spec/saas-api.md#api-22) | *Remaining Freshness*: … (a write without a TTL on a plan with a maximum TTL is bounded, and its `2xx` says so). A tier **MUST** determine a write's TTL with the store's own precedence (`X-CacheKit-TTL`, else `X-TTL`); the absence of … | Binds the server.
**Gap:** As API-19, for a tier that reads only X-CacheKit-TTL and so records a write that carried legacy X-TTL as unbounded. | n/a | n/a | n/a | +| [API-22](../spec/saas-api.md#api-22) | *Remaining Freshness*: … on its `2xx`; a `PUT` `2xx` without it never qualifies a copy on the first condition). A tier **MUST** determine a write's TTL with the store's own precedence (`X-CacheKit-TTL`, else `X-TTL`); the absence of … | Binds the server.
**Gap:** As API-19, for a tier that reads only X-CacheKit-TTL and so records a write that carried legacy X-TTL as unbounded. | n/a | n/a | n/a | | [API-23](../spec/saas-api.md#api-23) | *Remaining Freshness*: … or a store below that is pre-signal and therefore omits for bounded entries too — the tier **MUST** emit `X-CacheKit-Fresh-For: 0`. It MUST NOT omit a header it received, nor drop it for such a copy: omission would … | Binds the server.
**Gap:** As API-19, for a tier that omits the header, rather than sending 0, for a copy whose remainder it does not know. | n/a | n/a | n/a | | [API-24](../spec/saas-api.md#api-24) | *Remaining Freshness*: … and therefore omits for bounded entries too — the tier MUST emit `X-CacheKit-Fresh-For: 0`. It **MUST NOT** omit a header it received, nor drop it for such a copy: omission would claim "no bound" for an entry that may have one … | Binds the server.
**Gap:** As API-19, for a tier that strips a received X-CacheKit-Fresh-For, or drops it when it re-serves the copy. | n/a | n/a | n/a | | [API-25](../spec/saas-api.md#api-25) | *Remaining Freshness*: … that may have one and silently restore the unbounded backfill this header exists to kill. A tier **MUST** therefore record at populate whether a copy is unbounded or merely unhinted; a tier that cannot tell the two apart … | Binds the server.
**Gap:** As API-19, for a tier that keeps no record at populate of whether its copy was unbounded or merely unhinted. | n/a | n/a | n/a | @@ -268,17 +268,17 @@ Marked not-a-requirement in `spec/intent-presets.md`: | [API-74](../spec/saas-api.md#api-74) | *SSRF Protection*: All SDKs **MUST** enforce SSRF protection when accepting the API URL. The following rules apply: | **Gap:** No vector holds an API URL, so nothing here checks HTTPS-only, the listed private ranges or the hostname allowlist. | gap | gap | gap | | [API-75](../spec/saas-api.md#api-75) | *Counting outcomes*: Plain read. A value read **MUST** count one outcome: an L1 hit when the in-memory layer served it, an L2 hit when a backend read did, or a miss when … | **Gap:** No vector covers request headers, so nothing here catches a client that counts a plain read twice, or counts a read that failed at the backend; only the SDK's own counter tests do. | gap | gap | gap | | [API-76](../spec/saas-api.md#api-76) | *Counting outcomes*: … caching wrapper (the cachekit-py decorators, cachekit-ts `wrap()`, cachekit-rs `#[cachekit]`) **MUST** count one outcome, however many reads it makes. A call that runs the wrapped function counts one miss, before its … | **Gap:** No vector covers request headers, so nothing here catches a client that counts a wrapped call that runs the function as anything but one miss, a joined in-process caller as anything but one L1 hit, or a miss on each single-flight re-poll or lock re-check; only the SDK's own counter tests do. | gap | gap | gap | -| [API-77](../spec/saas-api.md#api-77) | *Plan maximum TTL*: … Then, on a plan with a maximum TTL `max_ttl` that the server has determined for the write, it **MUST** store the write with | Binds the server.
**Gap:** No public vector exercises the server, so nothing here catches one that rejects a TTL over the tenant's plan maximum, stores it unclamped, or keeps a stale window that carries the entry past the maximum. | n/a | n/a | n/a | +| [API-77](../spec/saas-api.md#api-77) | *Plan maximum TTL*: … every write. Then, on a plan with a maximum TTL `max_ttl`, outside the fallback below, the server **MUST** store the write with | Binds the server.
**Gap:** No public vector exercises the server, so nothing here catches one that rejects a TTL over the tenant's plan maximum, stores it unclamped, or keeps a stale window that carries the entry past the maximum. | n/a | n/a | n/a | | [API-78](../spec/saas-api.md#api-78) | *Plan maximum TTL*: … whose TTL reaches the maximum stores no stale window. A `PATCH /v1/cache/{key}/ttl` on such a plan **MUST** be clamped the same way, with its `ttl` in place of `effective_ttl` and the entry's stored stale window in place of … | Binds the server.
**Gap:** As API-77, for a PATCH /ttl handler that stores the requested ttl past the plan maximum, or shortens the fresh window to make room for the stored stale window. | n/a | n/a | n/a | | [API-79](../spec/saas-api.md#api-79) | *PUT /v1/cache/{key}*: … a plan without one — see No-expiry contract below. The plan maximum is the only default: servers **MUST NOT** apply any other, because the only default a writer cannot see must be one this spec publishes and the `2xx` reports … | Binds the server.
**Gap:** No public vector exercises the server, so nothing here catches one that stores a write without TTL headers under any default other than the tenant's plan maximum TTL. | n/a | n/a | n/a | | [API-80](../spec/saas-api.md#api-80) | *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-81](../spec/saas-api.md#api-81) | *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` and every tier that forwards `PUT`s to that store … | Binds the server.
**Gap:** As API-80, for a deployment that clamps writes before its store emits X-CacheKit-Fresh-For on PUT, or while a tier that forwards PUTs drops the header or bounds its copy by the stated TTL. | n/a | n/a | n/a | -| [API-82](../spec/saas-api.md#api-82) | *Remaining Freshness*: … that decayed remainder — its own copy TTL included — under any label. A tier that forwards a `PUT` **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 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-81](../spec/saas-api.md#api-81) | *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-80, 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-82](../spec/saas-api.md#api-82) | *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-83](../spec/saas-api.md#api-83) | *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-84](../spec/saas-api.md#api-84) | *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-85](../spec/saas-api.md#api-85) | *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-86](../spec/saas-api.md#api-86) | *Remaining Freshness*: … than one second after it. A tier caps what it emits at `2592000` (API-32), as the store does. It **MUST NOT** emit a value larger than that decayed remainder — its own copy TTL included — under any label. A tier that forwards a … | 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-87](../spec/saas-api.md#api-87) | *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 emits 0 for a copy of an accepted write whose PUT 2xx reported a positive bound. | n/a | n/a | n/a | +| [API-86](../spec/saas-api.md#api-86) | *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-87](../spec/saas-api.md#api-87) | *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 | Marked not-a-requirement in `spec/saas-api.md`: diff --git a/conformance/requirements.json b/conformance/requirements.json index 3e5e1e2..d0804cc 100644 --- a/conformance/requirements.json +++ b/conformance/requirements.json @@ -2104,12 +2104,12 @@ "API-81": { "section": "Remaining Freshness", "binds": "server", - "gap": "As API-80, for a deployment that clamps writes before its store emits X-CacheKit-Fresh-For on PUT, or while a tier that forwards PUTs drops the header or bounds its copy by the stated TTL." + "gap": "As API-80, 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." }, "API-82": { "section": "Remaining Freshness", "binds": "server", - "gap": "As API-19, for a tier that drops the X-CacheKit-Fresh-For of the store's PUT 2xx, or relays a larger value." + "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." }, "API-83": { "section": "Remaining Freshness", @@ -2134,7 +2134,7 @@ "API-87": { "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 emits 0 for a copy of an accepted write whose PUT 2xx reported a positive bound." + "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." } }, "retired": { diff --git a/spec/saas-api.md b/spec/saas-api.md index 4cc2917..aacccfc 100644 --- a/spec/saas-api.md +++ b/spec/saas-api.md @@ -159,12 +159,12 @@ X-CacheKit-L1-Status: miss - A **signal-capable server** is one that emits `X-CacheKit-Fresh-For` on `GET` as this section describes; the `PUT` emission below is a separate obligation. It is independent of [stale-while-revalidate](#stale-while-revalidate) support: a bounded entry has a `fresh_until` whether or not it has a stale window, so a server may emit `X-CacheKit-Fresh-For` without offering one, and a pre-signal SWR server emits `X-CacheKit-Freshness` without it. - Emitted on **every** `GET` `200 OK` for an entry that has a freshness bound. A **no-expiry** entry ([no-expiry contract](#put-v1cachekey)) has no `fresh_until`, no remainder to report, so the header is **omitted** — the server-side bound is unbounded, `min(local_ttl, ∞)` is the SDK's configured local TTL, and that is exactly the absent-header path below. When present, the value is a non-negative integer: `min(2592000, max(0, floor(fresh_until − now)))`, computed against the **server's clock** at response time — the client never compares server timestamps against its own clock. The `2592000` cap sits below the 90-day [TTL range](#put-v1cachekey), so it only ever shortens a local copy's bound. -- Emitted on a **write**, too. A store that applies a [plan maximum TTL](#plan-maximum-ttl) MUSTAPI-80 emit `X-CacheKit-Fresh-For` on every `PUT` `2xx` whose stored entry has a freshness bound, computed as above from the `fresh_until` it stamped, and omits it for a no-expiry entry; any other store may. That is how a writer learns the bound the store applied, which is shorter than the one it stated when the plan maximum clamped the write ([the writer's own copy](#remaining-freshness) below). A server MUST NOTAPI-81 clamp any write or `PATCH` until its store emits the header on `PUT` and every tier that forwards `PUT`s to that store relays it ([API-82](#api-82)) and bounds its copy by it ([API-85](#api-85)). So a `PUT` `2xx` without the header means the store applied exactly the write's [effective TTL](#put-v1cachekey), or stored a no-expiry entry because the write carried none. +- Emitted on a **write**, too. A store that applies a [plan maximum TTL](#plan-maximum-ttl) MUSTAPI-80 emit `X-CacheKit-Fresh-For` on every `PUT` `2xx` whose stored entry has a freshness bound, computed as above from the `fresh_until` it stamped, and omits it for a no-expiry entry; any other store may. That is how a writer learns the bound the store applied, which is shorter than the one it stated when the plan maximum clamped the write ([the writer's own copy](#remaining-freshness) below). A server MUST NOTAPI-81 clamp any write or `PATCH` until its store emits the header on `PUT`, every server-side hop that relays a `PUT` `2xx` from that store passes it through ([API-82](#api-82)), and every tier that keeps a copy of a forwarded write bounds that copy by it ([API-85](#api-85)). So a `PUT` `2xx` without the header means the store applied exactly the write's [effective TTL](#put-v1cachekey), or stored a no-expiry entry because the write carried none. - Stale-window responses (`X-CacheKit-Freshness: stale`) carry `X-CacheKit-Fresh-For: 0` — freshness is already exhausted. `X-CacheKit-Freshness: fresh` with `X-CacheKit-Fresh-For: 0` is also legal — an entry in its final sub-second of freshness floors to `0`. The response is served to the caller normally; the `0` governs only local caching (no backfill). -- Omitted by the store for no-expiry entries, and by pre-signal servers for everything. The client cannot tell the two apart, by design: both mean "no server-side freshness bound applies to this read" and both lead to the same SDK action (configured local TTL, fresh service only). Because absence carries that meaning, a tier that implements this section MUST NOTAPI-19 produce it for a copy it cannot positively confirm has no expiry — the tier rule below. +- Omitted on a `GET` by the store for no-expiry entries, and by pre-signal servers for everything. The client cannot tell the two apart, by design: both mean "no server-side freshness bound applies to this read" and both lead to the same SDK action (configured local TTL, fresh service only). Because absence carries that meaning, a tier that implements this section MUST NOTAPI-19 produce it for a copy it cannot positively confirm has no expiry — the tier rule below. - **Re-serving tiers** — an edge or colo cache in front of the store that answers from a copy it read earlier — are part of the server. A tier's **coherence window** is the deployment-documented maximum time it may keep serving a copy after the store has changed or deleted the entry; a tier that refreshes its copy's lifetime from the tier below rather than from the store adds its window to the path's total, so windows **compound** along the serving path and a deployment's effective window is their sum, not its largest single tier. That governs the *bytes*. The *header* follows stricter, fail-closed rules: - - **Decay, never re-stamp.** A tier MUSTAPI-85 emit its copy's remaining bound minus the seconds elapsed since that bound was obtained, flooring at `0`. A copy has exactly one bound source: the response it was read from (the bound is that response's `X-CacheKit-Fresh-For`), or — for a copy the tier populated from a write it forwarded — that write, once the store has accepted it. The bound of a forwarded write is the `X-CacheKit-Fresh-For` on the store's `PUT` `2xx` when it carries one, read with the [consumption](#remaining-freshness) grammar below (a malformed value is `0`, never a fallback to the TTL the write stated), otherwise the write's effective TTL (see [Migration](#put-v1cachekey)), and either is measured from when the tier dispatched the write. The store stamps `fresh_until` after that dispatch, from `floor(stored_at)` ([whole-second stamping](#entry-lifecycle)), and computes the header after stamping, so a bound from the header ends no later than `fresh_until`, and one from the effective TTL ends less than one second after it. A tier caps what it emits at `2592000` ([API-32](#api-32)), as the store does. It MUST NOTAPI-86 emit a value larger than that decayed remainder — its own copy TTL included — under any label. A tier that forwards a `PUT` MUST NOTAPI-82 drop the `X-CacheKit-Fresh-For` of the store's `2xx` from the response it relays, nor relay a value larger than the store's. The deployed tiers decay, so the rule costs a conforming implementation nothing, and it keeps a pre-`DELETE` copy's local service within one second of the entry's `fresh_until`. - - **Omit only on positive knowledge of no expiry; otherwise `0`.** A tier MAY omit the header only for a copy it positively knows has no expiry: a signal-capable store below omitted it (such a store omits only for no-expiry entries), or the tier populated the copy from an accepted write that carried no TTL at all — neither `X-CacheKit-TTL` nor legacy `X-TTL` — and whose `2xx` carried no `X-CacheKit-Fresh-For` (a write without a TTL on a plan with a [maximum TTL](#plan-maximum-ttl) is bounded, and its `2xx` says so). A tier MUSTAPI-22 determine a write's TTL with the store's own precedence (`X-CacheKit-TTL`, else `X-TTL`); the absence of `X-CacheKit-TTL` alone proves nothing, because a legacy `X-TTL: 60` write is bounded. For every other copy whose remainder it does not know — populated without a hint, a header lost in transit, or a store below that is pre-signal and therefore omits for bounded entries too — the tier MUSTAPI-23 emit `X-CacheKit-Fresh-For: 0`. It MUST NOTAPI-24 omit a header it received, nor drop it for such a copy: omission would claim "no bound" for an entry that may have one and silently restore the unbounded backfill this header exists to kill. A tier MUSTAPI-25 therefore record at populate whether a copy is unbounded or merely unhinted; a tier that cannot tell the two apart MUSTAPI-26 emit `0`. A tier fronting a pre-signal store is in exactly that position — it emits `0`, it does not pass the absence through; the cost is no local backfill behind that tier until the store signals, the fail-closed posture a mixed deployment should have. Whether the store below signals is a deployment fact the tier is configured with, never inferred per response. + - **Decay, never re-stamp.** A tier MUSTAPI-85 emit its copy's remaining bound minus the seconds elapsed since that bound was obtained, flooring at `0`. A copy has exactly one bound source: the response it was read from (the bound is that response's `X-CacheKit-Fresh-For`), or — for a copy the tier populated from a write it forwarded — that write, once the store has accepted it. The bound of a forwarded write is the `X-CacheKit-Fresh-For` on the store's `PUT` `2xx` when it carries one, read with the [consumption](#remaining-freshness) grammar below (a malformed value is `0`, never a fallback to the TTL the write stated), otherwise the write's effective TTL (see [Migration](#put-v1cachekey)), and either is measured from when the tier dispatched the write. The store stamps `fresh_until` after that dispatch, from `floor(stored_at)` ([whole-second stamping](#entry-lifecycle)), and computes the header after stamping, so a bound from the header ends no later than `fresh_until`, and one from the effective TTL ends less than one second after it. It MUST NOTAPI-86 emit a value larger than that decayed remainder or than `2592000` ([API-32](#api-32)) — its own copy TTL included — under any label. A tier, or any other server-side hop, that relays the store's `PUT` `2xx` MUST NOTAPI-82 drop the `X-CacheKit-Fresh-For` of the store's `2xx` from the response it relays, nor relay a value larger than the store's. The deployed tiers decay, so the rule costs a conforming implementation nothing, and it keeps a pre-`DELETE` copy's local service within one second of the entry's `fresh_until`. + - **Omit only on positive knowledge of no expiry; otherwise `0`.** A tier MAY omit the header only for a copy it positively knows has no expiry: the `GET` `200` the copy was read from came from a signal-capable store below and omitted it (such a store omits on `GET` only for no-expiry entries), or the tier populated the copy from an accepted write that carried no TTL at all — neither `X-CacheKit-TTL` nor legacy `X-TTL` — and whose `2xx` carried no `X-CacheKit-Fresh-For` (a write without a TTL that a [plan maximum TTL](#plan-maximum-ttl) bounded carries the header on its `2xx`; a `PUT` `2xx` without it never qualifies a copy on the first condition). A tier MUSTAPI-22 determine a write's TTL with the store's own precedence (`X-CacheKit-TTL`, else `X-TTL`); the absence of `X-CacheKit-TTL` alone proves nothing, because a legacy `X-TTL: 60` write is bounded. For every other copy whose remainder it does not know — populated without a hint, a header lost in transit, or a store below that is pre-signal and therefore omits for bounded entries too — the tier MUSTAPI-23 emit `X-CacheKit-Fresh-For: 0`. It MUST NOTAPI-24 omit a header it received, nor drop it for such a copy: omission would claim "no bound" for an entry that may have one and silently restore the unbounded backfill this header exists to kill. A tier MUSTAPI-25 therefore record at populate whether a copy is unbounded or merely unhinted; a tier that cannot tell the two apart MUSTAPI-26 emit `0`. A tier fronting a pre-signal store is in exactly that position — it emits `0`, it does not pass the absence through; the cost is no local backfill behind that tier until the store signals, the fail-closed posture a mixed deployment should have. Whether the store below signals is a deployment fact the tier is configured with, never inferred per response. - **Positive only for `fresh` + positive.** A tier MAY emit a positive value only when its copy's bound source qualifies, and then only the decayed remainder: a response the tier below labelled exactly `X-CacheKit-Freshness: fresh` (or left unlabelled — the pre-SWR `fresh` default) and stamped with a strictly positive `X-CacheKit-Fresh-For`; or an accepted write whose bound (above) is positive, whose copy is labelled `fresh` (a just-accepted write is fresh by construction). Every other shape — `stale`, `X-CacheKit-Fresh-For: 0` under any label, an unrecognized freshness token, or a missing `X-CacheKit-Fresh-For` on any entry not positively known to be no-expiry — MUSTAPI-87 be emitted with `X-CacheKit-Fresh-For: 0` and its freshness label passed through unchanged (a `fresh` `0` stays `fresh`; a `stale` stays `stale`). Stamping a positive bound onto a stale, exhausted, or unknown value resurrects an expired (or revoked) entry as locally-cacheable fresh — the exact hole this header closes. - `HEAD` does **not** carry this header — an existence check returns no payload, so there is nothing to backfill locally (the `X-CacheKit-Freshness` label on `HEAD` remains informational, per [Stale-While-Revalidate](#stale-while-revalidate)). Correspondingly, a `HEAD` response MUST NOTAPI-28 create, refresh, or extend any local entry's service bound. @@ -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-83 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. Add the plan-maximum excess (above): without a `2xx` the SDK cannot learn the bound the store applied. 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-83 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 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. @@ -226,7 +226,7 @@ X-CacheKit-L1-Status: miss > | TTL negative | **Reject** — return error to caller. | **Reject** — return `400 Bad Request`. | > | TTL non-integer | N/A (SDK converts duration to integer seconds). | **Reject** — return `400 Bad Request`. | > -> **No-expiry contract.** A **no-expiry entry** is one stored with no `fresh_until` and no `evict_at`. It comes only from a write without a TTL — neither `X-CacheKit-TTL` nor legacy `X-TTL` (see **Migration** below) — that no [plan maximum TTL](#plan-maximum-ttl) bounded: on a plan without one, when the server could not determine the plan, or before the tenant's plan gained one. On a plan with a maximum TTL such a write is bounded instead: +> **No-expiry contract.** A **no-expiry entry** is one stored with no `fresh_until` and no `evict_at`. It comes only from a write without a TTL — neither `X-CacheKit-TTL` nor legacy `X-TTL` (see **Migration** below) — that no [plan maximum TTL](#plan-maximum-ttl) bounded; that section says when one does. A no-expiry entry has these properties: > - **Reads:** permanently **fresh** — while the entry is present, `GET` and `HEAD` return `200 OK` with `X-CacheKit-Freshness: fresh`, and `GET` carries no [`X-CacheKit-Fresh-For`](#remaining-freshness). It never enters a stale window. Presence ends only on explicit `DELETE`, overwrite, or capacity eviction (below). > - **Storage bound: deployment-configured.** No-expiry entries are never age-evicted. Whether, and at what ceiling, a deployment applies capacity eviction is deployment configuration outside this protocol; no-expiry entries are eligible victims of whatever capacity eviction the deployment applies, so "no expiry" is **not** a durability guarantee, and an evicted entry gives no client-visible signal beyond a subsequent miss. A deployment that needs a specific storage ceiling must provision a tenant or namespace quota. > - **`GET /v1/cache/{key}/ttl`:** see the [endpoint](#get-v1cachekeyttl). @@ -248,7 +248,7 @@ X-CacheKit-L1-Status: miss | Header | Description | | :--- | :--- | -| `X-CacheKit-Fresh-For` | On a `2xx`: the stored entry's remaining freshness at response time, `min(2592000, max(0, floor(fresh_until − now)))` ([API-80](#api-80)); omitted for a no-expiry entry. Semantics: [Remaining Freshness](#remaining-freshness), including [the writer's own copy](#remaining-freshness). | +| `X-CacheKit-Fresh-For` | On a `2xx`: the stored entry's remaining freshness at response time, `min(2592000, max(0, floor(fresh_until − now)))`. Required from a store that clamps ([API-80](#api-80)), optional otherwise; when absent, the stated TTL was applied or the entry has no expiry. Semantics: [Remaining Freshness](#remaining-freshness), including [the writer's own copy](#remaining-freshness). | #### Plan maximum TTL @@ -261,7 +261,7 @@ Each plan has a maximum TTL: the longest a write or `PATCH` under it can set an | Professional | 7,776,000 seconds (90 days) | | Enterprise | None | -The maximum clamps a write; it never rejects one. The [TTL validation rules](#put-v1cachekey) and the [stale-window validation](#validation) run first and do not depend on the plan, so a client can pre-validate every write. Then, on a plan with a maximum TTL `max_ttl` that the server has determined for the write, it MUSTAPI-77 store the write with +The maximum clamps a write; it never rejects one. The [TTL validation rules](#put-v1cachekey) and the [stale-window validation](#validation) run first and do not depend on the plan, so a client can pre-validate every write. Then, on a plan with a maximum TTL `max_ttl`, outside the fallback below, the server MUSTAPI-77 store the write with ``` applied_ttl = min(effective_ttl, max_ttl), or max_ttl for a write without a TTL @@ -270,9 +270,9 @@ applied_stale_ttl = min(stale_ttl, max_ttl − applied_ttl) so that `evict_at − floor(stored_at) ≤ max_ttl`. The fresh window is kept first: the stale window gets only what the maximum leaves, so a write whose TTL reaches the maximum stores no stale window. A [`PATCH /v1/cache/{key}/ttl`](#patch-v1cachekeyttl) on such a plan MUSTAPI-78 be clamped the same way, with its `ttl` in place of `effective_ttl` and the entry's stored stale window in place of `stale_ttl`, so that `evict_at − floor(now) ≤ max_ttl`. On a plan without a maximum TTL, writes and `PATCH`es are stored as stated, and a write without a TTL stores a no-expiry entry. -A server that cannot determine the tenant's plan for a write or `PATCH` stores it as stated, as on a plan without a maximum. Its `2xx` reports what it stored, so every copy stays bounded by the stored entry, but the plan maximum is then not a guaranteed ceiling on that entry's lifetime. +**Fallback.** A server that cannot determine the tenant's plan for a write or `PATCH`, or whose deployment does not yet meet [API-81](#api-81), stores it as stated, as on a plan without a maximum; API-77 and API-78 apply only once neither holds. Its `2xx` reports what it stored, so every copy stays bounded by the stored entry, but the plan maximum is then not a guaranteed ceiling on that entry's lifetime. -The maximum applies when a write or `PATCH` is stamped. An entry keeps the timing it was stored with when the tenant's plan changes later, a no-expiry entry included, until its next write or `PATCH`. +The maximum applies when a write or `PATCH` is stamped. An entry keeps the timing it was stored with when the tenant's plan changes later, or once the fallback stops applying, a no-expiry entry included, until its next write or `PATCH`. --- @@ -346,7 +346,7 @@ All lifecycle times are computed against the **store's clock**; SDKs MUST NOT Date: Sun, 1 Nov 2026 11:39:49 +1100 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20address=20coderabbit=20review=20?= =?UTF-8?q?=E2=80=94=20fallback=20response=20claim,=20tier=20cap=20wording?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also folds in the held wording items: summary rows defer to the Plan maximum TTL section and its Fallback, the Fallback names the clamping conditions, the glossary notes the Fallback, and the changelog covers PATCH and the GET-only omit evidence. CodeRabbit-Resolved: changelog.d/20261031_plan-maximum-ttl.md:29:Do not require tiers to rep CodeRabbit-Resolved: spec/saas-api.md:273:Correct the fallback response --- CONTEXT.md | 2 +- changelog.d/20261031_plan-maximum-ttl.md | 22 ++++++++++++---------- conformance/coverage.md | 2 +- spec/saas-api.md | 12 ++++++------ 4 files changed, 20 insertions(+), 18 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 8c2efa9..ffe61fb 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -133,7 +133,7 @@ An entry stored with no end to its freshness window, which never enters a stale _Avoid_: permanent entry, infinite TTL **Plan maximum TTL**: -The longest a write or TTL update under a tenant's plan can make an entry live in CachekitIO, its stale window included; each plan's figure is in the SaaS API spec. A longer TTL, or none, is shortened to it, never rejected. +The longest a write or TTL update under a tenant's plan can make an entry live in CachekitIO, its stale window included; each plan's figure is in the SaaS API spec. A longer TTL, or none, is shortened to it, never rejected, unless the server stores the write as stated (the Fallback). _Avoid_: TTL cap, max TTL (on their own) **TTL range**: diff --git a/changelog.d/20261031_plan-maximum-ttl.md b/changelog.d/20261031_plan-maximum-ttl.md index 4d12a01..0b866b9 100644 --- a/changelog.d/20261031_plan-maximum-ttl.md +++ b/changelog.d/20261031_plan-maximum-ttl.md @@ -9,24 +9,26 @@ same way ([API-78](spec/saas-api.md#api-78)), and its response must not extend a local copy ([API-84](spec/saas-api.md#api-84)). Validation stays plan-independent, so a client can still pre-validate every write. A server that cannot determine the tenant's plan stores the write as - stated, and so does a deployment that does not yet meet API-81, so the plan maximum is not a - guaranteed ceiling on an entry's lifetime. Entries stored before the change keep their timing - until their next write or `PATCH`. + stated, and so does a deployment that does not yet meet the conditions API-81 sets for + clamping, so the plan maximum is not a guaranteed ceiling on an entry's lifetime. Entries stored + before the change keep their timing until their next write or `PATCH`. - The TTL range of `X-CacheKit-TTL`, of `PATCH /ttl`'s `ttl` and of `ttl + stale_ttl` rises from 2,592,000 to 7,776,000 (`stale_ttl` alone to 7,775,999). A larger value is still a `400`. - The plan maximum is the only default for a write without a TTL ([API-79](spec/saas-api.md#api-79), which merges and retires API-40 and API-41). - A store that clamps sends `X-CacheKit-Fresh-For` on every `PUT` `2xx` for a bounded entry: the - stored entry's remaining freshness ([API-80](spec/saas-api.md#api-80)). No write is clamped until - the store sends it, every server-side hop that relays the `PUT` `2xx` passes it through, and - every tier that keeps a copy of a forwarded write bounds that copy by it + stored entry's remaining freshness ([API-80](spec/saas-api.md#api-80)). No write or `PATCH` is + clamped until the store sends it, every server-side hop that relays the `PUT` `2xx` passes it + through, and every tier that keeps a copy of a forwarded write bounds that copy by it ([API-81](spec/saas-api.md#api-81)). An SDK bounds its copy of its own write by - `min(local_ttl, effective_ttl, fresh_for)` ([API-83](spec/saas-api.md#api-83), replacing API-35). A re-serving tier bounds its copy of a forwarded write by the same header + `min(local_ttl, effective_ttl, fresh_for)` ([API-83](spec/saas-api.md#api-83), replacing API-35). + A re-serving tier bounds its copy of a forwarded write by the same header ([API-85](spec/saas-api.md#api-85), [API-86](spec/saas-api.md#api-86) and [API-87](spec/saas-api.md#api-87), replacing API-20, API-21 and API-27) and relays it unraised - ([API-82](spec/saas-api.md#api-82)). -- The `X-CacheKit-Fresh-For` grammar keeps its 2,592,000 maximum: a server or tier reports an - entry with more than 30 days of freshness left as `2592000`. + ([API-82](spec/saas-api.md#api-82)). A tier may omit the header for a copy only on evidence + from a `GET` `200`; a `PUT` `2xx` without it never marks a copy as no-expiry. +- The `X-CacheKit-Fresh-For` grammar keeps its 2,592,000 maximum: no server or tier emits a larger + value, even for an entry with more than 30 days of freshness left. - **Breaking for callers on a plan with a maximum TTL:** once the server applies this, a write without a TTL is no longer stored with no expiry, and a TTL above the plan's maximum is shortened to it. diff --git a/conformance/coverage.md b/conformance/coverage.md index ce0f6a2..1cc3179 100644 --- a/conformance/coverage.md +++ b/conformance/coverage.md @@ -270,7 +270,7 @@ Marked not-a-requirement in `spec/intent-presets.md`: | [API-76](../spec/saas-api.md#api-76) | *Counting outcomes*: … caching wrapper (the cachekit-py decorators, cachekit-ts `wrap()`, cachekit-rs `#[cachekit]`) **MUST** count one outcome, however many reads it makes. A call that runs the wrapped function counts one miss, before its … | **Gap:** No vector covers request headers, so nothing here catches a client that counts a wrapped call that runs the function as anything but one miss, a joined in-process caller as anything but one L1 hit, or a miss on each single-flight re-poll or lock re-check; only the SDK's own counter tests do. | gap | gap | gap | | [API-77](../spec/saas-api.md#api-77) | *Plan maximum TTL*: … every write. Then, on a plan with a maximum TTL `max_ttl`, outside the fallback below, the server **MUST** store the write with | Binds the server.
**Gap:** No public vector exercises the server, so nothing here catches one that rejects a TTL over the tenant's plan maximum, stores it unclamped, or keeps a stale window that carries the entry past the maximum. | n/a | n/a | n/a | | [API-78](../spec/saas-api.md#api-78) | *Plan maximum TTL*: … whose TTL reaches the maximum stores no stale window. A `PATCH /v1/cache/{key}/ttl` on such a plan **MUST** be clamped the same way, with its `ttl` in place of `effective_ttl` and the entry's stored stale window in place of … | Binds the server.
**Gap:** As API-77, for a PATCH /ttl handler that stores the requested ttl past the plan maximum, or shortens the fresh window to make room for the stored stale window. | n/a | n/a | n/a | -| [API-79](../spec/saas-api.md#api-79) | *PUT /v1/cache/{key}*: … a plan without one — see No-expiry contract below. The plan maximum is the only default: servers **MUST NOT** apply any other, because the only default a writer cannot see must be one this spec publishes and the `2xx` reports … | Binds the server.
**Gap:** No public vector exercises the server, so nothing here catches one that stores a write without TTL headers under any default other than the tenant's plan maximum TTL. | n/a | n/a | n/a | +| [API-79](../spec/saas-api.md#api-79) | *PUT /v1/cache/{key}*: … Fallback included — see No-expiry contract below. The plan maximum is the only default: servers **MUST NOT** apply any other, because the only default a writer cannot see must be one this spec publishes and the `2xx` reports … | Binds the server.
**Gap:** No public vector exercises the server, so nothing here catches one that stores a write without TTL headers under any default other than the tenant's plan maximum TTL. | n/a | n/a | n/a | | [API-80](../spec/saas-api.md#api-80) | *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-81](../spec/saas-api.md#api-81) | *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-80, 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-82](../spec/saas-api.md#api-82) | *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 | diff --git a/spec/saas-api.md b/spec/saas-api.md index aacccfc..20491ca 100644 --- a/spec/saas-api.md +++ b/spec/saas-api.md @@ -210,7 +210,7 @@ X-CacheKit-L1-Status: miss | Header | Required | Description | | :--- | :---: | :--- | -| `X-CacheKit-TTL` | No | Time-to-live in seconds. Positive integer, minimum 1, maximum 7,776,000 (90 days, the **TTL range**). The store applies at most the tenant's [plan maximum TTL](#plan-maximum-ttl). Omit — together with legacy `X-TTL` — to store the entry with the plan maximum TTL, or with **no expiry** on a plan without one (see the **No-expiry contract** under TTL Validation Rules). | +| `X-CacheKit-TTL` | No | Time-to-live in seconds. Positive integer, minimum 1, maximum 7,776,000 (90 days, the **TTL range**). The store applies at most the tenant's [plan maximum TTL](#plan-maximum-ttl). Omit — together with legacy `X-TTL` — to store the entry as [Plan maximum TTL](#plan-maximum-ttl) says: with the plan maximum TTL, or with **no expiry** where none applies (see the **No-expiry contract** under TTL Validation Rules). | | `X-CacheKit-Stale-TTL` | No | Stale-grace window in seconds after freshness expiry. Requires an explicit TTL (`X-CacheKit-TTL`, or legacy `X-TTL`) on the same request. Validation and semantics: [Stale-While-Revalidate](#stale-while-revalidate). Pre-SWR servers ignore this header. | > [!IMPORTANT] @@ -218,11 +218,11 @@ X-CacheKit-L1-Status: miss > > | Condition | SDK Behavior | Server Behavior | > | :--- | :--- | :--- | -> | TTL omitted | Use client default TTL. If no client default, omit `X-CacheKit-TTL` header. | When the request carries neither `X-CacheKit-TTL` nor `X-TTL`: store with the tenant's [plan maximum TTL](#plan-maximum-ttl), or with **no expiry** on a plan without one — see **No-expiry contract** below. The plan maximum is the only default: servers MUST NOTAPI-79 apply any other, because the only default a writer cannot see must be one this spec publishes and the `2xx` reports ([Remaining Freshness](#remaining-freshness)). | +> | TTL omitted | Use client default TTL. If no client default, omit `X-CacheKit-TTL` header. | When the request carries neither `X-CacheKit-TTL` nor `X-TTL`: store as [Plan maximum TTL](#plan-maximum-ttl) says: with the tenant's plan maximum TTL, or with **no expiry** where none applies, its Fallback included — see **No-expiry contract** below. The plan maximum is the only default: servers MUST NOTAPI-79 apply any other, because the only default a writer cannot see must be one this spec publishes and the `2xx` reports ([Remaining Freshness](#remaining-freshness)). | > | TTL = 0 | **Reject** — return error to caller. Zero is not a valid TTL. | **Reject** — return `400 Bad Request`. | > | TTL < 1 second | **Round up to 1.** Sub-second durations MUSTAPI-42 be ceiled, never truncated to 0. | N/A (header is integer seconds). | > | TTL > 7,776,000 | **Reject** — return error to caller. | **Reject** — return `400 Bad Request`. | -> | TTL > the tenant's plan maximum TTL, and at most 7,776,000 | **Send it.** The SDK does not know the tenant's plan. | **Store with the plan maximum TTL** ([Plan maximum TTL](#plan-maximum-ttl)); never reject. | +> | TTL > the tenant's plan maximum TTL, and at most 7,776,000 | **Send it.** The SDK does not know the tenant's plan. | **Store as [Plan maximum TTL](#plan-maximum-ttl) says:** with the plan maximum, or as stated under its Fallback; never reject. | > | TTL negative | **Reject** — return error to caller. | **Reject** — return `400 Bad Request`. | > | TTL non-integer | N/A (SDK converts duration to integer seconds). | **Reject** — return `400 Bad Request`. | > @@ -270,7 +270,7 @@ applied_stale_ttl = min(stale_ttl, max_ttl − applied_ttl) so that `evict_at − floor(stored_at) ≤ max_ttl`. The fresh window is kept first: the stale window gets only what the maximum leaves, so a write whose TTL reaches the maximum stores no stale window. A [`PATCH /v1/cache/{key}/ttl`](#patch-v1cachekeyttl) on such a plan MUSTAPI-78 be clamped the same way, with its `ttl` in place of `effective_ttl` and the entry's stored stale window in place of `stale_ttl`, so that `evict_at − floor(now) ≤ max_ttl`. On a plan without a maximum TTL, writes and `PATCH`es are stored as stated, and a write without a TTL stores a no-expiry entry. -**Fallback.** A server that cannot determine the tenant's plan for a write or `PATCH`, or whose deployment does not yet meet [API-81](#api-81), stores it as stated, as on a plan without a maximum; API-77 and API-78 apply only once neither holds. Its `2xx` reports what it stored, so every copy stays bounded by the stored entry, but the plan maximum is then not a guaranteed ceiling on that entry's lifetime. +**Fallback.** A server that cannot determine the tenant's plan for a write or `PATCH`, or whose deployment does not yet meet the conditions [API-81](#api-81) sets for clamping (the store emits the header on `PUT`, every hop that relays a `PUT` `2xx` passes it through, every tier bounds its copy of a forwarded write by it), stores it as stated, as on a plan without a maximum; API-77 and API-78 apply only once neither holds. A `PUT` `2xx` without `X-CacheKit-Fresh-For` then means the stated TTL was applied ([API-81](#api-81)), so a writer's copy is bounded as for any unclamped write. The plan maximum is then not a guaranteed ceiling on that entry's lifetime. The maximum applies when a write or `PATCH` is stamped. An entry keeps the timing it was stored with when the tenant's plan changes later, or once the fallback stops applying, a no-expiry entry included, until its next write or `PATCH`. @@ -357,7 +357,7 @@ These rules are normative for both SDKs and the SaaS backend, mirroring the [TTL | `stale_ttl` negative, non-integer, or > 7,775,999 | `400 Bad Request`. The standalone bound is checked **before** any arithmetic (no integer wrap on `evict_at`). | | `X-CacheKit-Stale-TTL` present without an explicit TTL (`X-CacheKit-TTL`, or legacy `X-TTL`) | `400 Bad Request`. Validation must not depend on hidden tenant defaults — clients must be able to pre-validate. | | `ttl + stale_ttl > 7,776,000` | `400 Bad Request` (the stale window shares the [90-day TTL range](#put-v1cachekey)) | -| `ttl + stale_ttl` > the tenant's [plan maximum TTL](#plan-maximum-ttl), and at most 7,776,000 | Accepted; the store clamps the windows, the fresh window first | +| `ttl + stale_ttl` > the tenant's [plan maximum TTL](#plan-maximum-ttl), and at most 7,776,000 | Accepted; the store clamps the windows, the fresh window first, as [Plan maximum TTL](#plan-maximum-ttl) says (or stores them as stated under its Fallback) | | `stale_ttl = 0` | Accepted; equivalent to omitting the header | | `stale_ttl < 1` second (sub-second duration in SDK API) | SDKs MUSTAPI-54 ceil to 1, never truncate to 0 (same rule as TTL) | @@ -513,7 +513,7 @@ Content-Type: application/json {"ttl": 7200} ``` -The `ttl` field follows the same validation rules as `X-CacheKit-TTL`: positive integer, minimum 1, maximum 7,776,000. On a plan with a [maximum TTL](#plan-maximum-ttl) the store clamps it, and the stored stale window, to that maximum. The `200` does not report the applied value (`GET /v1/cache/{key}/ttl` reports the remaining time to `evict_at`, stale window included), so a `PATCH` response MUST NOTAPI-84 create, refresh or extend any local entry's service bound. For entries stored with a stale-grace window, see [SWR write semantics](#write-semantics): a PATCH within the fresh window renews it; a PATCH on a stale entry MUSTAPI-73 return `409 Conflict`. A `PATCH` on a no-expiry entry is within the fresh window by definition and bounds it: `fresh_until = floor(now) + ttl` ([whole-second stamping](#entry-lifecycle)), no stale window. +The `ttl` field follows the same validation rules as `X-CacheKit-TTL`: positive integer, minimum 1, maximum 7,776,000. On a plan with a maximum TTL the store clamps it, and the stored stale window, as [Plan maximum TTL](#plan-maximum-ttl) says ([API-78](#api-78), outside its Fallback). The `200` does not report the applied value (`GET /v1/cache/{key}/ttl` reports the remaining time to `evict_at`, stale window included), so a `PATCH` response MUST NOTAPI-84 create, refresh or extend any local entry's service bound. For entries stored with a stale-grace window, see [SWR write semantics](#write-semantics): a PATCH within the fresh window renews it; a PATCH on a stale entry MUSTAPI-73 return `409 Conflict`. A `PATCH` on a no-expiry entry is within the fresh window by definition and bounds it: `fresh_until = floor(now) + ttl` ([whole-second stamping](#entry-lifecycle)), no stale window. | Status | Meaning | | :---: | :--- | From e353461a78d658125ac8357e12845cf1cabaf7c1 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sun, 1 Nov 2026 11:55:00 +1100 Subject: [PATCH 7/8] docs(saas-api): changelog states the full omit rule; Fallback names server-side hops --- CONTEXT.md | 2 +- changelog.d/20261031_plan-maximum-ttl.md | 5 +++-- spec/saas-api.md | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index ffe61fb..c27c8f8 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -133,7 +133,7 @@ An entry stored with no end to its freshness window, which never enters a stale _Avoid_: permanent entry, infinite TTL **Plan maximum TTL**: -The longest a write or TTL update under a tenant's plan can make an entry live in CachekitIO, its stale window included; each plan's figure is in the SaaS API spec. A longer TTL, or none, is shortened to it, never rejected, unless the server stores the write as stated (the Fallback). +The longest a write or TTL update under a tenant's plan can make an entry live in CachekitIO, its stale window included; each plan's figure is in the SaaS API spec. A longer TTL, or none, is never rejected; it is shortened to the maximum unless the server stores the write as stated (the Fallback). _Avoid_: TTL cap, max TTL (on their own) **TTL range**: diff --git a/changelog.d/20261031_plan-maximum-ttl.md b/changelog.d/20261031_plan-maximum-ttl.md index 0b866b9..5b09360 100644 --- a/changelog.d/20261031_plan-maximum-ttl.md +++ b/changelog.d/20261031_plan-maximum-ttl.md @@ -25,8 +25,9 @@ A re-serving tier bounds its copy of a forwarded write by the same header ([API-85](spec/saas-api.md#api-85), [API-86](spec/saas-api.md#api-86) and [API-87](spec/saas-api.md#api-87), replacing API-20, API-21 and API-27) and relays it unraised - ([API-82](spec/saas-api.md#api-82)). A tier may omit the header for a copy only on evidence - from a `GET` `200`; a `PUT` `2xx` without it never marks a copy as no-expiry. + ([API-82](spec/saas-api.md#api-82)). A tier may omit the header only on positive knowledge + of no expiry: a `GET` `200` from a signal-capable store that omitted it, or a forwarded write + with no TTL whose `PUT` `2xx` omitted it. - The `X-CacheKit-Fresh-For` grammar keeps its 2,592,000 maximum: no server or tier emits a larger value, even for an entry with more than 30 days of freshness left. - **Breaking for callers on a plan with a maximum TTL:** once the server applies this, a write diff --git a/spec/saas-api.md b/spec/saas-api.md index 20491ca..0412b61 100644 --- a/spec/saas-api.md +++ b/spec/saas-api.md @@ -270,7 +270,7 @@ applied_stale_ttl = min(stale_ttl, max_ttl − applied_ttl) so that `evict_at − floor(stored_at) ≤ max_ttl`. The fresh window is kept first: the stale window gets only what the maximum leaves, so a write whose TTL reaches the maximum stores no stale window. A [`PATCH /v1/cache/{key}/ttl`](#patch-v1cachekeyttl) on such a plan MUSTAPI-78 be clamped the same way, with its `ttl` in place of `effective_ttl` and the entry's stored stale window in place of `stale_ttl`, so that `evict_at − floor(now) ≤ max_ttl`. On a plan without a maximum TTL, writes and `PATCH`es are stored as stated, and a write without a TTL stores a no-expiry entry. -**Fallback.** A server that cannot determine the tenant's plan for a write or `PATCH`, or whose deployment does not yet meet the conditions [API-81](#api-81) sets for clamping (the store emits the header on `PUT`, every hop that relays a `PUT` `2xx` passes it through, every tier bounds its copy of a forwarded write by it), stores it as stated, as on a plan without a maximum; API-77 and API-78 apply only once neither holds. A `PUT` `2xx` without `X-CacheKit-Fresh-For` then means the stated TTL was applied ([API-81](#api-81)), so a writer's copy is bounded as for any unclamped write. The plan maximum is then not a guaranteed ceiling on that entry's lifetime. +**Fallback.** A server that cannot determine the tenant's plan for a write or `PATCH`, or whose deployment does not yet meet the conditions [API-81](#api-81) sets for clamping (the store emits the header on `PUT`, every server-side hop that relays a `PUT` `2xx` passes it through, every tier bounds its copy of a forwarded write by it), stores it as stated, as on a plan without a maximum; API-77 and API-78 apply only once neither holds. A `PUT` `2xx` without `X-CacheKit-Fresh-For` then means the stated TTL was applied ([API-81](#api-81)), so a writer's copy is bounded as for any unclamped write. The plan maximum is then not a guaranteed ceiling on that entry's lifetime. The maximum applies when a write or `PATCH` is stamped. An entry keeps the timing it was stored with when the tenant's plan changes later, or once the fallback stops applying, a no-expiry entry included, until its next write or `PATCH`. From 1367725829fdc334692ba02e34082bec94940146 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sun, 1 Nov 2026 11:55:07 +1100 Subject: [PATCH 8/8] docs(saas-api): renumber this change's requirement ids after those minted on main --- changelog.d/20261031_plan-maximum-ttl.md | 22 ++++++------ conformance/coverage.md | 26 +++++++------- conformance/requirements.json | 44 ++++++++++++------------ sdk-feature-matrix.md | 2 +- spec/saas-api.md | 22 ++++++------ 5 files changed, 58 insertions(+), 58 deletions(-) diff --git a/changelog.d/20261031_plan-maximum-ttl.md b/changelog.d/20261031_plan-maximum-ttl.md index 5b09360..cdff342 100644 --- a/changelog.d/20261031_plan-maximum-ttl.md +++ b/changelog.d/20261031_plan-maximum-ttl.md @@ -4,28 +4,28 @@ (24 hours), Startup 2,592,000 s (30 days), Professional 7,776,000 s (90 days), Enterprise none. On a plan with a maximum, a stated TTL above it is stored with the maximum, not rejected, and a write without a TTL is stored with the maximum instead of with no expiry - ([API-77](spec/saas-api.md#api-77)). The maximum bounds `evict_at` from each write's stamp: the + ([API-97](spec/saas-api.md#api-97)). The maximum bounds `evict_at` from each write's stamp: the fresh window is kept first and the stale window gets what is left. `PATCH /ttl` is clamped the - same way ([API-78](spec/saas-api.md#api-78)), and its response must not extend a local copy - ([API-84](spec/saas-api.md#api-84)). Validation stays plan-independent, so a client can still + same way ([API-98](spec/saas-api.md#api-98)), and its response must not extend a local copy + ([API-104](spec/saas-api.md#api-104)). Validation stays plan-independent, so a client can still pre-validate every write. A server that cannot determine the tenant's plan stores the write as - stated, and so does a deployment that does not yet meet the conditions API-81 sets for + stated, and so does a deployment that does not yet meet the conditions API-101 sets for clamping, so the plan maximum is not a guaranteed ceiling on an entry's lifetime. Entries stored before the change keep their timing until their next write or `PATCH`. - The TTL range of `X-CacheKit-TTL`, of `PATCH /ttl`'s `ttl` and of `ttl + stale_ttl` rises from 2,592,000 to 7,776,000 (`stale_ttl` alone to 7,775,999). A larger value is still a `400`. - The plan maximum is the only default for a write without a TTL - ([API-79](spec/saas-api.md#api-79), which merges and retires API-40 and API-41). + ([API-99](spec/saas-api.md#api-99), which merges and retires API-40 and API-41). - A store that clamps sends `X-CacheKit-Fresh-For` on every `PUT` `2xx` for a bounded entry: the - stored entry's remaining freshness ([API-80](spec/saas-api.md#api-80)). No write or `PATCH` is + stored entry's remaining freshness ([API-100](spec/saas-api.md#api-100)). No write or `PATCH` is clamped until the store sends it, every server-side hop that relays the `PUT` `2xx` passes it through, and every tier that keeps a copy of a forwarded write bounds that copy by it - ([API-81](spec/saas-api.md#api-81)). An SDK bounds its copy of its own write by - `min(local_ttl, effective_ttl, fresh_for)` ([API-83](spec/saas-api.md#api-83), replacing API-35). + ([API-101](spec/saas-api.md#api-101)). An SDK bounds its copy of its own write by + `min(local_ttl, effective_ttl, fresh_for)` ([API-103](spec/saas-api.md#api-103), replacing API-35). A re-serving tier bounds its copy of a forwarded write by the same header - ([API-85](spec/saas-api.md#api-85), [API-86](spec/saas-api.md#api-86) and - [API-87](spec/saas-api.md#api-87), replacing API-20, API-21 and API-27) and relays it unraised - ([API-82](spec/saas-api.md#api-82)). A tier may omit the header only on positive knowledge + ([API-105](spec/saas-api.md#api-105), [API-106](spec/saas-api.md#api-106) and + [API-107](spec/saas-api.md#api-107), replacing API-20, API-21 and API-27) and relays it unraised + ([API-102](spec/saas-api.md#api-102)). A tier may omit the header only on positive knowledge of no expiry: a `GET` `200` from a signal-capable store that omitted it, or a forwarded write with no TTL whose `PUT` `2xx` omitted it. - The `X-CacheKit-Fresh-For` grammar keeps its 2,592,000 maximum: no server or tier emits a larger diff --git a/conformance/coverage.md b/conformance/coverage.md index 1cc3179..1f1f965 100644 --- a/conformance/coverage.md +++ b/conformance/coverage.md @@ -229,8 +229,8 @@ 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-83, 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-83, for an SDK that serves such a copy past its bound through a local stale-grace policy. | gap | gap | 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-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 | | [API-42](../spec/saas-api.md#api-42) | *PUT /v1/cache/{key}*: Round up to 1. Sub-second durations **MUST** be ceiled, never truncated to 0. | **Gap:** No vector maps a duration to a header, so nothing here catches a client that sends a sub-second TTL unrounded, or truncates it to 0 and then rejects it or sends 0. | gap | gap | gap | @@ -268,17 +268,17 @@ Marked not-a-requirement in `spec/intent-presets.md`: | [API-74](../spec/saas-api.md#api-74) | *SSRF Protection*: All SDKs **MUST** enforce SSRF protection when accepting the API URL. The following rules apply: | **Gap:** No vector holds an API URL, so nothing here checks HTTPS-only, the listed private ranges or the hostname allowlist. | gap | gap | gap | | [API-75](../spec/saas-api.md#api-75) | *Counting outcomes*: Plain read. A value read **MUST** count one outcome: an L1 hit when the in-memory layer served it, an L2 hit when a backend read did, or a miss when … | **Gap:** No vector covers request headers, so nothing here catches a client that counts a plain read twice, or counts a read that failed at the backend; only the SDK's own counter tests do. | gap | gap | gap | | [API-76](../spec/saas-api.md#api-76) | *Counting outcomes*: … caching wrapper (the cachekit-py decorators, cachekit-ts `wrap()`, cachekit-rs `#[cachekit]`) **MUST** count one outcome, however many reads it makes. A call that runs the wrapped function counts one miss, before its … | **Gap:** No vector covers request headers, so nothing here catches a client that counts a wrapped call that runs the function as anything but one miss, a joined in-process caller as anything but one L1 hit, or a miss on each single-flight re-poll or lock re-check; only the SDK's own counter tests do. | gap | gap | gap | -| [API-77](../spec/saas-api.md#api-77) | *Plan maximum TTL*: … every write. Then, on a plan with a maximum TTL `max_ttl`, outside the fallback below, the server **MUST** store the write with | Binds the server.
**Gap:** No public vector exercises the server, so nothing here catches one that rejects a TTL over the tenant's plan maximum, stores it unclamped, or keeps a stale window that carries the entry past the maximum. | n/a | n/a | n/a | -| [API-78](../spec/saas-api.md#api-78) | *Plan maximum TTL*: … whose TTL reaches the maximum stores no stale window. A `PATCH /v1/cache/{key}/ttl` on such a plan **MUST** be clamped the same way, with its `ttl` in place of `effective_ttl` and the entry's stored stale window in place of … | Binds the server.
**Gap:** As API-77, for a PATCH /ttl handler that stores the requested ttl past the plan maximum, or shortens the fresh window to make room for the stored stale window. | n/a | n/a | n/a | -| [API-79](../spec/saas-api.md#api-79) | *PUT /v1/cache/{key}*: … Fallback included — see No-expiry contract below. The plan maximum is the only default: servers **MUST NOT** apply any other, because the only default a writer cannot see must be one this spec publishes and the `2xx` reports … | Binds the server.
**Gap:** No public vector exercises the server, so nothing here catches one that stores a write without TTL headers under any default other than the tenant's plan maximum TTL. | n/a | n/a | n/a | -| [API-80](../spec/saas-api.md#api-80) | *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-81](../spec/saas-api.md#api-81) | *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-80, 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-82](../spec/saas-api.md#api-82) | *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-83](../spec/saas-api.md#api-83) | *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-84](../spec/saas-api.md#api-84) | *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-85](../spec/saas-api.md#api-85) | *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-86](../spec/saas-api.md#api-86) | *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-87](../spec/saas-api.md#api-87) | *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-97](../spec/saas-api.md#api-97) | *Plan maximum TTL*: … every write. Then, on a plan with a maximum TTL `max_ttl`, outside the fallback below, the server **MUST** store the write with | Binds the server.
**Gap:** No public vector exercises the server, so nothing here catches one that rejects a TTL over the tenant's plan maximum, stores it unclamped, or keeps a stale window that carries the entry past the maximum. | n/a | n/a | n/a | +| [API-98](../spec/saas-api.md#api-98) | *Plan maximum TTL*: … whose TTL reaches the maximum stores no stale window. A `PATCH /v1/cache/{key}/ttl` on such a plan **MUST** be clamped the same way, with its `ttl` in place of `effective_ttl` and the entry's stored stale window in place of … | Binds the server.
**Gap:** As API-97, for a PATCH /ttl handler that stores the requested ttl past the plan maximum, or shortens the fresh window to make room for the stored stale window. | n/a | n/a | n/a | +| [API-99](../spec/saas-api.md#api-99) | *PUT /v1/cache/{key}*: … Fallback included — see No-expiry contract below. The plan maximum is the only default: servers **MUST NOT** apply any other, because the only default a writer cannot see must be one this spec publishes and the `2xx` reports … | Binds the server.
**Gap:** No public vector exercises the server, so nothing here catches one that stores a write without TTL headers under any default other than the tenant's plan maximum TTL. | n/a | n/a | n/a | +| [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-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 | Marked not-a-requirement in `spec/saas-api.md`: diff --git a/conformance/requirements.json b/conformance/requirements.json index d0804cc..bd5a5f5 100644 --- a/conformance/requirements.json +++ b/conformance/requirements.json @@ -1521,7 +1521,7 @@ }, "spec/saas-api.md": { "prefix": "API", - "next": 88, + "next": 108, "requirements": { "API-1": { "section": "Authentication", @@ -1863,12 +1863,12 @@ "API-36": { "section": "Remaining Freshness", "binds": "sdk", - "gap": "As API-83, 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": "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." }, "API-37": { "section": "Remaining Freshness", "binds": "sdk", - "gap": "As API-83, for an SDK that serves such a copy past its bound through a local stale-grace policy." + "gap": "As API-103, for an SDK that serves such a copy past its bound through a local stale-grace policy." }, "API-38": { "section": "Remaining Freshness", @@ -2081,69 +2081,69 @@ "binds": "sdk", "gap": "No vector covers request headers, so nothing here catches a client that counts a wrapped call that runs the function as anything but one miss, a joined in-process caller as anything but one L1 hit, or a miss on each single-flight re-poll or lock re-check; only the SDK's own counter tests do." }, - "API-77": { + "API-97": { "section": "Plan maximum TTL", "binds": "server", "gap": "No public vector exercises the server, so nothing here catches one that rejects a TTL over the tenant's plan maximum, stores it unclamped, or keeps a stale window that carries the entry past the maximum." }, - "API-78": { + "API-98": { "section": "Plan maximum TTL", "binds": "server", - "gap": "As API-77, for a PATCH /ttl handler that stores the requested ttl past the plan maximum, or shortens the fresh window to make room for the stored stale window." + "gap": "As API-97, for a PATCH /ttl handler that stores the requested ttl past the plan maximum, or shortens the fresh window to make room for the stored stale window." }, - "API-79": { + "API-99": { "section": "PUT /v1/cache/{key}", "binds": "server", "gap": "No public vector exercises the server, so nothing here catches one that stores a write without TTL headers under any default other than the tenant's plan maximum TTL." }, - "API-80": { + "API-100": { "section": "Remaining Freshness", "binds": "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." }, - "API-81": { + "API-101": { "section": "Remaining Freshness", "binds": "server", - "gap": "As API-80, 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." + "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." }, - "API-82": { + "API-102": { "section": "Remaining Freshness", "binds": "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." }, - "API-83": { + "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." }, - "API-84": { + "API-104": { "section": "PATCH /v1/cache/{key}/ttl", "binds": "sdk", "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." }, - "API-85": { + "API-105": { "section": "Remaining Freshness", "binds": "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." }, - "API-86": { + "API-106": { "section": "Remaining Freshness", "binds": "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." }, - "API-87": { + "API-107": { "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." } }, "retired": { - "API-20": "Replaced by API-85: a tier's copy of a forwarded write takes its bound from the X-CacheKit-Fresh-For on the store's PUT 2xx when that carries one, and a malformed value is 0.", - "API-21": "Replaced by API-86: the decayed remainder it bounds now comes from API-85's bound source, capped at 2,592,000.", - "API-27": "Replaced by API-87: an accepted write qualifies for a positive value by its bound, which the store's PUT 2xx can set, not by its stated TTL.", - "API-35": "Replaced by API-83: the writer's copy is also bounded by the X-CacheKit-Fresh-For on the PUT 2xx.", - "API-40": "Merged with API-41 into API-79: the plan maximum TTL is the only default.", - "API-41": "Merged with API-40 into API-79: the plan maximum TTL is the only default." + "API-20": "Replaced by API-105: a tier's copy of a forwarded write takes its bound from the X-CacheKit-Fresh-For on the store's PUT 2xx when that carries one, and a malformed value is 0.", + "API-21": "Replaced by API-106: the decayed remainder it bounds now comes from API-105's bound source, capped at 2,592,000.", + "API-27": "Replaced by API-107: an accepted write qualifies for a positive value by its bound, which the store's PUT 2xx can set, not by its stated TTL.", + "API-35": "Replaced by API-103: the writer's copy is also bounded by the X-CacheKit-Fresh-For on the PUT 2xx.", + "API-40": "Merged with API-41 into API-99: the plan maximum TTL is the only default.", + "API-41": "Merged with API-40 into API-99: the plan maximum TTL is the only default." } }, "spec/wire-format.md": { diff --git a/sdk-feature-matrix.md b/sdk-feature-matrix.md index 97e61bd..2bf8a0c 100644 --- a/sdk-feature-matrix.md +++ b/sdk-feature-matrix.md @@ -186,7 +186,7 @@ The contract a storage backend must satisfy per SDK (bytes in / bytes out; seria | 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`) | ❌ | ❌ | ❌ | -| 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-83](spec/saas-api.md#api-83) | ⚠️ `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-83](spec/saas-api.md#api-83) | ⚠️ `@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-83](spec/saas-api.md#api-83) | ❌ | +| 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] > ¹³ **The Rust reliability tier ships in `cachekit-rs` 0.6.0+ and is on by default.** Verified inside the published artifact, not the branch: the `cachekit-rs` 0.6.0 `.crate` from crates.io (published 2026-08-03T14:58:16Z) contains `src/reliability.rs`, `src/flight.rs`, `tests/reliability_tests.rs`, and `get_with_swr` in `src/l1/mod.rs`, and its `Cargo.toml` declares `default = ["cachekitio", "encryption", "l1", "reliability"]`. So a plain `cargo add cachekit-rs` gets **circuit breaker, retry, backpressure and L1 SWR** with no feature flags. Two of the six cells need an opt-in feature: macro-level graceful degradation and the automatic `#[cachekit]` single-flight wiring are emitted by the proc-macro, and `macros = ["dep:cachekit-macros"]` is **not** in `default` — add `--features macros`. Redis-backed presets likewise need the non-default `redis` feature (see [Developer Experience](#developer-experience) note ¹¹). diff --git a/spec/saas-api.md b/spec/saas-api.md index 0412b61..1be76a9 100644 --- a/spec/saas-api.md +++ b/spec/saas-api.md @@ -159,13 +159,13 @@ X-CacheKit-L1-Status: miss - A **signal-capable server** is one that emits `X-CacheKit-Fresh-For` on `GET` as this section describes; the `PUT` emission below is a separate obligation. It is independent of [stale-while-revalidate](#stale-while-revalidate) support: a bounded entry has a `fresh_until` whether or not it has a stale window, so a server may emit `X-CacheKit-Fresh-For` without offering one, and a pre-signal SWR server emits `X-CacheKit-Freshness` without it. - Emitted on **every** `GET` `200 OK` for an entry that has a freshness bound. A **no-expiry** entry ([no-expiry contract](#put-v1cachekey)) has no `fresh_until`, no remainder to report, so the header is **omitted** — the server-side bound is unbounded, `min(local_ttl, ∞)` is the SDK's configured local TTL, and that is exactly the absent-header path below. When present, the value is a non-negative integer: `min(2592000, max(0, floor(fresh_until − now)))`, computed against the **server's clock** at response time — the client never compares server timestamps against its own clock. The `2592000` cap sits below the 90-day [TTL range](#put-v1cachekey), so it only ever shortens a local copy's bound. -- Emitted on a **write**, too. A store that applies a [plan maximum TTL](#plan-maximum-ttl) MUSTAPI-80 emit `X-CacheKit-Fresh-For` on every `PUT` `2xx` whose stored entry has a freshness bound, computed as above from the `fresh_until` it stamped, and omits it for a no-expiry entry; any other store may. That is how a writer learns the bound the store applied, which is shorter than the one it stated when the plan maximum clamped the write ([the writer's own copy](#remaining-freshness) below). A server MUST NOTAPI-81 clamp any write or `PATCH` until its store emits the header on `PUT`, every server-side hop that relays a `PUT` `2xx` from that store passes it through ([API-82](#api-82)), and every tier that keeps a copy of a forwarded write bounds that copy by it ([API-85](#api-85)). So a `PUT` `2xx` without the header means the store applied exactly the write's [effective TTL](#put-v1cachekey), or stored a no-expiry entry because the write carried none. +- Emitted on a **write**, too. A store that applies a [plan maximum TTL](#plan-maximum-ttl) MUSTAPI-100 emit `X-CacheKit-Fresh-For` on every `PUT` `2xx` whose stored entry has a freshness bound, computed as above from the `fresh_until` it stamped, and omits it for a no-expiry entry; any other store may. That is how a writer learns the bound the store applied, which is shorter than the one it stated when the plan maximum clamped the write ([the writer's own copy](#remaining-freshness) below). A server MUST NOTAPI-101 clamp any write or `PATCH` until its store emits the header on `PUT`, every server-side hop that relays a `PUT` `2xx` from that store passes it through ([API-102](#api-102)), and every tier that keeps a copy of a forwarded write bounds that copy by it ([API-105](#api-105)). So a `PUT` `2xx` without the header means the store applied exactly the write's [effective TTL](#put-v1cachekey), or stored a no-expiry entry because the write carried none. - Stale-window responses (`X-CacheKit-Freshness: stale`) carry `X-CacheKit-Fresh-For: 0` — freshness is already exhausted. `X-CacheKit-Freshness: fresh` with `X-CacheKit-Fresh-For: 0` is also legal — an entry in its final sub-second of freshness floors to `0`. The response is served to the caller normally; the `0` governs only local caching (no backfill). - Omitted on a `GET` by the store for no-expiry entries, and by pre-signal servers for everything. The client cannot tell the two apart, by design: both mean "no server-side freshness bound applies to this read" and both lead to the same SDK action (configured local TTL, fresh service only). Because absence carries that meaning, a tier that implements this section MUST NOTAPI-19 produce it for a copy it cannot positively confirm has no expiry — the tier rule below. - **Re-serving tiers** — an edge or colo cache in front of the store that answers from a copy it read earlier — are part of the server. A tier's **coherence window** is the deployment-documented maximum time it may keep serving a copy after the store has changed or deleted the entry; a tier that refreshes its copy's lifetime from the tier below rather than from the store adds its window to the path's total, so windows **compound** along the serving path and a deployment's effective window is their sum, not its largest single tier. That governs the *bytes*. The *header* follows stricter, fail-closed rules: - - **Decay, never re-stamp.** A tier MUSTAPI-85 emit its copy's remaining bound minus the seconds elapsed since that bound was obtained, flooring at `0`. A copy has exactly one bound source: the response it was read from (the bound is that response's `X-CacheKit-Fresh-For`), or — for a copy the tier populated from a write it forwarded — that write, once the store has accepted it. The bound of a forwarded write is the `X-CacheKit-Fresh-For` on the store's `PUT` `2xx` when it carries one, read with the [consumption](#remaining-freshness) grammar below (a malformed value is `0`, never a fallback to the TTL the write stated), otherwise the write's effective TTL (see [Migration](#put-v1cachekey)), and either is measured from when the tier dispatched the write. The store stamps `fresh_until` after that dispatch, from `floor(stored_at)` ([whole-second stamping](#entry-lifecycle)), and computes the header after stamping, so a bound from the header ends no later than `fresh_until`, and one from the effective TTL ends less than one second after it. It MUST NOTAPI-86 emit a value larger than that decayed remainder or than `2592000` ([API-32](#api-32)) — its own copy TTL included — under any label. A tier, or any other server-side hop, that relays the store's `PUT` `2xx` MUST NOTAPI-82 drop the `X-CacheKit-Fresh-For` of the store's `2xx` from the response it relays, nor relay a value larger than the store's. The deployed tiers decay, so the rule costs a conforming implementation nothing, and it keeps a pre-`DELETE` copy's local service within one second of the entry's `fresh_until`. + - **Decay, never re-stamp.** A tier MUSTAPI-105 emit its copy's remaining bound minus the seconds elapsed since that bound was obtained, flooring at `0`. A copy has exactly one bound source: the response it was read from (the bound is that response's `X-CacheKit-Fresh-For`), or — for a copy the tier populated from a write it forwarded — that write, once the store has accepted it. The bound of a forwarded write is the `X-CacheKit-Fresh-For` on the store's `PUT` `2xx` when it carries one, read with the [consumption](#remaining-freshness) grammar below (a malformed value is `0`, never a fallback to the TTL the write stated), otherwise the write's effective TTL (see [Migration](#put-v1cachekey)), and either is measured from when the tier dispatched the write. The store stamps `fresh_until` after that dispatch, from `floor(stored_at)` ([whole-second stamping](#entry-lifecycle)), and computes the header after stamping, so a bound from the header ends no later than `fresh_until`, and one from the effective TTL ends less than one second after it. It MUST NOTAPI-106 emit a value larger than that decayed remainder or than `2592000` ([API-32](#api-32)) — its own copy TTL included — under any label. A tier, or any other server-side hop, that relays the store's `PUT` `2xx` MUST NOTAPI-102 drop the `X-CacheKit-Fresh-For` of the store's `2xx` from the response it relays, nor relay a value larger than the store's. The deployed tiers decay, so the rule costs a conforming implementation nothing, and it keeps a pre-`DELETE` copy's local service within one second of the entry's `fresh_until`. - **Omit only on positive knowledge of no expiry; otherwise `0`.** A tier MAY omit the header only for a copy it positively knows has no expiry: the `GET` `200` the copy was read from came from a signal-capable store below and omitted it (such a store omits on `GET` only for no-expiry entries), or the tier populated the copy from an accepted write that carried no TTL at all — neither `X-CacheKit-TTL` nor legacy `X-TTL` — and whose `2xx` carried no `X-CacheKit-Fresh-For` (a write without a TTL that a [plan maximum TTL](#plan-maximum-ttl) bounded carries the header on its `2xx`; a `PUT` `2xx` without it never qualifies a copy on the first condition). A tier MUSTAPI-22 determine a write's TTL with the store's own precedence (`X-CacheKit-TTL`, else `X-TTL`); the absence of `X-CacheKit-TTL` alone proves nothing, because a legacy `X-TTL: 60` write is bounded. For every other copy whose remainder it does not know — populated without a hint, a header lost in transit, or a store below that is pre-signal and therefore omits for bounded entries too — the tier MUSTAPI-23 emit `X-CacheKit-Fresh-For: 0`. It MUST NOTAPI-24 omit a header it received, nor drop it for such a copy: omission would claim "no bound" for an entry that may have one and silently restore the unbounded backfill this header exists to kill. A tier MUSTAPI-25 therefore record at populate whether a copy is unbounded or merely unhinted; a tier that cannot tell the two apart MUSTAPI-26 emit `0`. A tier fronting a pre-signal store is in exactly that position — it emits `0`, it does not pass the absence through; the cost is no local backfill behind that tier until the store signals, the fail-closed posture a mixed deployment should have. Whether the store below signals is a deployment fact the tier is configured with, never inferred per response. - - **Positive only for `fresh` + positive.** A tier MAY emit a positive value only when its copy's bound source qualifies, and then only the decayed remainder: a response the tier below labelled exactly `X-CacheKit-Freshness: fresh` (or left unlabelled — the pre-SWR `fresh` default) and stamped with a strictly positive `X-CacheKit-Fresh-For`; or an accepted write whose bound (above) is positive, whose copy is labelled `fresh` (a just-accepted write is fresh by construction). Every other shape — `stale`, `X-CacheKit-Fresh-For: 0` under any label, an unrecognized freshness token, or a missing `X-CacheKit-Fresh-For` on any entry not positively known to be no-expiry — MUSTAPI-87 be emitted with `X-CacheKit-Fresh-For: 0` and its freshness label passed through unchanged (a `fresh` `0` stays `fresh`; a `stale` stays `stale`). Stamping a positive bound onto a stale, exhausted, or unknown value resurrects an expired (or revoked) entry as locally-cacheable fresh — the exact hole this header closes. + - **Positive only for `fresh` + positive.** A tier MAY emit a positive value only when its copy's bound source qualifies, and then only the decayed remainder: a response the tier below labelled exactly `X-CacheKit-Freshness: fresh` (or left unlabelled — the pre-SWR `fresh` default) and stamped with a strictly positive `X-CacheKit-Fresh-For`; or an accepted write whose bound (above) is positive, whose copy is labelled `fresh` (a just-accepted write is fresh by construction). Every other shape — `stale`, `X-CacheKit-Fresh-For: 0` under any label, an unrecognized freshness token, or a missing `X-CacheKit-Fresh-For` on any entry not positively known to be no-expiry — MUSTAPI-107 be emitted with `X-CacheKit-Fresh-For: 0` and its freshness label passed through unchanged (a `fresh` `0` stays `fresh`; a `stale` stays `stale`). Stamping a positive bound onto a stale, exhausted, or unknown value resurrects an expired (or revoked) entry as locally-cacheable fresh — the exact hole this header closes. - `HEAD` does **not** carry this header — an existence check returns no payload, so there is nothing to backfill locally (the `X-CacheKit-Freshness` label on `HEAD` remains informational, per [Stale-While-Revalidate](#stale-while-revalidate)). Correspondingly, a `HEAD` response MUST NOTAPI-28 create, refresh, or extend any local entry's service bound. **SDK (consumption):** @@ -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-83 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`. `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 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. @@ -218,7 +218,7 @@ X-CacheKit-L1-Status: miss > > | Condition | SDK Behavior | Server Behavior | > | :--- | :--- | :--- | -> | TTL omitted | Use client default TTL. If no client default, omit `X-CacheKit-TTL` header. | When the request carries neither `X-CacheKit-TTL` nor `X-TTL`: store as [Plan maximum TTL](#plan-maximum-ttl) says: with the tenant's plan maximum TTL, or with **no expiry** where none applies, its Fallback included — see **No-expiry contract** below. The plan maximum is the only default: servers MUST NOTAPI-79 apply any other, because the only default a writer cannot see must be one this spec publishes and the `2xx` reports ([Remaining Freshness](#remaining-freshness)). | +> | TTL omitted | Use client default TTL. If no client default, omit `X-CacheKit-TTL` header. | When the request carries neither `X-CacheKit-TTL` nor `X-TTL`: store as [Plan maximum TTL](#plan-maximum-ttl) says: with the tenant's plan maximum TTL, or with **no expiry** where none applies, its Fallback included — see **No-expiry contract** below. The plan maximum is the only default: servers MUST NOTAPI-99 apply any other, because the only default a writer cannot see must be one this spec publishes and the `2xx` reports ([Remaining Freshness](#remaining-freshness)). | > | TTL = 0 | **Reject** — return error to caller. Zero is not a valid TTL. | **Reject** — return `400 Bad Request`. | > | TTL < 1 second | **Round up to 1.** Sub-second durations MUSTAPI-42 be ceiled, never truncated to 0. | N/A (header is integer seconds). | > | TTL > 7,776,000 | **Reject** — return error to caller. | **Reject** — return `400 Bad Request`. | @@ -248,7 +248,7 @@ X-CacheKit-L1-Status: miss | Header | Description | | :--- | :--- | -| `X-CacheKit-Fresh-For` | On a `2xx`: the stored entry's remaining freshness at response time, `min(2592000, max(0, floor(fresh_until − now)))`. Required from a store that clamps ([API-80](#api-80)), optional otherwise; when absent, the stated TTL was applied or the entry has no expiry. Semantics: [Remaining Freshness](#remaining-freshness), including [the writer's own copy](#remaining-freshness). | +| `X-CacheKit-Fresh-For` | On a `2xx`: the stored entry's remaining freshness at response time, `min(2592000, max(0, floor(fresh_until − now)))`. Required from a store that clamps ([API-100](#api-100)), optional otherwise; when absent, the stated TTL was applied or the entry has no expiry. Semantics: [Remaining Freshness](#remaining-freshness), including [the writer's own copy](#remaining-freshness). | #### Plan maximum TTL @@ -261,16 +261,16 @@ Each plan has a maximum TTL: the longest a write or `PATCH` under it can set an | Professional | 7,776,000 seconds (90 days) | | Enterprise | None | -The maximum clamps a write; it never rejects one. The [TTL validation rules](#put-v1cachekey) and the [stale-window validation](#validation) run first and do not depend on the plan, so a client can pre-validate every write. Then, on a plan with a maximum TTL `max_ttl`, outside the fallback below, the server MUSTAPI-77 store the write with +The maximum clamps a write; it never rejects one. The [TTL validation rules](#put-v1cachekey) and the [stale-window validation](#validation) run first and do not depend on the plan, so a client can pre-validate every write. Then, on a plan with a maximum TTL `max_ttl`, outside the fallback below, the server MUSTAPI-97 store the write with ``` applied_ttl = min(effective_ttl, max_ttl), or max_ttl for a write without a TTL applied_stale_ttl = min(stale_ttl, max_ttl − applied_ttl) ``` -so that `evict_at − floor(stored_at) ≤ max_ttl`. The fresh window is kept first: the stale window gets only what the maximum leaves, so a write whose TTL reaches the maximum stores no stale window. A [`PATCH /v1/cache/{key}/ttl`](#patch-v1cachekeyttl) on such a plan MUSTAPI-78 be clamped the same way, with its `ttl` in place of `effective_ttl` and the entry's stored stale window in place of `stale_ttl`, so that `evict_at − floor(now) ≤ max_ttl`. On a plan without a maximum TTL, writes and `PATCH`es are stored as stated, and a write without a TTL stores a no-expiry entry. +so that `evict_at − floor(stored_at) ≤ max_ttl`. The fresh window is kept first: the stale window gets only what the maximum leaves, so a write whose TTL reaches the maximum stores no stale window. A [`PATCH /v1/cache/{key}/ttl`](#patch-v1cachekeyttl) on such a plan MUSTAPI-98 be clamped the same way, with its `ttl` in place of `effective_ttl` and the entry's stored stale window in place of `stale_ttl`, so that `evict_at − floor(now) ≤ max_ttl`. On a plan without a maximum TTL, writes and `PATCH`es are stored as stated, and a write without a TTL stores a no-expiry entry. -**Fallback.** A server that cannot determine the tenant's plan for a write or `PATCH`, or whose deployment does not yet meet the conditions [API-81](#api-81) sets for clamping (the store emits the header on `PUT`, every server-side hop that relays a `PUT` `2xx` passes it through, every tier bounds its copy of a forwarded write by it), stores it as stated, as on a plan without a maximum; API-77 and API-78 apply only once neither holds. A `PUT` `2xx` without `X-CacheKit-Fresh-For` then means the stated TTL was applied ([API-81](#api-81)), so a writer's copy is bounded as for any unclamped write. The plan maximum is then not a guaranteed ceiling on that entry's lifetime. +**Fallback.** A server that cannot determine the tenant's plan for a write or `PATCH`, or whose deployment does not yet meet the conditions [API-101](#api-101) sets for clamping (the store emits the header on `PUT`, every server-side hop that relays a `PUT` `2xx` passes it through, every tier bounds its copy of a forwarded write by it), stores it as stated, as on a plan without a maximum; API-97 and API-98 apply only once neither holds. A `PUT` `2xx` without `X-CacheKit-Fresh-For` then means the stated TTL was applied ([API-101](#api-101)), so a writer's copy is bounded as for any unclamped write. The plan maximum is then not a guaranteed ceiling on that entry's lifetime. The maximum applies when a write or `PATCH` is stamped. An entry keeps the timing it was stored with when the tenant's plan changes later, or once the fallback stops applying, a no-expiry entry included, until its next write or `PATCH`. @@ -364,7 +364,7 @@ These rules are normative for both SDKs and the SaaS backend, mirroring the [TTL ### Write semantics - **`PUT` fully replaces the entry's timing metadata.** Both windows derive from the new request alone; a `PUT` that omits `X-CacheKit-Stale-TTL` (or sends `0`) leaves the entry with **no** stale window, regardless of what the previous entry had. A revalidation `PUT` therefore MUSTAPI-55 re-send both the `X-CacheKit-TTL` and the stale window it intends to keep — the previous bound is never carried forward, so a recompute that carries neither `X-CacheKit-TTL` nor legacy `X-TTL` stores a **no-expiry** entry, or one bounded by the [plan maximum TTL](#plan-maximum-ttl) on a plan with one ([PUT](#put-v1cachekey)). -- **`PATCH /v1/cache/{key}/ttl` within the fresh window** resets `fresh_until = floor(now) + ttl` ([whole-second stamping](#entry-lifecycle)) and preserves the entry's stored stale window; the combined total is re-validated against the 90-day TTL range, then clamped ([API-78](#api-78)), which can shorten the stale window. +- **`PATCH /v1/cache/{key}/ttl` within the fresh window** resets `fresh_until = floor(now) + ttl` ([whole-second stamping](#entry-lifecycle)) and preserves the entry's stored stale window; the combined total is re-validated against the 90-day TTL range, then clamped ([API-98](#api-98)), which can shorten the stale window. - **`PATCH /v1/cache/{key}/ttl` on an entry past `fresh_until` MUSTAPI-56 return `409 Conflict`.** A stale entry regains freshness only via a `PUT` of recomputed bytes — otherwise a routine TTL-renewal job could indefinitely resurrect stale data without revalidation, defeating the `evict_at` bound. ### Reading a stale entry @@ -513,7 +513,7 @@ Content-Type: application/json {"ttl": 7200} ``` -The `ttl` field follows the same validation rules as `X-CacheKit-TTL`: positive integer, minimum 1, maximum 7,776,000. On a plan with a maximum TTL the store clamps it, and the stored stale window, as [Plan maximum TTL](#plan-maximum-ttl) says ([API-78](#api-78), outside its Fallback). The `200` does not report the applied value (`GET /v1/cache/{key}/ttl` reports the remaining time to `evict_at`, stale window included), so a `PATCH` response MUST NOTAPI-84 create, refresh or extend any local entry's service bound. For entries stored with a stale-grace window, see [SWR write semantics](#write-semantics): a PATCH within the fresh window renews it; a PATCH on a stale entry MUSTAPI-73 return `409 Conflict`. A `PATCH` on a no-expiry entry is within the fresh window by definition and bounds it: `fresh_until = floor(now) + ttl` ([whole-second stamping](#entry-lifecycle)), no stale window. +The `ttl` field follows the same validation rules as `X-CacheKit-TTL`: positive integer, minimum 1, maximum 7,776,000. On a plan with a maximum TTL the store clamps it, and the stored stale window, as [Plan maximum TTL](#plan-maximum-ttl) says ([API-98](#api-98), outside its Fallback). The `200` does not report the applied value (`GET /v1/cache/{key}/ttl` reports the remaining time to `evict_at`, stale window included), so a `PATCH` response MUST NOTAPI-104 create, refresh or extend any local entry's service bound. For entries stored with a stale-grace window, see [SWR write semantics](#write-semantics): a PATCH within the fresh window renews it; a PATCH on a stale entry MUSTAPI-73 return `409 Conflict`. A `PATCH` on a no-expiry entry is within the fresh window by definition and bounds it: `fresh_until = floor(now) + ttl` ([whole-second stamping](#entry-lifecycle)), no stale window. | Status | Meaning | | :---: | :--- |