diff --git a/CONTEXT.md b/CONTEXT.md index 2a39101..c27c8f8 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. _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 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**: +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 new file mode 100644 index 0000000..cdff342 --- /dev/null +++ b/changelog.d/20261031_plan-maximum-ttl.md @@ -0,0 +1,41 @@ +### 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-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-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-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-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-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-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-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 + 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. +- **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 5abf115..ab7b88c 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) | 96 | 0 | 15 | 0 | 81 | +| [`spec/saas-api.md`](../spec/saas-api.md) | 101 | 0 | 15 | 0 | 86 | | [`spec/wire-format.md`](../spec/wire-format.md) | 24 | 3 | 18 | 3 | 0 | | SDK | Covered | Partial | Uncovered | Gap | n/a | | :--- | ---: | ---: | ---: | ---: | ---: | -| cachekit-py | 12 | 86 | 5 | 83 | 53 | -| cachekit-ts | 9 | 78 | 7 | 73 | 72 | -| cachekit-rs | 4 | 69 | 3 | 69 | 94 | +| cachekit-py | 12 | 86 | 5 | 84 | 57 | +| cachekit-ts | 9 | 78 | 7 | 74 | 76 | +| cachekit-rs | 4 | 69 | 3 | 70 | 98 | Not indexed yet: [`spec/interop-v2.md`](../spec/interop-v2.md). @@ -217,28 +217,22 @@ 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-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 | | [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` (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-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-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-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 | @@ -294,6 +288,17 @@ Marked not-a-requirement in `spec/intent-presets.md`: | [API-94](../spec/saas-api.md#api-94) | *Telemetry switch*: Every SDK **MUST** let its user turn telemetry off through both of these controls, which mean the same in every SDK: | **Gap:** No vector reaches an SDK's configuration, so nothing here catches a client that lacks the telemetry setting, defaults it to true or false, or ignores CACHEKIT_TELEMETRY. | gap | gap | gap | | [API-95](../spec/saas-api.md#api-95) | *Telemetry switch*: An SDK **MUST** read both controls once, when it constructs the backend, and resolve them exactly as below, for that backend … | **Gap:** As API-94, for a client that lets a configuration loader fill the setting from the environment, lets on win over off, reads an unrecognised value as on, or re-reads the controls after construction. | gap | gap | gap | | [API-96](../spec/saas-api.md#api-96) | *Telemetry switch*: With telemetry off, an SDK **MUST NOT** send any optional metrics header on that backend's requests: `X-CacheKit-Session-ID`, `X-CacheKit-Session-Start`, … | **Gap:** No vector covers request headers, so nothing here catches a client that, with telemetry off, still sends a session or latency header. | gap | gap | gap | +| [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 e9e7199..b7b3520 100644 --- a/conformance/requirements.json +++ b/conformance/requirements.json @@ -1521,7 +1521,7 @@ }, "spec/saas-api.md": { "prefix": "API", - "next": 97, + "next": 108, "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." - }, - "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", @@ -1875,20 +1860,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-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-35, 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", @@ -1900,16 +1880,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", @@ -2210,9 +2180,71 @@ "section": "Server handling", "binds": "server", "gap": "No public vector exercises the server, so nothing here catches one that records a latency set with a missing or malformed header, a sum of 0, a One-In outside 1 to 1,000,000, or a sum its buckets cannot produce." + }, + "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-98": { + "section": "Plan maximum TTL", + "binds": "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." + }, + "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-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-101": { + "section": "Remaining Freshness", + "binds": "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." + }, + "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-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-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-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-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-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": {} + "retired": { + "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": { "prefix": "WIRE", diff --git a/sdk-feature-matrix.md b/sdk-feature-matrix.md index 14c7bd2..d0f9995 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, 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 ¹¹). @@ -301,7 +301,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 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 c8175cf..2890b64 100644 --- a/spec/saas-api.md +++ b/spec/saas-api.md @@ -157,14 +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. 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. +- 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-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 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-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-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-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):** @@ -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` (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.** 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 `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`, 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`, 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. --- @@ -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 **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] @@ -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 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 > 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 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`. | > -> **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.** 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). -> - **`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 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 — 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**. 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,36 @@ 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 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 + +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 | +| :--- | :--- | +| 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`, 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-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-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`. + --- ### DELETE /v1/cache/{key} @@ -300,6 +332,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 +346,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, 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) | ### 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 ([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 @@ -384,7 +419,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. --- @@ -478,12 +513,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 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 | | :---: | :--- | | `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, 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 | --- @@ -653,7 +688,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)) | diff --git a/tools/freshness-headers-verify.py b/tools/freshness-headers-verify.py index 9ebbcfe..b38d441 100644 --- a/tools/freshness-headers-verify.py +++ b/tools/freshness-headers-verify.py @@ -26,7 +26,8 @@ ROOT = Path(__file__).resolve().parents[1] VECTORS = ROOT / "test-vectors" / "freshness-headers.json" -# The 30-day TTL cap. It has seven digits, so no valid X-CacheKit-Fresh-For is longer. +# The header's own cap, 30 days: lower than the 90-day TTL range, and not to be synced to it. It has seven +# digits, so no valid X-CacheKit-Fresh-For is longer. FRESH_FOR_MAX = 2_592_000 FRESH_FOR_MAX_DIGITS = 7 # Each table, and the field that holds its rows' result.