diff --git a/changelog.d/20261005_lab-8254.md b/changelog.d/20261005_lab-8254.md
new file mode 100644
index 0000000..9404424
--- /dev/null
+++ b/changelog.d/20261005_lab-8254.md
@@ -0,0 +1,16 @@
+### Feature matrix and conformance — cachekit-py vendors `path-encoding.json` 1.2.0 and `freshness-headers.json` 1.0.0 (LAB-8254)
+
+- `conformance/sdks.json` reads cachekit-py at `ce427a2`, the merge of
+ [cachekit-py#522](https://github.com/cachekit-io/cachekit-py/pull/522), and records its
+ byte-identical copies of `path-encoding.json` 1.2.0 and `freshness-headers.json` 1.0.0.
+ `conformance/coverage.md` is regenerated: API-4 to API-7, API-17, API-18, API-30, API-33,
+ API-34 and API-57 now read `partial (gap)` for cachekit-py, which means its copies hold
+ every vector those ids map to and only the recorded gap remains.
+- The **Test vectors in CI** row's cachekit-py cell lists both fixtures, vendored byte for
+ byte (sha256-pinned) and CI-executed in cachekit-py#522, unreleased. Note ¹⁶ now says
+ all three SDKs vendor this repo's current `path-encoding.json` and `freshness-headers.json`.
+- The same cell dated `interop-mode.json` 1.2.0
+ ([cachekit-py#391](https://github.com/cachekit-io/cachekit-py/pull/391)) and
+ `path-encoding.json` 1.1.0
+ ([cachekit-py#397](https://github.com/cachekit-io/cachekit-py/pull/397)) as unreleased. Both
+ have shipped since PyPI 0.21.0, and the cell now says so.
diff --git a/conformance/coverage.md b/conformance/coverage.md
index 40327fa..4e63364 100644
--- a/conformance/coverage.md
+++ b/conformance/coverage.md
@@ -12,7 +12,7 @@ How ids are assigned, and what each status means: [README.md](README.md#coverage
| SDK | Read at | Vendored fixtures |
| :--- | :--- | :--- |
-| cachekit-py | [`0359979`](https://github.com/cachekit-io/cachekit-py/commit/0359979051889d50de3ef8dd5759829030310230) | `cache-keys.json` 1.1.0, `decode-bounds.json` 1.1.0, `file-backend.json` 1.1.0, `interop-mode.json` 1.2.0, `path-encoding.json` 1.1.0 (not the current revision), `wire-format.json` 1.3.0 |
+| cachekit-py | [`ce427a2`](https://github.com/cachekit-io/cachekit-py/commit/ce427a2450bdb83a07e30d5b32449c556be1ff00) | `cache-keys.json` 1.1.0, `decode-bounds.json` 1.1.0, `file-backend.json` 1.1.0, `freshness-headers.json` 1.0.0, `interop-mode.json` 1.2.0, `path-encoding.json` 1.2.0, `wire-format.json` 1.3.0 |
| cachekit-ts | [`9694eee`](https://github.com/cachekit-io/cachekit-ts/commit/9694eeedf99ea21598336d22457bde8c992247d4) | `decode-bounds.json` 1.1.0, `encryption.json` 1.2.0 (not the current revision), `file-backend.json` 1.1.0, `freshness-headers.json` 1.0.0, `interop-mode.json` 1.2.0, `path-encoding.json` 1.2.0, `wire-format.json` 1.3.0 |
| cachekit-rs | [`91da095`](https://github.com/cachekit-io/cachekit-rs/commit/91da0954c7c0b051f35b4e55657221fe9dfa29b3) | `decode-bounds.json` 1.1.0, `file-backend.json` 1.1.0, `freshness-headers.json` 1.0.0, `interop-mode.json` 1.2.0, `path-encoding.json` 1.2.0 |
@@ -30,7 +30,7 @@ How ids are assigned, and what each status means: [README.md](README.md#coverage
| SDK | Covered | Partial | Uncovered | Gap | n/a |
| :--- | ---: | ---: | ---: | ---: | ---: |
-| cachekit-py | 5 | 68 | 21 | 76 | 47 |
+| cachekit-py | 5 | 73 | 16 | 76 | 47 |
| cachekit-ts | 5 | 72 | 9 | 60 | 71 |
| cachekit-rs | 3 | 56 | 10 | 60 | 88 |
@@ -201,10 +201,10 @@ Marked not-a-requirement in `spec/intent-presets.md`:
| [API-1](../spec/saas-api.md#api-1) | *Authentication*: **MUST** send `X-CacheKit-L1-Status` on every request (Required Headers) — rejected with `400` otherwise. May mutate … | **Gap:** No vector covers request headers, so nothing here catches a client that, with a ck_sdk_ key, sends X-CacheKit-L1-Status on GET and PUT but not on HEAD, DELETE, /ttl, /lock or health requests; the server's 400 shows the omission only on a live call. | gap | gap | gap |
| [API-2](../spec/saas-api.md#api-2) | *Authentication*: HTTP intermediary caching is prohibited. Servers **MUST** emit `Cache-Control: no-store` and `Vary: Authorization` on every response. The cache key carries no tenant component … | Binds the server.
**Gap:** No public vector exercises the server's responses, so nothing here catches a server that omits either header on one path, such as an error raised before the cache handler, the OPTIONS preflight or the health endpoint. | n/a | n/a | n/a |
| [API-3](../spec/saas-api.md#api-3) | *Authentication*: … copy to tenant B's request. Any CacheKit-operated serving tier that caches responses (edge, colo) **MUST** partition its internal cache by tenant, never by URL alone; such tiers are part of the server, not HTTP … | Binds the server.
**Gap:** A tier's internal cache key is invisible to any public vector; only a test that sends two tenants' requests for one path through the tier catches a tier keyed by URL alone. | n/a | n/a | n/a |
-| [API-4](../spec/saas-api.md#api-4) | *Encoding rules*: 1. One segment, percent-encoded. `{key}` **MUST** be exactly one path segment. Clients MUST percent-encode the key's UTF-8 bytes (RFC 3986 §2.1) so that only unreserved … | `path-encoding.json`: `default:../../admin`, `x/../../health`, `k?x=1#f`, `a\..\health`, `".\t."`, `".\r\n."`, `".. "`, `"health\u000b"`, `%2e%2e`, `‥`
`tools/path-encoding-verify.py:verify`
**Gap:** The rows pin the encoder, not the request path (API-15). | partial (3/10) | partial (gap) | partial (gap) |
-| [API-5](../spec/saas-api.md#api-5) | *Encoding rules*: 1. One segment, percent-encoded. `{key}` MUST be exactly one path segment. Clients **MUST** percent-encode the key's UTF-8 bytes (RFC 3986 §2.1) so that only unreserved characters — `ALPHA / DIGIT / "-" / "." / … | `path-encoding.json`: `ns:test:func:__main__.get_user:args:3870b2ea5735ae639ded9450ef117768db676f037bec636503796c5b81095153:1s`, `default:../../admin`, `x/../../health`, `k?x=1#f`, `a b`, `100%`, `a:..`, `ns:key`, `f(x)!*'`, `a\..\health`, `".\t."`, `".\r\n."`, `".. "`, `"health\u000b"`, `"a\u0000b\u001fc\u007fd"`, `%2e%2e`, `User%3AKey`, `café`, `😀`, `‥`, `User:Key`, `a+b&c;d,e@f$g[h]i`, ``a"bd^e`f{g\|h}i``
`tools/path-encoding-verify.py:verify`
**Gap:** The rows pin the encoder, not the request path (API-15), and a test that reads only the parsed path cannot see a raw byte the URL parser encodes itself (API-7). | partial (9/23) | partial (gap) | partial (gap) |
-| [API-6](../spec/saas-api.md#api-6) | *Encoding rules*: … so that only unreserved characters — `ALPHA / DIGIT / "-" / "." / "_" / "~"` — appear raw, and **MUST NOT** percent-encode an unreserved character (RFC 3986 §2.3): `.` is sent as `.`, never `%2E`. Every other byte MUST be sent … | `path-encoding.json`: `ns:test:func:__main__.get_user:args:3870b2ea5735ae639ded9450ef117768db676f037bec636503796c5b81095153:1s`, `default:../../admin`, `x/../../health`, `a:..`, `..a`, `a-b~c`, `User:Key`
`tools/path-encoding-verify.py:verify`
**Gap:** The rows pin the encoder, not the request path (API-15). | partial (5/7) | partial (gap) | partial (gap) |
-| [API-7](../spec/saas-api.md#api-7) | *Encoding rules*: … an unreserved character (RFC 3986 §2.3): `.` is sent as `.`, never `%2E`. Every other byte **MUST** be sent as `%HH` — the delimiters `/ ? # %`, `:` (a canonical key carries six), space (`%20`, never `+`), every byte ≥ … | `path-encoding.json`: `ns:test:func:__main__.get_user:args:3870b2ea5735ae639ded9450ef117768db676f037bec636503796c5b81095153:1s`, `default:../../admin`, `x/../../health`, `k?x=1#f`, `a b`, `100%`, `a:..`, `ns:key`, `f(x)!*'`, `a\..\health`, `".\t."`, `".\r\n."`, `".. "`, `"health\u000b"`, `"a\u0000b\u001fc\u007fd"`, `%2e%2e`, `User%3AKey`, `café`, `😀`, `‥`, `a+b&c;d,e@f$g[h]i`, ``a"bd^e`f{g\|h}i``
`tools/path-encoding-verify.py:verify`
**Gap:** The rows pin the encoder, not the request path (API-15), and a test that reads only the parsed path cannot tell a raw byte the URL parser encodes itself (a space or a C0 control other than tab, LF and CR inside the key, DEL, a byte of 0x80 or above, `"<>^{}` or the backtick) from the encoder's `%HH`, though the parser then sends the same bytes. | partial (9/22) | partial (gap) | partial (gap) |
+| [API-4](../spec/saas-api.md#api-4) | *Encoding rules*: 1. One segment, percent-encoded. `{key}` **MUST** be exactly one path segment. Clients MUST percent-encode the key's UTF-8 bytes (RFC 3986 §2.1) so that only unreserved … | `path-encoding.json`: `default:../../admin`, `x/../../health`, `k?x=1#f`, `a\..\health`, `".\t."`, `".\r\n."`, `".. "`, `"health\u000b"`, `%2e%2e`, `‥`
`tools/path-encoding-verify.py:verify`
**Gap:** The rows pin the encoder, not the request path (API-15). | partial (gap) | partial (gap) | partial (gap) |
+| [API-5](../spec/saas-api.md#api-5) | *Encoding rules*: 1. One segment, percent-encoded. `{key}` MUST be exactly one path segment. Clients **MUST** percent-encode the key's UTF-8 bytes (RFC 3986 §2.1) so that only unreserved characters — `ALPHA / DIGIT / "-" / "." / … | `path-encoding.json`: `ns:test:func:__main__.get_user:args:3870b2ea5735ae639ded9450ef117768db676f037bec636503796c5b81095153:1s`, `default:../../admin`, `x/../../health`, `k?x=1#f`, `a b`, `100%`, `a:..`, `ns:key`, `f(x)!*'`, `a\..\health`, `".\t."`, `".\r\n."`, `".. "`, `"health\u000b"`, `"a\u0000b\u001fc\u007fd"`, `%2e%2e`, `User%3AKey`, `café`, `😀`, `‥`, `User:Key`, `a+b&c;d,e@f$g[h]i`, ``a"bd^e`f{g\|h}i``
`tools/path-encoding-verify.py:verify`
**Gap:** The rows pin the encoder, not the request path (API-15), and a test that reads only the parsed path cannot see a raw byte the URL parser encodes itself (API-7). | partial (gap) | partial (gap) | partial (gap) |
+| [API-6](../spec/saas-api.md#api-6) | *Encoding rules*: … so that only unreserved characters — `ALPHA / DIGIT / "-" / "." / "_" / "~"` — appear raw, and **MUST NOT** percent-encode an unreserved character (RFC 3986 §2.3): `.` is sent as `.`, never `%2E`. Every other byte MUST be sent … | `path-encoding.json`: `ns:test:func:__main__.get_user:args:3870b2ea5735ae639ded9450ef117768db676f037bec636503796c5b81095153:1s`, `default:../../admin`, `x/../../health`, `a:..`, `..a`, `a-b~c`, `User:Key`
`tools/path-encoding-verify.py:verify`
**Gap:** The rows pin the encoder, not the request path (API-15). | partial (gap) | partial (gap) | partial (gap) |
+| [API-7](../spec/saas-api.md#api-7) | *Encoding rules*: … an unreserved character (RFC 3986 §2.3): `.` is sent as `.`, never `%2E`. Every other byte **MUST** be sent as `%HH` — the delimiters `/ ? # %`, `:` (a canonical key carries six), space (`%20`, never `+`), every byte ≥ … | `path-encoding.json`: `ns:test:func:__main__.get_user:args:3870b2ea5735ae639ded9450ef117768db676f037bec636503796c5b81095153:1s`, `default:../../admin`, `x/../../health`, `k?x=1#f`, `a b`, `100%`, `a:..`, `ns:key`, `f(x)!*'`, `a\..\health`, `".\t."`, `".\r\n."`, `".. "`, `"health\u000b"`, `"a\u0000b\u001fc\u007fd"`, `%2e%2e`, `User%3AKey`, `café`, `😀`, `‥`, `a+b&c;d,e@f$g[h]i`, ``a"bd^e`f{g\|h}i``
`tools/path-encoding-verify.py:verify`
**Gap:** The rows pin the encoder, not the request path (API-15), and a test that reads only the parsed path cannot tell a raw byte the URL parser encodes itself (a space or a C0 control other than tab, LF and CR inside the key, DEL, a byte of 0x80 or above, `"<>^{}` or the backtick) from the encoder's `%HH`, though the parser then sends the same bytes. | partial (gap) | partial (gap) | partial (gap) |
| [API-8](../spec/saas-api.md#api-8) | *Encoding rules*: 2. Reserved segments **MUST** be rejected client-side. A key of exactly `.` or `..` survives rule 1 unchanged (`.` is unreserved) and is a *dot … | `path-encoding.json`: the empty key, `.`, `..`, `health`, `ttl`, `lock`
**Gap:** The rows reach whichever function an SDK's test calls: a client that guards the key where it builds one endpoint's URL but builds its /ttl or /lock URLs elsewhere passes them, and so does one that raises only after a request is built, unless the test asserts the raise comes first (API-14). | partial (gap) | partial (gap) | partial (gap) |
| [API-9](../spec/saas-api.md#api-9) | *Encoding rules*: Therefore clients **MUST** reject a key that is empty or exactly `.`, `..`, `health`, `ttl` or `lock` before building the URL, surfacing a … | `path-encoding.json`: the empty key, `.`, `..`, `health`, `ttl`, `lock`
**Gap:** As API-8: the rows reach only the entry points an SDK's test drives, and only the test discipline of API-14 shows the error comes before a URL is built. | partial (gap) | partial (gap) | partial (gap) |
| [API-10](../spec/saas-api.md#api-10) | *Encoding rules*: … `..`, `health`, `ttl` or `lock` before building the URL, surfacing a client-side error; servers **MUST NOT** be relied on to compensate. Under rule 1 each of these keys encodes to itself, so checking the key and checking its … | `path-encoding.json`: the empty key, `.`, `..`, `health`, `ttl`, `lock`
**Gap:** An SDK must not leave the check to the server, which cannot make it: a dot key never reaches the server's validator, a route-token key addresses another route or key, and an empty key addresses no entry. So the rows fail a client that relies on the server, but, as on API-8, only on the entry points its test drives. | partial (gap) | partial (gap) | partial (gap) |
@@ -214,8 +214,8 @@ Marked not-a-requirement in `spec/intent-presets.md`:
| [API-14](../spec/saas-api.md#api-14) | *Encoding rules*: Conformance tests **MUST** assert that every `reject: true` vector raises before a URL is built. They MUST assert every transmittable vector on … | **Gap:** Whether an SDK's test asserts that each row raises before any URL or request is built, on every entry point, is SDK test code no vector can check: a suite that asserts something weaker passes every reject row. | gap | gap | gap |
| [API-15](../spec/saas-api.md#api-15) | *Encoding rules*: Conformance tests MUST assert that every `reject: true` vector raises before a URL is built. They **MUST** assert every transmittable vector on the *parsed* request path (`new URL(u).pathname`, `Url::parse(u)?.path()`, … | **Gap:** Whether an SDK's test reads the parsed path of the request it actually builds, rather than the encoder's return value or a template string, is SDK test code no vector can check; until it does, the encoding rules' rows pin the encoder alone. | gap | gap | gap |
| [API-16](../spec/saas-api.md#api-16) | *Encoding rules*: … write-space or namespace-grant violation is `403 Forbidden` (Authentication). Consequences clients **MUST** honour: | `path-encoding.json`: `default:../../admin`, `x/../../health`
`tools/path-encoding-verify.py:verify`
**Gap:** The lead-in's one item without an id of its own, a '/' in a key reaching the server as %2F, is pinned on the encoder, not the request path (API-15); API-17 owns the other item. | partial (gap) | partial (gap) | partial (gap) |
-| [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 (2/4) | 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. | uncovered (0/9) | partial (gap) | partial (gap) |
+| [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 |
@@ -227,11 +227,11 @@ Marked not-a-requirement in `spec/intent-presets.md`:
| [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). | uncovered (0/1) | partial (gap) | partial (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. | uncovered (0/17) | 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). | uncovered (0/4) | partial (gap) | partial (gap) |
+| [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-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 |
@@ -254,7 +254,7 @@ Marked not-a-requirement in `spec/intent-presets.md`:
| [API-54](../spec/saas-api.md#api-54) | *Validation*: SDKs **MUST** ceil to 1, never truncate to 0 (same rule as TTL) | Binds cachekit-py only.
**Gap:** As API-42, for X-CacheKit-Stale-TTL. | gap | n/a | n/a |
| [API-55](../spec/saas-api.md#api-55) | *Write semantics*: … with no stale window, regardless of what the previous entry had. A revalidation `PUT` therefore **MUST** re-send both the `X-CacheKit-TTL` and the stale window it intends to keep — the previous bound is never carried … | Binds cachekit-py only.
**Gap:** No vector covers request headers, so nothing here catches a revalidation write that omits X-CacheKit-TTL, which stores a no-expiry entry (or draws a 400 if it still sends X-CacheKit-Stale-TTL), or omits X-CacheKit-Stale-TTL, which leaves no stale window. | gap | n/a | n/a |
| [API-56](../spec/saas-api.md#api-56) | *Write semantics*: `PATCH /v1/cache/{key}/ttl` on an entry past `fresh_until` **MUST** return `409 Conflict`. A stale entry regains freshness only via a `PUT` of recomputed bytes — otherwise a routine … | Binds the server.
**Gap:** No public vector exercises the server, so nothing here catches a PATCH /ttl handler that renews an entry past fresh_until instead of answering 409. | n/a | n/a | n/a |
-| [API-57](../spec/saas-api.md#api-57) | *Reading a stale entry*: An SDK **MUST NOT** treat the response as a protocol error. | `freshness-headers.json`: `freshness_stale`
`tools/freshness-headers-verify.py:verify`
**Gap:** The row reaches the parser an SDK's test hands the value, not its read path: a parser that raises on `stale` fails it, but nothing here catches a read path that classifies the GET 200 as stale and then raises instead of serving it or taking its local miss policy. | uncovered (0/1) | partial (gap) | partial (gap) |
+| [API-57](../spec/saas-api.md#api-57) | *Reading a stale entry*: An SDK **MUST NOT** treat the response as a protocol error. | `freshness-headers.json`: `freshness_stale`
`tools/freshness-headers-verify.py:verify`
**Gap:** The row reaches the parser an SDK's test hands the value, not its read path: a parser that raises on `stale` fails it, but nothing here catches a read path that classifies the GET 200 as stale and then raises instead of serving it or taking its local miss policy. | partial (gap) | partial (gap) | partial (gap) |
| [API-58](../spec/saas-api.md#api-58) | *Reading a stale entry*: Local caches (L1) **MUST NOT** backfill a stale-flagged response at all — not as fresh, not as locally-stale — regardless of any … | **Gap:** No vector reaches an SDK's local cache, so nothing here catches one that backfills a stale response, as fresh or as locally stale. | gap | gap | gap |
| [API-59](../spec/saas-api.md#api-59) | *Reading a stale entry*: … once under Remaining Freshness). On a response carrying `X-CacheKit-Fresh-For`, local caching **MUST NOT** extend service of an entry past the store's `evict_at` — for *fresh*-labelled reads near the freshness boundary, the … | **Gap:** As API-58, for an SDK whose local copy outlives the store's evict_at because it ignores X-CacheKit-Fresh-For. | gap | gap | gap |
| [API-60](../spec/saas-api.md#api-60) | *Reading a stale entry*: Revalidation is triggered only by `GET`. `HEAD` freshness is informational; an existence check **MUST NOT** fire a background recompute. | **Gap:** No vector drives a background recompute, so nothing here catches a client that starts one from a HEAD labelled stale. Unlike API-61 to API-63, the rule binds every SDK: a local refresh-ahead is a background recompute too. | gap | gap | gap |
diff --git a/conformance/sdks.json b/conformance/sdks.json
index 304ffbd..f5b1ec2 100644
--- a/conformance/sdks.json
+++ b/conformance/sdks.json
@@ -1,13 +1,14 @@
{
"cachekit-py": {
"repository": "https://github.com/cachekit-io/cachekit-py",
- "commit": "0359979051889d50de3ef8dd5759829030310230",
+ "commit": "ce427a2450bdb83a07e30d5b32449c556be1ff00",
"fixtures": {
"cache-keys.json": "e4c4174217757686069224a6f4553a1af7816191f689a865498f1cb4969856bb",
"decode-bounds.json": "907b025d2b270a0f60abd9296a8a1c864e69057c553ac7a70206b44256558916",
"file-backend.json": "8d9d8c4709baf9ef3a8f2d71d21fb5a56207bc7a05c9bc7a967ac567f2604615",
+ "freshness-headers.json": "74beb975b6855f52d9dd453279873faf8048a9c3c79a8167cfda099beec5fcd3",
"interop-mode.json": "702613766d1b92bc3a337627a96b9aedc89abfeb4d9208c2bb00c9539a0a1f40",
- "path-encoding.json": "8f6fd4be5440da9cf4bbb1a112cb89c410c4e46734c7d8a9c023eaa034727ee3",
+ "path-encoding.json": "807af0d39ccf3f5afda65577787ac5adcd01ff6617468060161c84267fa7e0cb",
"wire-format.json": "5d72ca1ff27202ab46aa501f54abf77e535f275d2ea4443966ad464f3c020cd7"
}
},
diff --git a/sdk-feature-matrix.md b/sdk-feature-matrix.md
index 55fd227..5e6ef82 100644
--- a/sdk-feature-matrix.md
+++ b/sdk-feature-matrix.md
@@ -299,7 +299,7 @@ its spec:
| AAD v0x03 | ⚠️ Compliant (5 components — every auto serializer appends `original_type`; interop mode is the sole 4-component path) except the `cache_key` component behind a prefixing backend: through `cachekit` 0.22.0 it omits the backend's key prefix (`MemcachedBackend` `key_prefix`, the tenant-scoped Redis backend's `t:{tenant}:`), short of the [logical backend key](spec/encryption.md#additional-authenticated-data-aad); bound in [cachekit-py#518](https://github.com/cachekit-io/cachekit-py/pull/518), unreleased (see the Encryption table) | ✅ 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 [cachekit-py#520](https://github.com/cachekit-io/cachekit-py/pull/520), unreleased. 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 [cachekit-rs#142](https://github.com/cachekit-io/cachekit-rs/pull/142), unreleased; 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) | ⚠️ through npm 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`; on `main` since [cachekit-ts#110](https://github.com/cachekit-io/cachekit-ts/pull/110), unreleased, a PUT sends `X-CacheKit-TTL` ceiled to whole seconds and rejects 0 and an over-cap TTL. Every release and `main`: 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 ([API-65](spec/saas-api.md#api-65)); fixed in [cachekit-ts#215](https://github.com/cachekit-io/cachekit-ts/pull/215), unreleased | ❌ Not implemented |
| SaaS API — cache-key path encoding ([spec](spec/saas-api.md#cache-key-path-encoding)) | ✅ Compliant on `main`, unreleased — rejects the empty key ([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 on `main`, unreleased ([cachekit-rs#100](https://github.com/cachekit-io/cachekit-rs/pull/100), which adds the empty key); 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 on `main`, unreleased ([cachekit-ts#118](https://github.com/cachekit-io/cachekit-ts/pull/118)) | ❌ 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) in [cachekit-py#391](https://github.com/cachekit-io/cachekit-py/pull/391), unreleased; `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)), 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) — ⚠️ except the envelope entry point (`ByteStorage::retrieve`), which has no guard to assert until cachekit-py moves to a core release carrying [cachekit-core#80](https://github.com/cachekit-io/cachekit-core/pull/80) (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) in [cachekit-py#397](https://github.com/cachekit-io/cachekit-py/pull/397), unreleased; `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 | ✅ 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 in [cachekit-rs#98](https://github.com/cachekit-io/cachekit-rs/pull/98), unreleased; `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)), 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 that PR (no envelope entry point¹⁵); `path-encoding.json` 1.1.0 vendored byte for byte (sha256-pinned) + CI-executed in [cachekit-rs#100](https://github.com/cachekit-io/cachekit-rs/pull/100), unreleased, 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), unreleased, 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 | ✅ interop/v1 (full set, incl. its key vectors) + inline Python-generated AAD-construction and encryption (decrypt-Python-ciphertext) vectors — fixture 1.1.0 in [cachekit-ts#143](https://github.com/cachekit-io/cachekit-ts/pull/143), unreleased; fixture 1.2.0 in [cachekit-ts#164](https://github.com/cachekit-io/cachekit-ts/pull/164), unreleased; 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), unreleased, asserting the guard error each vector trips (pre-scan, or the event size cap ahead of it) — ⚠️ except the envelope entry point (`unpack` in `cachekit-core-ts` and `cachekit-core-wasm`), which has no guard to assert until both bindings move to a core release carrying [cachekit-core#80](https://github.com/cachekit-io/cachekit-core/pull/80) and the vectors run through `unpack` (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), unreleased; `path-encoding.json` vendored + CI-executed since [cachekit-ts#118](https://github.com/cachekit-io/cachekit-ts/pull/118), unreleased; fixture 1.1.0 (empty-key row) in [cachekit-ts#165](https://github.com/cachekit-io/cachekit-ts/pull/165), unreleased; fixture 1.2.0 (encoder-mistake rows) in [cachekit-ts#217](https://github.com/cachekit-io/cachekit-ts/pull/217), unreleased, 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), unreleased, 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), unreleased (its reject vectors: see the Wire format row); `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), unreleased, behind this repo's `1.3.0` | ⚠️ Pending |
+| 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)); `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)), 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) — ⚠️ except the envelope entry point (`ByteStorage::retrieve`), which has no guard to assert until cachekit-py moves to a core release carrying [cachekit-core#80](https://github.com/cachekit-io/cachekit-core/pull/80) (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 in [cachekit-py#522](https://github.com/cachekit-io/cachekit-py/pull/522), unreleased; `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 | ✅ 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 in [cachekit-rs#98](https://github.com/cachekit-io/cachekit-rs/pull/98), unreleased; `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)), 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 that PR (no envelope entry point¹⁵); `path-encoding.json` 1.1.0 vendored byte for byte (sha256-pinned) + CI-executed in [cachekit-rs#100](https://github.com/cachekit-io/cachekit-rs/pull/100), unreleased, 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), unreleased, 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 | ✅ interop/v1 (full set, incl. its key vectors) + inline Python-generated AAD-construction and encryption (decrypt-Python-ciphertext) vectors — fixture 1.1.0 in [cachekit-ts#143](https://github.com/cachekit-io/cachekit-ts/pull/143), unreleased; fixture 1.2.0 in [cachekit-ts#164](https://github.com/cachekit-io/cachekit-ts/pull/164), unreleased; 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), unreleased, asserting the guard error each vector trips (pre-scan, or the event size cap ahead of it) — ⚠️ except the envelope entry point (`unpack` in `cachekit-core-ts` and `cachekit-core-wasm`), which has no guard to assert until both bindings move to a core release carrying [cachekit-core#80](https://github.com/cachekit-io/cachekit-core/pull/80) and the vectors run through `unpack` (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), unreleased; `path-encoding.json` vendored + CI-executed since [cachekit-ts#118](https://github.com/cachekit-io/cachekit-ts/pull/118), unreleased; fixture 1.1.0 (empty-key row) in [cachekit-ts#165](https://github.com/cachekit-io/cachekit-ts/pull/165), unreleased; fixture 1.2.0 (encoder-mistake rows) in [cachekit-ts#217](https://github.com/cachekit-io/cachekit-ts/pull/217), unreleased, 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), unreleased, 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), unreleased (its reject vectors: see the Wire format row); `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), unreleased, behind this repo's `1.3.0` | ⚠️ 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 |
> [!NOTE]
@@ -307,7 +307,7 @@ its spec:
>
> ¹⁵ Auto-mode **stored bytes** are SDK-internal and differ per SDK — see [wire-format.md → SDK Storage Containers](spec/wire-format.md#sdk-storage-containers-auto-mode). Python stores the ByteStorage envelope *inside* its CK v3 frame; `cachekit-rs` does not use the envelope for values at all (it uses `cachekit-core` only for encryption). Cross-SDK value compatibility is exclusively an [interop-mode](spec/interop-mode.md) property (protocol#11).
>
-> ¹⁶ "Test vectors in CI" = vectors the SDK's own default CI executes. Beyond the SDKs, this repo's `verify.yml` CI-verifies `interop-mode.json`, `encryption.json`, `python-frame.json`, `file-backend.json` ([`tools/file-backend-reference.py`](tools/file-backend-reference.py)), and — since LAB-423 — `wire-format.json` ([`tools/wire-format-reference.py`](tools/wire-format-reference.py)), and — since LAB-2503 — `decode-bounds.json` ([`tools/decode-bounds-reference.py`](tools/decode-bounds-reference.py), `verify` in both the stdlib and the optional-deps legs), and — since LAB-2879 — `path-encoding.json` ([`tools/path-encoding-verify.py`](tools/path-encoding-verify.py)), and `freshness-headers.json` ([`tools/freshness-headers-verify.py`](tools/freshness-headers-verify.py)) against reference implementations. This repo's `wire-format.json` is `1.3.0`, which adds six ByteStorage `reject_vectors` (LAB-6508). cachekit-py runs all six since 0.21.0 ([cachekit-py#448](https://github.com/cachekit-io/cachekit-py/pull/448)) and cachekit-ts on `main` ([cachekit-ts#198](https://github.com/cachekit-io/cachekit-ts/pull/198), unreleased), each through its envelope read path; `cachekit-rs` stores no envelope¹⁵. No `cachekit-core` release runs them (0.6.0 vendors `wire-format.json` 1.1.0), and no core release has the allocation probe their size-cap and ratio vectors require, so neither SDK asserts that bound yet. This repo's `path-encoding.json` is `1.2.0` (31 rows, 6 reject); cachekit-ts and cachekit-rs vendor `1.2.0` byte for byte in [cachekit-ts#217](https://github.com/cachekit-io/cachekit-ts/pull/217) and [cachekit-rs#146](https://github.com/cachekit-io/cachekit-rs/pull/146) (unreleased); cachekit-py vendors `1.1.0` (16 rows) byte for byte in [cachekit-py#397](https://github.com/cachekit-io/cachekit-py/pull/397) (unreleased). This repo's `freshness-headers.json` is `1.0.0` (31 rows); cachekit-ts and cachekit-rs vendor it byte for byte in [cachekit-ts#217](https://github.com/cachekit-io/cachekit-ts/pull/217) and [cachekit-rs#146](https://github.com/cachekit-io/cachekit-rs/pull/146) (unreleased), and cachekit-py does not yet. This repo's `encryption.json` is `1.3.0`, which adds master key input rows for [intent-presets.md → Master Key Input](spec/intent-presets.md#master-key-input): a sealed 32-byte key with a leading zero byte, bytes above 7f and every lowercase hex digit, eleven malformed or short hex keys, and five raw keys of the wrong length. cachekit-ts vendors `1.2.0`, and no SDK runs the new rows yet. This repo's `decode-bounds.json` is `1.1.0` (17 reject, 3 accept), and all three SDKs vendor it byte for byte: cachekit-rs since 0.8.0 ([cachekit-rs#93](https://github.com/cachekit-io/cachekit-rs/pull/93)), cachekit-py since 0.20.0 ([cachekit-py#363](https://github.com/cachekit-io/cachekit-py/pull/363)), and cachekit-ts in [cachekit-ts#152](https://github.com/cachekit-io/cachekit-ts/pull/152) (unreleased); their earlier copies were `1.0.0` revisions (13 reject / 2 accept in py and ts, 10 / 2 in rs). `cachekit-core` vendors `1.1.0` too and drives it through `ByteStorage::retrieve`, on core `main` ([cachekit-core#80](https://github.com/cachekit-io/cachekit-core/pull/80)), unreleased. `cache-keys.json` is vendored and CI-verified in cachekit-py since [cachekit-py#229](https://github.com/cachekit-io/cachekit-py/pull/229) (LAB-425). This repo's copy is `1.1.0` (17 vectors; the first 10 byte-identical to the v0.5.0 originals); cachekit-py vendors `1.1.0` byte for byte since 0.21.0 ([cachekit-py#460](https://github.com/cachekit-io/cachekit-py/pull/460)).
+> ¹⁶ "Test vectors in CI" = vectors the SDK's own default CI executes. Beyond the SDKs, this repo's `verify.yml` CI-verifies `interop-mode.json`, `encryption.json`, `python-frame.json`, `file-backend.json` ([`tools/file-backend-reference.py`](tools/file-backend-reference.py)), and — since LAB-423 — `wire-format.json` ([`tools/wire-format-reference.py`](tools/wire-format-reference.py)), and — since LAB-2503 — `decode-bounds.json` ([`tools/decode-bounds-reference.py`](tools/decode-bounds-reference.py), `verify` in both the stdlib and the optional-deps legs), and — since LAB-2879 — `path-encoding.json` ([`tools/path-encoding-verify.py`](tools/path-encoding-verify.py)), and `freshness-headers.json` ([`tools/freshness-headers-verify.py`](tools/freshness-headers-verify.py)) against reference implementations. This repo's `wire-format.json` is `1.3.0`, which adds six ByteStorage `reject_vectors` (LAB-6508). cachekit-py runs all six since 0.21.0 ([cachekit-py#448](https://github.com/cachekit-io/cachekit-py/pull/448)) and cachekit-ts on `main` ([cachekit-ts#198](https://github.com/cachekit-io/cachekit-ts/pull/198), unreleased), each through its envelope read path; `cachekit-rs` stores no envelope¹⁵. No `cachekit-core` release runs them (0.6.0 vendors `wire-format.json` 1.1.0), and no core release has the allocation probe their size-cap and ratio vectors require, so neither SDK asserts that bound yet. This repo's `path-encoding.json` is `1.2.0` (31 rows, 6 reject); all three SDKs vendor `1.2.0` byte for byte, in [cachekit-py#522](https://github.com/cachekit-io/cachekit-py/pull/522), [cachekit-ts#217](https://github.com/cachekit-io/cachekit-ts/pull/217) and [cachekit-rs#146](https://github.com/cachekit-io/cachekit-rs/pull/146) (unreleased). This repo's `freshness-headers.json` is `1.0.0` (31 rows), and all three SDKs vendor it byte for byte in the same PRs (unreleased). This repo's `encryption.json` is `1.3.0`, which adds master key input rows for [intent-presets.md → Master Key Input](spec/intent-presets.md#master-key-input): a sealed 32-byte key with a leading zero byte, bytes above 7f and every lowercase hex digit, eleven malformed or short hex keys, and five raw keys of the wrong length. cachekit-ts vendors `1.2.0`, and no SDK runs the new rows yet. This repo's `decode-bounds.json` is `1.1.0` (17 reject, 3 accept), and all three SDKs vendor it byte for byte: cachekit-rs since 0.8.0 ([cachekit-rs#93](https://github.com/cachekit-io/cachekit-rs/pull/93)), cachekit-py since 0.20.0 ([cachekit-py#363](https://github.com/cachekit-io/cachekit-py/pull/363)), and cachekit-ts in [cachekit-ts#152](https://github.com/cachekit-io/cachekit-ts/pull/152) (unreleased); their earlier copies were `1.0.0` revisions (13 reject / 2 accept in py and ts, 10 / 2 in rs). `cachekit-core` vendors `1.1.0` too and drives it through `ByteStorage::retrieve`, on core `main` ([cachekit-core#80](https://github.com/cachekit-io/cachekit-core/pull/80)), unreleased. `cache-keys.json` is vendored and CI-verified in cachekit-py since [cachekit-py#229](https://github.com/cachekit-io/cachekit-py/pull/229) (LAB-425). This repo's copy is `1.1.0` (17 vectors; the first 10 byte-identical to the v0.5.0 originals); cachekit-py vendors `1.1.0` byte for byte since 0.21.0 ([cachekit-py#460](https://github.com/cachekit-io/cachekit-py/pull/460)).
>
> ¹⁷ Version cells are **floors** (`X+`), not snapshots — they stay true as new versions publish; check the registry for the current release. Python's floor is the first *installable* one: interop merged under the `v0.13.0` tag, but neither `0.12.0` nor `0.13.0` was ever published to PyPI, so `0.14.0` is the earliest PyPI release containing interop mode. Do not "correct" this to 0.13.0 from the cachekit-py changelog alone.