From aff6e607e98858265fc72fc0ab5850889af90046 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 24 Oct 2026 01:30:37 +1100 Subject: [PATCH 1/2] docs(matrix): bring the TypeScript cells to the published npm 0.2.1 Every TypeScript claim that called a change unreleased, or bounded current behaviour through npm 0.1.5, now names @cachekit-io/cachekit 0.2.1 as its floor or is re-dated against 0.2.1. Each flip was checked against the published 0.2.1 tarball (0.1.5 as the negative control), the tag history of cachekit-v0.2.1, and the tests at that tag run against the published bindings. The caveats that wait on cachekit-core 0.7.0 stay: both 0.2.1 bindings embed core 0.6.0. Note 6 and note 16 now record what core 0.7.0 ships. --- changelog.d/20261024_lab-4614.md | 14 ++++++++++ decisions/key-rotation.md | 2 +- sdk-feature-matrix.md | 44 +++++++++++++++++--------------- spec/encryption.md | 2 +- 4 files changed, 39 insertions(+), 23 deletions(-) create mode 100644 changelog.d/20261024_lab-4614.md diff --git a/changelog.d/20261024_lab-4614.md b/changelog.d/20261024_lab-4614.md new file mode 100644 index 0000000..72bf612 --- /dev/null +++ b/changelog.d/20261024_lab-4614.md @@ -0,0 +1,14 @@ +### SDK feature matrix — TypeScript cells brought to the published npm 0.2.1 (LAB-4614) + +- `sdk-feature-matrix.md`: every TypeScript claim that called a change "unreleased" or bounded current behaviour + "through npm 0.1.5" now names `@cachekit-io/cachekit` 0.2.1 as its floor, or is re-dated against 0.2.1 where 0.2.1 + still lacks the change. Key rotation, hardware-acceleration detection, server-bounded L1 backfill and cache-key path + encoding read ✅ for TypeScript from 0.2.1. The test-vector, wire-format, `minimal` L1, tenancy and SaaS API cells, + notes ¹, ⁴, ⁵, ⁶, ¹⁶ and ¹⁷, and the `bin` rollout note follow. +- Note ¹⁷ records that npm 0.2.0 carries the same changes but does not install, so no floor names it. +- Unchanged, because both 0.2.1 bindings embed `cachekit-core` 0.6.0: TypeScript's envelope pre-scan caveat, its + aarch64 hardware-acceleration false positive, and its temporal-sentinel ❌. Note ⁶ and note ¹⁶ now say that + `cachekit-core` 0.7.0 ships the aarch64 probe, the envelope pre-scan and the allocation probe, and that + `cachekit-rs` 0.11.1+ inherits the aarch64 fix. +- `spec/encryption.md` and `decisions/key-rotation.md`: the keyring is released in all three SDKs; TypeScript's floor + is 0.2.1. diff --git a/decisions/key-rotation.md b/decisions/key-rotation.md index 6793714..03bead8 100644 --- a/decisions/key-rotation.md +++ b/decisions/key-rotation.md @@ -8,7 +8,7 @@ | **Date** | 2026-07-23 | | **Ticket** | LAB-516 (filed by the LAB-275 cross-SDK feature-gap audit) | | **Normative spec** | [`spec/encryption.md` → Key Rotation (Keyring)](../spec/encryption.md#key-rotation-keyring) — the spec section owns the rules; this record owns the rationale and runbooks. | -| **Implementation** | Implemented in all three SDKs; released in Python (`cachekit` 0.18.0+, PyPI) and Rust (`cachekit-rs` 0.8.0+, crates.io; `CACHEKIT_PREVIOUS_MASTER_KEYS` is read by its `from_env()` and, from 0.9.0, by the `secure` preset's `secure_from_env` — see the matrix note), with TypeScript on `main` pending its next release (artifacts inspected 2026-09-22, the Rust `.crate`s 2026-09-30 (0.8.0) and 2026-10-03 (0.9.0)). Shared decrypt helper `Keyring` in cachekit-core 0.5.0 ([cachekit-core#67](https://github.com/cachekit-io/cachekit-core/pull/67), LAB-683 — also deleted the `rotate_key()` stub, `KeyRotationState` and `RotationAwareHeader`); SDK surfaces in [cachekit-py#261](https://github.com/cachekit-io/cachekit-py/pull/261) (LAB-684), [cachekit-rs#63](https://github.com/cachekit-io/cachekit-rs/pull/63) (LAB-686) and [cachekit-ts#103](https://github.com/cachekit-io/cachekit-ts/pull/103) (LAB-685). The [feature matrix](../sdk-feature-matrix.md#encryption) rotation row reads ✅ for Python and Rust and 🚧 unreleased for TypeScript until a release carries the code; conformance is enforced by [`tools/encryption-verify.py`](../tools/encryption-verify.py) against the keyring vectors in [`test-vectors/encryption.json`](../test-vectors/encryption.json) (LAB-687). | +| **Implementation** | Implemented and released in all three SDKs: Python (`cachekit` 0.18.0+, PyPI), Rust (`cachekit-rs` 0.8.0+, crates.io; `CACHEKIT_PREVIOUS_MASTER_KEYS` is read by its `from_env()` and, from 0.9.0, by the `secure` preset's `secure_from_env` — see the matrix note), and TypeScript (`@cachekit-io/cachekit` 0.2.1+, npm; 0.2.0 does not install) (artifacts inspected 2026-09-22, the Rust `.crate`s 2026-09-30 (0.8.0) and 2026-10-03 (0.9.0), the npm `.tgz` 2026-10-24). Shared decrypt helper `Keyring` in cachekit-core 0.5.0 ([cachekit-core#67](https://github.com/cachekit-io/cachekit-core/pull/67), LAB-683 — also deleted the `rotate_key()` stub, `KeyRotationState` and `RotationAwareHeader`); SDK surfaces in [cachekit-py#261](https://github.com/cachekit-io/cachekit-py/pull/261) (LAB-684), [cachekit-rs#63](https://github.com/cachekit-io/cachekit-rs/pull/63) (LAB-686) and [cachekit-ts#103](https://github.com/cachekit-io/cachekit-ts/pull/103) (LAB-685). The [feature matrix](../sdk-feature-matrix.md#encryption) rotation row reads ✅ for all three; conformance is enforced by [`tools/encryption-verify.py`](../tools/encryption-verify.py) against the keyring vectors in [`test-vectors/encryption.json`](../test-vectors/encryption.json) (LAB-687). | --- diff --git a/sdk-feature-matrix.md b/sdk-feature-matrix.md index f5b1dbc..960ae18 100644 --- a/sdk-feature-matrix.md +++ b/sdk-feature-matrix.md @@ -6,9 +6,9 @@ **Feature parity and compliance status across all CacheKit SDK implementations.** -*Last updated: 2026-09-30 — LAB-5291 Rust preset, keyring, hardware-acceleration, observability, path-encoding and test-vector cells brought to the published `cachekit-rs` 0.8.0 (following LAB-3479's cachekit-core envelope pre-scan on core `main`, unreleased). Every version-keyed claim is verified against the **published artifact** (registry metadata, and the `.crate`/`.tgz` contents where an embedded dependency version decides the answer), not against a repo branch — see [decisions/matrix-version-verification.md](decisions/matrix-version-verification.md) for why and how. Per-PR fold verdicts are in [CHANGELOG.md](CHANGELOG.md); per-row history is `git log sdk-feature-matrix.md`.* +*Last updated: 2026-10-24 — LAB-4614 TypeScript keyring, hardware-acceleration, backfill, preset, tenancy, SaaS API, path-encoding, wire-format and test-vector cells brought to the published `@cachekit-io/cachekit` 0.2.1, whose `cachekit-core-ts` 0.2.0 and `cachekit-core-wasm` 0.2.0 bindings embed `cachekit-core` 0.6.0, so the TypeScript caveats that wait on core 0.7.0 stay. Every version-keyed claim is verified against the **published artifact** (registry metadata, and the `.crate`/`.tgz` contents where an embedded dependency version decides the answer), not against a repo branch — see [decisions/matrix-version-verification.md](decisions/matrix-version-verification.md) for why and how. Per-PR fold verdicts are in [CHANGELOG.md](CHANGELOG.md); per-row history is `git log sdk-feature-matrix.md`.* -*__Cells that reversed — check these if you built on them:__ Rust sync support (✅ → does not exist) and the Rust `::secure` preset (✅ → spelled `::encrypted` on every published `cachekit-rs` through 0.7.0 → `::secure` from 0.8.0¹¹), Builder API (py/ts ✅ → ❌), Hardware acceleration (rs ✅ → not re-exported → re-exported from 0.8.0, ts N/A → ❌), TypeScript Arrow (🔜 → ❌), Python's encrypted read path (documented fail-closed → **fail-open by default**), and `cache.secure.wrap()` in TypeScript (implied encryption → no guarantee → **enforced since LAB-513: throws without encryption**). The TypeScript protocol-1.1 `bin` rollout also reversed twice in two days: it is **not** shipped on either ts path (per-artifact evidence in the [cachekit-core architecture note](#architecture-notes)).* +*__Cells that reversed — check these if you built on them:__ Rust sync support (✅ → does not exist) and the Rust `::secure` preset (✅ → spelled `::encrypted` on every published `cachekit-rs` through 0.7.0 → `::secure` from 0.8.0¹¹), Builder API (py/ts ✅ → ❌), Hardware acceleration (rs ✅ → not re-exported → re-exported from 0.8.0, ts N/A → ❌ → surfaced from 0.2.1), TypeScript Arrow (🔜 → ❌), Python's encrypted read path (documented fail-closed → **fail-open by default**), and `cache.secure.wrap()` in TypeScript (implied encryption → no guarantee → **enforced since LAB-513: throws without encryption**). The TypeScript protocol-1.1 `bin` rollout also reversed twice in two days: it was **not** shipped on either ts path through 0.1.5, and ships on both from 0.2.1 (per-artifact evidence in the [cachekit-core architecture note](#architecture-notes)).* @@ -80,17 +80,17 @@ | HKDF-SHA256 key derivation | ✅ via Rust FFI | ✅ | ✅ via NAPI (Rust) | 🔜 Planned | | Per-tenant key isolation | ✅ | ✅ | ✅ via TenantKeys NAPI | 🔜 Planned | | AAD v0x03 (cache_key binding) | ✅ 0.23.0+ — the AAD `cache_key` is the [logical backend key](spec/encryption.md#additional-authenticated-data-aad), a prefixing backend's key prefix included (`MemcachedBackend` `key_prefix`, the tenant-scoped Redis backend's `t:{tenant}:`); wheel inspected and `tests/critical/test_aad_backend_key_prefix.py` run against the 0.23.0 and 0.24.0 wheels 2026-10-19. Through 0.22.0 that prefix was left out ([cachekit-py#518](https://github.com/cachekit-io/cachekit-py/pull/518)). Interop mode, which refuses prefixing backends, binds the bare key | ✅ | ✅ | ❌ | -| Key rotation | ✅ 0.18.0+ — keyring; derived-key fingerprint selection⁵ | ✅ `cachekit-rs` 0.8.0+ — keyring; sequential current-first attempts; previous keys via `from_env()`, the builder, or `::secure_from_env` from 0.9.0 (0.8.0's `::secure_from_env` ignores them; `.crate`s inspected 2026-09-30 and 2026-10-03)⁵ | 🚧 unreleased — keyring on `main` ([cachekit-ts#103](https://github.com/cachekit-io/cachekit-ts/pull/103)); absent from npm 0.1.5⁵ | ❌ | +| Key rotation | ✅ 0.18.0+ — keyring; derived-key fingerprint selection⁵ | ✅ `cachekit-rs` 0.8.0+ — keyring; sequential current-first attempts; previous keys via `from_env()`, the builder, or `::secure_from_env` from 0.9.0 (0.8.0's `::secure_from_env` ignores them; `.crate`s inspected 2026-09-30 and 2026-10-03)⁵ | ✅ `@cachekit-io/cachekit` 0.2.1+¹⁷ — keyring; sequential current-first attempts; previous keys via `encryption.previousMasterKeys`, `createCache.secure`'s `previousMasterKeys` or `CACHEKIT_PREVIOUS_MASTER_KEYS` (`dist/intents-core.js:89` and `:217-224`; `.tgz` inspected 2026-10-24); absent from 0.1.5⁵ | ❌ | | **Tamper / wrong-key failure mode** | ⚠️ **fail-OPEN by default** — warn + recompute; switchable with `CACHEKIT_ENCRYPTION_FAIL_CLOSED=true`¹⁸ | ✅ **Fails closed** — `decrypt_or_evict(…)?` drops the L1 copy and propagates (`SecureCache::decrypt_or_evict` in `client.rs`), and `#[cachekit(secure)]` emits no fail-open arm (`fail_open_arm` in `expand`, `cachekit-macros/src/lib.rs`; 0.8.0 crates). From `cachekit-rs` 0.10.0 every read on a client with encryption configured takes that path, plain `get` and `#[cachekit]` without `secure` included | ⚠️ **fail-OPEN on reads, drops writes with only a rate-limited warning, no fail-closed switch**⁸ | — | | **Does the `secure` API enforce encryption?** | ✅ Raises without a key | ✅ `secure_cache()`¹¹ returns `Err` | ✅ **`cache.secure.wrap()` throws `ConfigurationError` at wrap time** on any instance without `encryption` configured — instance and `withExecutionContext(ctx)` view alike (before this check, an unconditional alias for `wrap()`, so on an instance without configured encryption a "secure" registration cached plaintext, CWE-311); see [Intent-preset semantics](#intent-preset-semantics-parity-not-presence) | — | -| Hardware acceleration detection | ✅ surfaced (`EncryptionWrapper.hardware_acceleration_enabled`)⁶ | ✅ `cachekit-rs` 0.8.0+ — surfaced (`SecureCache::hardware_acceleration_enabled()`; `.crate` inspected 2026-09-30)⁶ | 🚧 unreleased — on `main` ([cachekit-ts#132](https://github.com/cachekit-io/cachekit-ts/pull/132)); absent from npm 0.1.5⁶ | N/A | +| Hardware acceleration detection | ✅ surfaced (`EncryptionWrapper.hardware_acceleration_enabled`)⁶ | ✅ `cachekit-rs` 0.8.0+ — surfaced (`SecureCache::hardware_acceleration_enabled()`; `.crate` inspected 2026-09-30)⁶ | ✅ `@cachekit-io/cachekit` 0.2.1+ — surfaced (`EncryptionManager.isHardwareAccelerated()`; `.tgz` inspected 2026-10-24); `true` on every aarch64 build, since both bindings embed `cachekit-core` 0.6.0⁶ | N/A | | Counter-based nonces | ✅ via Rust | ✅ | ✅ via NAPI (Rust) | ❌ use random | > [!IMPORTANT] > AAD v0x03 is required for protocol compliance. SDKs without it cannot safely interoperate with encrypted payloads from compliant SDKs — the auth tag will fail verification. Python, Rust, and TypeScript construction was code-verified byte-identical on 2026-07-21 (LAB-272); the normative byte layout and the frozen `True`/`False` compressed tokens (protocol#12) are defined in [spec/encryption.md](spec/encryption.md#additional-authenticated-data-aad). Python's auto serializers append the optional `original_type` fifth component; Rust, TypeScript, and interop mode always emit exactly four. When encryption is enabled but a stored entry claims plaintext, Python never returns it — the entry is converted to a miss and evicted rather than raising (LAB-241, [cachekit-py#215](https://github.com/cachekit-io/cachekit-py/pull/215)). `cachekit` 0.9.0 through 0.22.0 violates [ENC-2](spec/encryption.md#enc-2) and [ENC-3](spec/encryption.md#enc-3): after a decrypt, `ArrowSerializer` sniffs the plaintext and returns the DataFrame from bare Arrow IPC instead of hard-failing on the mismatch with its configured checksummed container. From 0.23.0 ([cachekit-py#517](https://github.com/cachekit-io/cachekit-py/pull/517)) it is refused (`TestArrowPostDecryptContainer` in `tests/unit/test_decrypt_fail_policy.py`, run against the 0.23.0 and 0.24.0 wheels 2026-10-19): the read fails with `EncryptionError` (corruption class), so it is a miss and the entry is evicted, including under fail-closed. `cachekit` 0.9.0 through 0.22.0 also violates [ENC-2](spec/encryption.md#enc-2) and [ENC-3](spec/encryption.md#enc-3) on a config-drift read: a handler built with `serializer="auto"` (or its alias `"pythonic"`) and encryption off, given an encrypted entry whose stored header names that serializer, decrypts it with its master key (`master_key=` or `CACHEKIT_MASTER_KEY`), then `AutoSerializer` sniffs the plaintext (0.9.0 and 0.22.0 wheels run 2026-10-09). An encrypted entry stored under another serializer name fails the serializer-name check before it is decrypted. From 0.23.0 ([cachekit-py#521](https://github.com/cachekit-io/cachekit-py/pull/521)), `EncryptionWrapper` refuses `AutoSerializer` at construction (`TestEncryptionWrapperRefusesUnfixedFormatSerializer::test_auto_serializer_refused` in `tests/unit/test_encryption_security_invariants.py`, run against the 0.23.0 and 0.24.0 wheels 2026-10-19). On the drift read that refusal is raised inside the handler's deserialize step, which wraps it as `SerializationError` for the read site to classify; no test yet asserts what the caller sees. `cachekit` 0.1.0 through 0.22.0 also violates [ENC-2](spec/encryption.md#enc-2) and [ENC-3](spec/encryption.md#enc-3) through the public `EncryptionWrapper`: built directly with `serializer=AutoSerializer()`, it decrypts and then lets `AutoSerializer` sniff the plaintext (0.1.0 and 0.22.0 wheels run 2026-10-09). In 0.1.0 through 0.4.0 the wrapper's default serializer was `AutoSerializer`, so a bare `EncryptionWrapper(master_key=...)` does the same, and so does every encrypted read by a cache handler built with `encryption=True`, whatever serializer it is configured with (0.1.0 and 0.4.0 wheels run 2026-10-09); from 0.6.1, the first PyPI release after 0.4.0, neither reaches `AutoSerializer` (wheel run 2026-10-09). From 0.23.0 ([cachekit-py#521](https://github.com/cachekit-io/cachekit-py/pull/521)), `EncryptionWrapper` refuses `AutoSerializer` at construction (`TestEncryptionWrapperRefusesUnfixedFormatSerializer::test_auto_serializer_refused` in `tests/unit/test_encryption_security_invariants.py`, run against the 0.23.0 and 0.24.0 wheels 2026-10-19). > [!WARNING] -> ⁵ **Keyring rotation is implemented in Python, Rust and TypeScript, and released in Python and Rust** — `cachekit` 0.18.0+ on PyPI ([cachekit-py#261](https://github.com/cachekit-io/cachekit-py/pull/261); wheel inspected 2026-09-22, `previous_master_keys` present) and `cachekit-rs` 0.8.0+ on crates.io ([cachekit-rs#63](https://github.com/cachekit-io/cachekit-rs/pull/63); `.crate` inspected 2026-09-30, `previous_master_keys` present, drain signal `SecureCache::previous_key_hits()`). **In Rust, `CACHEKIT_PREVIOUS_MASTER_KEYS` is read by `from_env()` and, from `cachekit-rs` 0.9.0 (`.crate` inspected 2026-10-03), by `CacheKit::secure_from_env(url)`**, which validates it as `CachekitConfig::from_env()` does, so a Redis fleet that builds its `secure` clients through `secure_from_env` can follow the env-var runbook as written. Two `secure` paths still take no previous keys from the environment: the explicit-key `CacheKit::secure(url, master_key_hex)`, and `secure_from_env` on 0.8.0, which reads `CACHEKIT_MASTER_KEY` and ignores the previous-keys variable without an error. Because Rust fails closed, every old-key read on such a client becomes an error at promotion. On those paths, read both variables with `cachekit::CachekitConfig::from_env()` — it needs no API key, builds no backend, and hex-decodes and validates the previous keys — and pass its `master_key` and `previous_master_keys` to `CacheKitBuilder::encryption_from_bytes_with_previous(current, previous, "default")` on the builder the preset returns. That call replaces the preset's encryption layer, so it needs the current key again, decoded bytes (never the ASCII bytes of a hex string, which derive a key no other SDK derives, with no error), and the tenant `"default"` the preset uses. From 0.9.0 it also requires every key, current and previous, to be exactly 32 bytes, and returns an error for any other length. A fleet with a longer key either rotates to exactly 32-byte keys — the only length every SDK accepts — or builds through `secure_from_env`, which takes hex keys of 32 bytes or more, current and previous. The TypeScript surface ([cachekit-ts#103](https://github.com/cachekit-io/cachekit-ts/pull/103), merged 2026-08-15) is on `main` but absent from the latest published artifact — npm 0.1.5 (2026-08-03), inspected 2026-09-22. On that release `CACHEKIT_PREVIOUS_MASTER_KEYS` is ignored and rotating the master key invalidates every encrypted entry; the cell flips to ✅ with a version floor when a release carries the code, per [decisions/matrix-version-verification.md](decisions/matrix-version-verification.md). Each SDK that has it accepts one current encryption key plus up to three decrypt-only previous keys, rejects a configuration that repeats the current key in that list, writes only with the current key, and keeps reading old-key entries until the operator removes that key — retirement is manual, not TTL-driven. Python selects a keyring entry by the stored fingerprint of its HKDF-derived per-tenant encryption key; Rust and TypeScript attempt the current key followed by decrypt-only keys with identical AAD. The protocol reference verifier ([`tools/encryption-verify.py`](tools/encryption-verify.py)) checks the [keyring vectors](test-vectors/encryption.json): both entries decrypt under `[k2, k1]` at the declared entry, the k1 entry is rejected under `[k2]`, and each stored fingerprint selects the HKDF-derived (not master) key; no SDK consumes these vectors yet. Use the [key rotation runbook](https://docs.cachekit.io/concepts/key-rotation/) (normative source: [decisions/key-rotation.md § Runbooks](decisions/key-rotation.md#runbooks-normative-for-docs)) for the required three-phase rollout and compromise response. +> ⁵ **Keyring rotation is released in Python, Rust and TypeScript** — `cachekit` 0.18.0+ on PyPI ([cachekit-py#261](https://github.com/cachekit-io/cachekit-py/pull/261); wheel inspected 2026-09-22, `previous_master_keys` present), `cachekit-rs` 0.8.0+ on crates.io ([cachekit-rs#63](https://github.com/cachekit-io/cachekit-rs/pull/63); `.crate` inspected 2026-09-30, `previous_master_keys` present, drain signal `SecureCache::previous_key_hits()`) and `@cachekit-io/cachekit` 0.2.1+ on npm ([cachekit-ts#103](https://github.com/cachekit-io/cachekit-ts/pull/103); `.tgz` inspected 2026-10-24, `previousMasterKeys` and `CACHEKIT_PREVIOUS_MASTER_KEYS` in `dist/intents-core.js`; 0.2.0 does not install, note ¹⁷). **In Rust, `CACHEKIT_PREVIOUS_MASTER_KEYS` is read by `from_env()` and, from `cachekit-rs` 0.9.0 (`.crate` inspected 2026-10-03), by `CacheKit::secure_from_env(url)`**, which validates it as `CachekitConfig::from_env()` does, so a Redis fleet that builds its `secure` clients through `secure_from_env` can follow the env-var runbook as written. Two `secure` paths still take no previous keys from the environment: the explicit-key `CacheKit::secure(url, master_key_hex)`, and `secure_from_env` on 0.8.0, which reads `CACHEKIT_MASTER_KEY` and ignores the previous-keys variable without an error. Because Rust fails closed, every old-key read on such a client becomes an error at promotion. On those paths, read both variables with `cachekit::CachekitConfig::from_env()` — it needs no API key, builds no backend, and hex-decodes and validates the previous keys — and pass its `master_key` and `previous_master_keys` to `CacheKitBuilder::encryption_from_bytes_with_previous(current, previous, "default")` on the builder the preset returns. That call replaces the preset's encryption layer, so it needs the current key again, decoded bytes (never the ASCII bytes of a hex string, which derive a key no other SDK derives, with no error), and the tenant `"default"` the preset uses. From 0.9.0 it also requires every key, current and previous, to be exactly 32 bytes, and returns an error for any other length. A fleet with a longer key either rotates to exactly 32-byte keys — the only length every SDK accepts — or builds through `secure_from_env`, which takes hex keys of 32 bytes or more, current and previous. TypeScript npm 0.1.5 and earlier have no keyring (0.1.5 inspected 2026-09-22): `CACHEKIT_PREVIOUS_MASTER_KEYS` is ignored and rotating the master key invalidates every encrypted entry, so a TypeScript fleet runs 0.2.1+ before it rotates. Each SDK that has it accepts one current encryption key plus up to three decrypt-only previous keys, rejects a configuration that repeats the current key in that list, writes only with the current key, and keeps reading old-key entries until the operator removes that key — retirement is manual, not TTL-driven. Python selects a keyring entry by the stored fingerprint of its HKDF-derived per-tenant encryption key; Rust and TypeScript attempt the current key followed by decrypt-only keys with identical AAD. The protocol reference verifier ([`tools/encryption-verify.py`](tools/encryption-verify.py)) checks the [keyring vectors](test-vectors/encryption.json): both entries decrypt under `[k2, k1]` at the declared entry, the k1 entry is rejected under `[k2]`, and each stored fingerprint selects the HKDF-derived (not master) key; no SDK consumes these vectors yet. Use the [key rotation runbook](https://docs.cachekit.io/concepts/key-rotation/) (normative source: [decisions/key-rotation.md § Runbooks](decisions/key-rotation.md#runbooks-normative-for-docs)) for the required three-phase rollout and compromise response. > > ¹⁸ **Python's encrypted read path is fail-OPEN by default.** `EncryptionWrapper(fail_closed=False)` (`EncryptionWrapper.__init__` in `serializers/encryption_wrapper.py`) from `encryption_fail_closed: bool = Field(default=False)` (`CachekitConfig.encryption_fail_closed` in `config/settings.py`): on a key-fingerprint mismatch the default is a warning plus a recompute, not a raise — that flag gates the fingerprint pre-check — and the same setting drives the AES-GCM authentication-failure policy through `handle_decrypt_failure` (called from `CacheOperationHandler._handle_l2_read_error` and `_handle_l2_read_error_async` in `cache_handler.py`; resolved in `CacheSerializationHandler.__init__`; L1 path in `create_cache_wrapper`'s `sync_wrapper` / `async_wrapper`, `decorators/wrapper.py`). Both default to recompute. Set `CACHEKIT_ENCRYPTION_FAIL_CLOSED=true` if you are relying on hard errors as your wrong-key or tamper alarm — otherwise the signal is a log line and a billable miss. (Separately, a stored entry that claims plaintext while encryption is enabled is never returned to the caller: it is converted to a miss and evicted, deliberately, for plaintext→encrypted migration — LAB-241, [cachekit-py#215](https://github.com/cachekit-io/cachekit-py/pull/215).) > @@ -104,7 +104,7 @@ > > Degradation is on unless explicitly disabled (`degradationEnabled = …degradation !== false`, read the same way by the `CacheImpl` constructor in `cache-core.ts` and the `ReliabilityExecutor` constructor in `reliability/executor.ts`) and `createCache.secure()` / `.production()` / `.io()` all set it `true` (`PRODUCTION_RELIABILITY` in `intents-core.ts`); only `minimal` sets `false`, and `minimal` carries no encryption. **There is no `failClosed` option anywhere in cachekit-ts** — Python's `CACHEKIT_ENCRYPTION_FAIL_CLOSED` has no counterpart, so the only lever is `reliability: { degradation: false }`, which also gives up backend-outage degradation. If you rely on a thrown error as your tamper, wrong-key or nonce alarm, TypeScript raises none with degradation on. The signals are the `errors_total` counter (metrics on), the rate-limited write warning and the L1 decrypt log; an L2 read that fails to decrypt shows up only in the counter. > -> ⁶ AES hardware detection lives in the shared core — `ZeroKnowledgeEncryptor::hardware_acceleration_enabled()` in `cachekit-core/src/encryption/core.rs`: a runtime `is_x86_feature_detected!("aes")` probe on x86/x86_64; on aarch64 it returns `cfg!(target_feature = "neon")`, which every aarch64 target enables, so it is `true` on every aarch64 build whether or not the CPU has the Crypto Extension — a Raspberry Pi 4 (Cortex-A72 without the Crypto Extension) reports `true` while `ring` runs software AES. That holds for every published `cachekit-core` through 0.6.0 (0.4.0, 0.5.0 and 0.6.0 included); the fix, [cachekit-core#77](https://github.com/cachekit-io/cachekit-core/pull/77) (LAB-4650), is merged to core `main` but unreleased; always `false` on wasm32 (no AES instructions to detect). It is **informational only** — `ring`/`aes-gcm` choose their implementation independently of the flag. Published artifacts (registries checked 2026-09-29; crates.io re-checked 2026-09-30): Python surfaces it (`EncryptionWrapper.hardware_acceleration_enabled`, in `get_encryption_info()` and the init log); `cachekit-rs` 0.8.0+ re-exports it as `EncryptionLayer::hardware_acceleration_enabled()`, forwarded by `SecureCache` ([cachekit-rs#80](https://github.com/cachekit-io/cachekit-rs/pull/80)), and its `cachekit-core` `^0.6` requirement resolves to 0.6.0 (the only 0.6.x published), so it inherits the aarch64 false positive above; 0.7.0 and earlier never re-export it; and `@cachekit-io/cachekit` 0.1.5 exposes nothing on either binding — `N/A` for TypeScript was wrong, since both bindings run the same Rust core. The TypeScript surface (`TenantKeys.hardwareAccelerationEnabled()` on the NAPI and wasm bindings; `EncryptionManager.isHardwareAccelerated()`, which initialises on demand and returns `null` — unknown, not `false` — when the installed binding predates the accessor) is merged to `main` in [cachekit-ts#132](https://github.com/cachekit-io/cachekit-ts/pull/132) but not released. The wasm32 always-`false` claim is asserted by cachekit-ts's workerd test (`encryption.protocol.workers.test.ts`), and the Rust accessor is pinned to `is_x86_feature_detected!("aes")` on x86_64 by the `hardware_acceleration_reports_core_detection` test in the published 0.8.0 crate's `src/encryption.rs`. The TypeScript cell flips to ✅ with a version floor on release, per [decisions/matrix-version-verification.md](decisions/matrix-version-verification.md). Tracked as LAB-523. +> ⁶ AES hardware detection lives in the shared core — `ZeroKnowledgeEncryptor::hardware_acceleration_enabled()` in `cachekit-core/src/encryption/core.rs`: a runtime `is_x86_feature_detected!("aes")` probe on x86/x86_64; on aarch64 it returns `cfg!(target_feature = "neon")`, which every aarch64 target enables, so it is `true` on every aarch64 build whether or not the CPU has the Crypto Extension — a Raspberry Pi 4 (Cortex-A72 without the Crypto Extension) reports `true` while `ring` runs software AES. That holds for every published `cachekit-core` through 0.6.0 (0.4.0, 0.5.0 and 0.6.0 included). `cachekit-core` 0.7.0 (crates.io, 2026-10-21) ships the fix, [cachekit-core#77](https://github.com/cachekit-io/cachekit-core/pull/77) (LAB-4650): on aarch64 it calls `is_aarch64_feature_detected!("aes")` (`src/encryption/core.rs:263` of the published `.crate`, inspected 2026-10-24). Always `false` on wasm32 (no AES instructions to detect). It is **informational only** — `ring`/`aes-gcm` choose their implementation independently of the flag. Published artifacts (registries checked 2026-09-29; crates.io re-checked 2026-09-30): Python surfaces it (`EncryptionWrapper.hardware_acceleration_enabled`, in `get_encryption_info()` and the init log); `cachekit-rs` 0.8.0+ re-exports it as `EncryptionLayer::hardware_acceleration_enabled()`, forwarded by `SecureCache` ([cachekit-rs#80](https://github.com/cachekit-io/cachekit-rs/pull/80)); 0.8.0 through 0.11.0 require `cachekit-core` `^0.6`, which resolves to 0.6.0 (the only 0.6.x published), so they inherit the aarch64 false positive above, and 0.11.1+ (crates.io, 2026-10-23) requires `^0.7` and inherits the fix (registry dependency lists read 2026-10-24); 0.7.0 and earlier never re-export it. `@cachekit-io/cachekit` 0.2.1+ surfaces it ([cachekit-ts#132](https://github.com/cachekit-io/cachekit-ts/pull/132)): `TenantKeys.hardwareAccelerationEnabled()` on the NAPI and wasm bindings (`index.d.ts:108` of `cachekit-core-ts` 0.2.0, `index.d.ts:50` of `cachekit-core-wasm` 0.2.0) and `EncryptionManager.isHardwareAccelerated()` (`dist/encryption/manager-core.js:265`), which initialises on demand and returns `null` — unknown, not `false` — when the installed binding predates the accessor (`.tgz`s inspected 2026-10-24). Both 0.2.1 bindings embed `cachekit-core` 0.6.0 (`strings` on every `.node` and the `.wasm`, 2026-10-24), so TypeScript still reports `true` on every aarch64 build. 0.1.5 and earlier expose nothing on either binding — `N/A` for TypeScript was wrong, since both bindings run the same Rust core. The wasm32 always-`false` claim is asserted by cachekit-ts's workerd test (`encryption.protocol.workers.test.ts`), and the Rust accessor is pinned to `is_x86_feature_detected!("aes")` on x86_64 by the `hardware_acceleration_reports_core_detection` test in the published 0.8.0 crate's `src/encryption.rs`. TypeScript's aarch64 false positive ends with a release whose bindings embed `cachekit-core` 0.7.0 or later. --- @@ -121,7 +121,7 @@ | Cache API (Cloudflare) | N/A | ❌ | ✅ `workersCacheAPI` on the `/workers` entry (LAB-750)¹ | N/A | | DynamoDB | ❌² | ❌ | ❌ | ❌ | -> ¹ Shipped in LAB-595 (2026-07-24), following spike LAB-431's GO verdict: the `@cachekit-io/cachekit/workers` subpath (also the `workerd` condition on the root export) runs crypto and the ByteStorage envelope on a **wasm32 build of cachekit-core** (`@cachekit-io/cachekit-core-wasm`, 137 KB raw / ~55 KB gzipped + ~10 KB JS glue, wasm-bindgen `--target web` + wasm-opt `-Oz`) — counter nonces and the envelope carry over unchanged and the crypto stays single-touchpoint in the Rust core; byte-verified against the Python-ground-truth `test-vectors/encryption.json` and `wire-format.json` suites inside real workerd (`wire-format.json` 1.4.0 `vectors`, `constructed_vectors` including the `bin16` → `bin32` edge, and, on `main`, all fourteen `reject_vectors` and both `payload_reject_vectors` on the envelope read path, unreleased — [cachekit-ts#198](https://github.com/cachekit-io/cachekit-ts/pull/198), 1.4.0 in [cachekit-ts#229](https://github.com/cachekit-io/cachekit-ts/pull/229); see the Wire format row). Backend surface (phase 2, LAB-750): CachekitIO (pure `fetch`), **Workers KV** (`workersKV({ kv })` over a `KVNamespace` binding; native `expirationTtl` — KV rejects values under its 60s minimum, so the SDK clamps shorter TTLs up to 60s before the `put`, and by an SDK rule (not KV's) `ttl <= 0` stores without expiry; eventually consistent ~60s), the **Cache API** (`workersCacheAPI()` over `caches.default`/named caches; per-data-center only, `Cache-Control: max-age` to the second, best-effort eviction, keys mapped to synthetic never-fetched URLs), or a custom `Backend` instance — all storage transports over the unchanged opaque ByteStorage payload (encryption above the backend; secure caches store ciphertext only). Backend instances are accepted by the `minimal`/`production`/`secure` intents (`{ backend }` in place of `{ url }`, both entrypoints); Redis-URL intents, Redis Pub/Sub invalidation, and Prometheus metrics stay Node-only and are excluded from the edge bundle (CI-guarded: no `node:*`, no NAPI, no ioredis/prom-client — no `nodejs_compat` flag needed); SWR background refresh requires binding the request's `ExecutionContext` — `cache.withExecutionContext(ctx)` returns a cheap per-request view whose refreshes ride `ctx.waitUntil`, because workerd cancels fire-and-forget work at response return; a read on a cache with no bound context fails safe to a plain (no-SWR) L1 get (LAB-751, the module doc comment of `packages/cachekit/src/workers/index.ts`; `workersRuntime.swrRequiresWaitUntil` and `WorkersCache.withExecutionContext` in `workers/runtime.ts`). Semantics delta: keys live in wasm linear memory (a host-readable ArrayBuffer), weaker isolation than NAPI's Rust heap but ~JS-heap-equivalent on Workers where the host is your own isolate; zeroized deterministically on `dispose()`. WebCrypto (AES-256-GCM + HKDF-SHA256, random-nonce fallback per [encryption.md → Nonce Generation](spec/encryption.md#nonce-generation)) remains the documented fallback if the wasm path ever hits a wall. +> ¹ Shipped in LAB-595 (2026-07-24), following spike LAB-431's GO verdict: the `@cachekit-io/cachekit/workers` subpath (also the `workerd` condition on the root export) runs crypto and the ByteStorage envelope on a **wasm32 build of cachekit-core** (`@cachekit-io/cachekit-core-wasm`, 137 KB raw / ~55 KB gzipped + ~10 KB JS glue, wasm-bindgen `--target web` + wasm-opt `-Oz`) — counter nonces and the envelope carry over unchanged and the crypto stays single-touchpoint in the Rust core; byte-verified against the Python-ground-truth `test-vectors/encryption.json` and `wire-format.json` suites inside real workerd (`wire-format.json` 1.4.0 `vectors`, `constructed_vectors` including the `bin16` → `bin32` edge, and, since npm 0.2.1, all fourteen `reject_vectors` and both `payload_reject_vectors` on the envelope read path — [cachekit-ts#198](https://github.com/cachekit-io/cachekit-ts/pull/198), 1.4.0 in [cachekit-ts#229](https://github.com/cachekit-io/cachekit-ts/pull/229); see the Wire format row). Backend surface (phase 2, LAB-750): CachekitIO (pure `fetch`), **Workers KV** (`workersKV({ kv })` over a `KVNamespace` binding; native `expirationTtl` — KV rejects values under its 60s minimum, so the SDK clamps shorter TTLs up to 60s before the `put`, and by an SDK rule (not KV's) `ttl <= 0` stores without expiry; eventually consistent ~60s), the **Cache API** (`workersCacheAPI()` over `caches.default`/named caches; per-data-center only, `Cache-Control: max-age` to the second, best-effort eviction, keys mapped to synthetic never-fetched URLs), or a custom `Backend` instance — all storage transports over the unchanged opaque ByteStorage payload (encryption above the backend; secure caches store ciphertext only). Backend instances are accepted by the `minimal`/`production`/`secure` intents (`{ backend }` in place of `{ url }`, both entrypoints); Redis-URL intents, Redis Pub/Sub invalidation, and Prometheus metrics stay Node-only and are excluded from the edge bundle (CI-guarded: no `node:*`, no NAPI, no ioredis/prom-client — no `nodejs_compat` flag needed); SWR background refresh requires binding the request's `ExecutionContext` — `cache.withExecutionContext(ctx)` returns a cheap per-request view whose refreshes ride `ctx.waitUntil`, because workerd cancels fire-and-forget work at response return; a read on a cache with no bound context fails safe to a plain (no-SWR) L1 get (LAB-751, the module doc comment of `packages/cachekit/src/workers/index.ts`; `workersRuntime.swrRequiresWaitUntil` and `WorkersCache.withExecutionContext` in `workers/runtime.ts`). Semantics delta: keys live in wasm linear memory (a host-readable ArrayBuffer), weaker isolation than NAPI's Rust heap but ~JS-heap-equivalent on Workers where the host is your own isolate; zeroized deterministically on `dispose()`. WebCrypto (AES-256-GCM + HKDF-SHA256, random-nonce fallback per [encryption.md → Nonce Generation](spec/encryption.md#nonce-generation)) remains the documented fallback if the wasm path ever hits a wall. > > ² DynamoDB has never shipped in any SDK. The previous Python ✅ traced to the [custom-backend tutorial](https://github.com/cachekit-io/cachekit-py/blob/main/docs/backends/custom.md), which shows how a *user* can implement the backend protocol against DynamoDB — that is an extension point, not shipped support (LAB-273). > @@ -134,7 +134,7 @@ > > ³ Added 2026-07-24 (LAB-429). Both are cargo features on cachekit-rs (`--features memcached` / `--features file`), native targets only (compile-error guarded against `workers`). The rs File backend shares py's on-disk format — Blake2b-128 hashed filenames, the 14-byte `CK` header, atomic write-then-rename, lazy expiry — verified by running the actual py `FileBackend` against an rs-written directory and vice versa. Not yet ported from py: LRU eviction/size caps and the mmap buffer read. rs Memcached is single-server (py's `HashClient` shards across servers); CI exercises it against a live memcached 1.6 container. CI runs the vendored `file-backend.json` 1.1.0 (version-pinned) through `read_entry` and `probe_header`, and parses each vector that names a `reader_now_unix_seconds` at that clock, so `expired_entry` is checked at its expiry (`now >= expiry`). -> ⁴ Added 2026-07-25 (LAB-430). TypeScript exposes these Node-only backends only through subpath exports, keeping `memjs` optional and `node:fs` out of browser and edge bundles. File implements full `TTLBackend`; Memcached deliberately offers refresh-only `touch`, not `TTLBackend`, because it cannot inspect remaining TTL. File names and headers follow [spec/file-backend-format.md](spec/file-backend-format.md); unknown reserved or flag values are misses preserved for a newer reader. CI runs `file-backend.json` 1.1.0, vendored byte for byte and sha256-pinned, on every read path with the clock frozen at each vector's `reader_now_unix_seconds`, and the backend expires an entry at `now >= expiry`, in [cachekit-ts#212](https://github.com/cachekit-io/cachekit-ts/pull/212), unreleased; `1.2.0` in [cachekit-ts#229](https://github.com/cachekit-io/cachekit-ts/pull/229), unreleased, whose two expired entries with an unknown flag or reserved value miss and leave the file byte-identical. Before it, the File tests used inline py-generated vectors and served an entry at its exact expiry second. +> ⁴ Added 2026-07-25 (LAB-430). TypeScript exposes these Node-only backends only through subpath exports, keeping `memjs` optional and `node:fs` out of browser and edge bundles. File implements full `TTLBackend`; Memcached deliberately offers refresh-only `touch`, not `TTLBackend`, because it cannot inspect remaining TTL. File names and headers follow [spec/file-backend-format.md](spec/file-backend-format.md); unknown reserved or flag values are misses preserved for a newer reader. CI runs `file-backend.json` 1.1.0, vendored byte for byte and sha256-pinned, on every read path with the clock frozen at each vector's `reader_now_unix_seconds`, and the backend expires an entry at `now >= expiry`, in [cachekit-ts#212](https://github.com/cachekit-io/cachekit-ts/pull/212), since npm 0.2.1; `1.2.0` in [cachekit-ts#229](https://github.com/cachekit-io/cachekit-ts/pull/229), since 0.2.1, whose two expired entries with an unknown flag or reserved value miss and leave the file byte-identical. Before it, the File tests used inline py-generated vectors and served an entry at its exact expiry second. **Backend selection / env auto-detection:** Python is the only SDK that auto-detects *which* backend to use from the environment — a single unambiguous selector (`CACHEKIT_API_KEY` → SaaS, `CACHEKIT_REDIS_URL` → Redis, `CACHEKIT_MEMCACHED_SERVERS` → Memcached, `CACHEKIT_FILE_CACHE_DIR` → File; fallback `REDIS_URL`; setting more than one selector raises `ConfigurationError`). With nothing configured, `cachekit` 0.23.0+ caches a plaintext, non-interop decorator in L1 only, per process, with no connection attempt (`tests/unit/test_zero_config_l1.py`; 0.23.0 and 0.24.0 wheels run 2026-10-19); 0.22.0 and earlier fall back to `redis://localhost:6379`, as encrypted and `interop=` decorators still do ([cachekit-py#580](https://github.com/cachekit-io/cachekit-py/pull/580)). Rust `CacheKit::from_env()` reads SaaS credentials only (`CACHEKIT_API_KEY` / `CACHEKIT_API_URL` / `CACHEKIT_MASTER_KEY` / `CACHEKIT_PREVIOUS_MASTER_KEYS` / `CACHEKIT_DEFAULT_TTL`; the previous-keys variable since 0.8.0) and always builds the CachekitIO backend — Redis/Memcached/File are wired explicitly via `.backend()`. TypeScript has no backend auto-detection: each preset fixes the backend type and env vars (`CACHEKIT_API_KEY`, `CACHEKIT_MASTER_KEY`) serve only as credential fallbacks. Whether rs/ts should gain selector-style auto-detection is an open product question, not a recorded decision (LAB-273 finding). @@ -185,7 +185,7 @@ The contract a storage backend must satisfy per SDK (bytes in / bytes out; seria | TTL management | ✅ Redis + SaaS + File; Memcached refresh-only (see note) | ✅ Redis + SaaS + File + Workers (`TtlInspectable`); Memcached refresh-only (LAB-429/426) | ✅ Redis + SaaS + File (`TTLBackend`); Memcached refresh-only (LAB-430) | ❌ | | Stale-while-revalidate (client L1) | ⚠️ L1-only mode (`backend=None`) only, and only with a TTL: the `production` and `dev` presets supply one by default from `cachekit` 0.20.0, a bare `@cache(backend=None)` still needs `ttl=`¹⁰ | ✅ Refresh-ahead within the L1 TTL: an entry past `swr_threshold_ratio` of its TTL is still served while one single-flight background refresh runs, and is never served after the TTL (LAB-728)¹⁰ ¹³ | ✅ `getWithSwr` — refresh-ahead within the L1 TTL: version tokens + background refresh, `maxConcurrentRefreshes` cap, never served after the TTL¹⁰; on Workers requires a bound `ExecutionContext` (see [Cache Backends](#cache-backends) note ¹) | ❌ | | Stale-while-revalidate (server stale-grace) | ✅ CachekitIO backend, PyPI 0.14.0+ ([cachekit-py#228](https://github.com/cachekit-io/cachekit-py/pull/228); wheel inspected 2026-10-05): a PUT carries `X-CacheKit-Stale-TTL` from an explicit `stale_ttl=` or `@cache.io`'s default window, and a GET labelled `stale` is served at once and revalidated in the background under the lock, with a contested lock serving stale without recompute (`tests/unit/test_swr_decorator.py`). `stale_ttl` needs the CachekitIO backend known at decoration and fails there otherwise; a sub-second window is not rounded (see the SaaS API row) | ❌ | ❌ | ❌ | -| 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) | ❌ | ❌ | +| 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 | ❌ | > [!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 ¹¹). @@ -258,11 +258,11 @@ The four shared preset names configure **different things per SDK**. Each cell i | Semantic | Python | Rust | TypeScript | | :--- | :--- | :--- | :--- | | Preset TTL defaults | 300 / 600 / 600 / 3 600 s; explicit `ttl=` overrides — `cachekit` 0.20.0+ (wheel inspected 2026-10-03); **none — entries never expire** unless `ttl=` is passed through 0.19.0 | 300 / 600 / 600 / 3 600 s (`minimal_defaults`, `CacheKit::production`, `secure_defaults`, `CacheKit::io` in `intents.rs`) | 300 / 600 / 600 / 3 600 s | -| `minimal`: L1 | **on** (SWR / invalidation off) | **on** (SWR / invalidation off) — `cachekit-rs` 0.8.0+ (`.l1_capacity(1000)`, `.swr_enabled(false)`, `minimal_defaults` in `intents.rs`; `.crate` inspected 2026-09-30); **off** on 0.7.0 and earlier (`no_l1()`) | **on** (SWR / invalidation off by default). Through npm 0.1.5 (`.tgz` inspected 2026-10-06) `createMinimal` (`intents-core.ts`) forces both off over an explicit `l1.swrEnabled` / `l1.namespaceIndex`, against [PRE-48](spec/intent-presets.md#pre-48); [cachekit-ts#226](https://github.com/cachekit-io/cachekit-ts/pull/226) makes the explicit value win, unreleased | +| `minimal`: L1 | **on** (SWR / invalidation off) | **on** (SWR / invalidation off) — `cachekit-rs` 0.8.0+ (`.l1_capacity(1000)`, `.swr_enabled(false)`, `minimal_defaults` in `intents.rs`; `.crate` inspected 2026-09-30); **off** on 0.7.0 and earlier (`no_l1()`) | **on** (SWR / invalidation off by default). From npm 0.2.1 an explicit `l1.swrEnabled` / `l1.namespaceIndex` wins, per [PRE-48](spec/intent-presets.md#pre-48) ([cachekit-ts#226](https://github.com/cachekit-io/cachekit-ts/pull/226); `createMinimal` in `dist/intents-core.js`, `.tgz` inspected 2026-10-24; `src/intents.test.ts` passes at tag `cachekit-v0.2.1`). Through 0.1.5 (`.tgz` inspected 2026-10-06) `createMinimal` forces both off over the explicit value | | `minimal`: integrity checksums | **off** (`integrity_checking=False`) | n/a — values carry no envelope | **on** (ByteStorage on by default) | | `minimal`: reliability | circuit breaker / timeout off, backpressure on | **off** — the `reliability` layer is only attached by `production`/`secure`¹¹/`io` | circuit breaker neutered (∞ threshold), no retry, degradation off | | Encrypted preset | `.secure(master_key=…)` — hex string, ≥64 hex chars (exactly 64 recommended); falls back to `CACHEKIT_MASTER_KEY` | `::secure(url, master_key_hex)`¹¹ — hex string, ≥ 32 B (exactly 32 recommended); `::secure_from_env(url)` reads `CACHEKIT_MASTER_KEY` — `cachekit-rs` 0.8.0+ (`.crate` inspected 2026-09-30); raw bytes, argument only, through 0.7.0 | `.secure({ masterKey })` — hex; falls back to `CACHEKIT_MASTER_KEY` | -| Tenancy on the encrypted preset | `tenant_extractor` callable, `single_tenant_mode`, `fail_closed` tri-state | fixed `"default"` tenant (override via builder) | `tenantId` string. Through npm 0.1.5 (`.tgz` executed 2026-10-06) an empty one is accepted at construction, and key derivation then fails on every operation that encrypts, inside the reliability executor: with `reliability.degradation: false` `set()` and `wrap()` throw `EncryptionError`; with the default `true` nothing raises, every write is skipped and `wrap()` re-runs the function on every call, so nothing is ever cached; [cachekit-ts#226](https://github.com/cachekit-io/cachekit-ts/pull/226) rejects it at construction with `ConfigurationError`, unreleased | +| Tenancy on the encrypted preset | `tenant_extractor` callable, `single_tenant_mode`, `fail_closed` tri-state | fixed `"default"` tenant (override via builder) | `tenantId` string. Through npm 0.1.5 (`.tgz` executed 2026-10-06) an empty one is accepted at construction, and key derivation then fails on every operation that encrypts, inside the reliability executor: with `reliability.degradation: false` `set()` and `wrap()` throw `EncryptionError`; with the default `true` nothing raises, every write is skipped and `wrap()` re-runs the function on every call, so nothing is ever cached; from 0.2.1 ([cachekit-ts#226](https://github.com/cachekit-io/cachekit-ts/pull/226); `dist/encryption/manager-core.js:79`, `.tgz` inspected 2026-10-24; `src/encryption/manager-core.test.ts` passes at tag `cachekit-v0.2.1`) it is rejected at construction with `ConfigurationError` | | `io`: credentials | `api_key` argument **or** `CACHEKIT_API_KEY`; `backend=` raises `ConfigurationError` — `cachekit` 0.20.0+; env only through 0.19.0 | `api_key` argument **or** `CACHEKIT_API_KEY` via `io_from_env()`, which reads no other variable — `cachekit-rs` 0.8.0+; argument only through 0.7.0 | `apiKey` option **or** `CACHEKIT_API_KEY` | | `CACHEKIT_MASTER_KEY` auto-enables encryption | only `@cache.secure` (key fallback) — `cachekit` 0.21.0+. In `cachekit` 0.21.0, every other preset except `.local` also raises `ConfigurationError` at construction when a key is present and no intent is stated (no `encryption=`, or an `EncryptionConfig` without `enabled=`). That error is [migration step 3](spec/intent-presets.md#encryption-activation)'s one-release transitional gate: the spec's steady state constructs such a cache as plaintext, so state `encryption=` rather than rely on the error. Through 0.20.0: every serialized preset that stated no intent and was passed neither `master_key=` nor `tenant_extractor=` | only `CacheKit::from_env()` and, from 0.8.0, `::secure_from_env(url)` | only `createCache.secure()` | @@ -293,14 +293,14 @@ its spec: | Requirement | Python | Rust | TypeScript | PHP | | :--- | :---: | :---: | :---: | :---: | | Key generation (Blake2b) | ✅ Compliant | N/A auto mode¹⁴ — interop/v1 keygen ✅ merged ([#33](https://github.com/cachekit-io/cachekit-rs/pull/33)); `#[cachekit]` mints interop keys ([#35](https://github.com/cachekit-io/cachekit-rs/pull/35)) | ✅ Compliant | ⚠️ Untested | -| Wire format (ByteStorage) | ⚠️ Compliant¹⁵ except the envelope pre-scan: payload decodes are pre-scanned (`unpackb_bounded`), but the envelope goes through `cachekit-core`'s `ByteStorage::retrieve`, which has no [Retrieve Flow](spec/wire-format.md#retrieve-flow) step-2 pre-scan in any published core; it is on core `main` ([cachekit-core#80](https://github.com/cachekit-io/cachekit-core/pull/80)), unreleased, and this cell flips when cachekit-py bumps to a core release that carries it; ✅ runs all six `wire-format.json` 1.3.0 [Reject vectors](spec/wire-format.md#reject-vectors) through `ByteStorage.retrieve` since 0.21.0 ([cachekit-py#448](https://github.com/cachekit-io/cachekit-py/pull/448); `TestRejectVectors` in `tests/unit/protocol/test_envelope_wire_vectors.py`), asserting the error each row names, except that core shares one error between its zero-length and ratio checks, so the zero-length vector asserts the ratio message; all fourteen `wire-format.json` 1.4.0 reject vectors in [cachekit-py#550](https://github.com/cachekit-io/cachekit-py/pull/550), unreleased, each asserting the error its row names (the five step-2 typed-decode vectors the typed-decode error, `reject_original_size_sign_bit` the range-checked `u32` decode), with `reject_envelope_slots_overclaim` a strict expected failure (WIRE-9) until cachekit-py pins a core release with the pre-scan, and both `payload_reject_vectors` asserting the structural guard's own error after the envelope read; ⚠️ the allocation bound for the size-cap and ratio vectors needs a probe on `cachekit-core`'s allocator, which no core release has | 🚧 unreleased — Canonical (`cachekit-core`), unused for stored values¹⁵; step-2 pre-scan on core `main` ([cachekit-core#80](https://github.com/cachekit-io/cachekit-core/pull/80)): `ByteStorage::retrieve` and `validate` pre-scan the envelope bytes before the typed `rmp_serde::from_slice` into `StorageEnvelope`; absent from crates.io 0.6.0 | ⚠️ Compliant except the envelope pre-scan: payload decodes are pre-scanned, but the envelope goes through `cachekit-core`'s `ByteStorage::retrieve` (`unpack` in both `cachekit-core-ts` and `cachekit-core-wasm`), which has no step-2 pre-scan in any published core; it is on core `main` ([cachekit-core#80](https://github.com/cachekit-io/cachekit-core/pull/80)), unreleased, and this cell flips when both bindings move to a core release that carries it; 🚧 unreleased — on `main`, runs all fourteen `wire-format.json` 1.4.0 [Reject vectors](spec/wire-format.md#reject-vectors) on the envelope read path, asserting the error each row names, in the Node lane (`packages/cachekit/test/protocol/wire-format.protocol.test.ts`) and the Workers lane (`packages/cachekit/test/workers/wire-format.workers.test.ts`) ([cachekit-ts#198](https://github.com/cachekit-io/cachekit-ts/pull/198); 1.4.0 in [cachekit-ts#229](https://github.com/cachekit-io/cachekit-ts/pull/229)), and both [`payload_reject_vectors`](spec/wire-format.md#payload-decode-bounds), refused by the payload pre-scan; `reject_envelope_slots_overclaim` passes in both lanes since [cachekit-ts#232](https://github.com/cachekit-io/cachekit-ts/pull/232) (unreleased): the SDK's own header walk keeps a running total of the declared slots and refuses the vector with the envelope pre-scan's own error ([WIRE-9](spec/wire-format.md#wire-9)), before element 1's type check and before core sees the bytes. The walk allocates nothing and admits only complete envelopes; ⚠️ the allocation bound for the size-cap and ratio vectors needs a probe on `cachekit-core`'s allocator, which no core release has | ⚠️ Untested | +| Wire format (ByteStorage) | ⚠️ Compliant¹⁵ except the envelope pre-scan: payload decodes are pre-scanned (`unpackb_bounded`), but the envelope goes through `cachekit-core`'s `ByteStorage::retrieve`, which has no [Retrieve Flow](spec/wire-format.md#retrieve-flow) step-2 pre-scan in any published core; it is on core `main` ([cachekit-core#80](https://github.com/cachekit-io/cachekit-core/pull/80)), unreleased, and this cell flips when cachekit-py bumps to a core release that carries it; ✅ runs all six `wire-format.json` 1.3.0 [Reject vectors](spec/wire-format.md#reject-vectors) through `ByteStorage.retrieve` since 0.21.0 ([cachekit-py#448](https://github.com/cachekit-io/cachekit-py/pull/448); `TestRejectVectors` in `tests/unit/protocol/test_envelope_wire_vectors.py`), asserting the error each row names, except that core shares one error between its zero-length and ratio checks, so the zero-length vector asserts the ratio message; all fourteen `wire-format.json` 1.4.0 reject vectors in [cachekit-py#550](https://github.com/cachekit-io/cachekit-py/pull/550), unreleased, each asserting the error its row names (the five step-2 typed-decode vectors the typed-decode error, `reject_original_size_sign_bit` the range-checked `u32` decode), with `reject_envelope_slots_overclaim` a strict expected failure (WIRE-9) until cachekit-py pins a core release with the pre-scan, and both `payload_reject_vectors` asserting the structural guard's own error after the envelope read; ⚠️ the allocation bound for the size-cap and ratio vectors needs a probe on `cachekit-core`'s allocator, which no core release has | 🚧 unreleased — Canonical (`cachekit-core`), unused for stored values¹⁵; step-2 pre-scan on core `main` ([cachekit-core#80](https://github.com/cachekit-io/cachekit-core/pull/80)): `ByteStorage::retrieve` and `validate` pre-scan the envelope bytes before the typed `rmp_serde::from_slice` into `StorageEnvelope`; absent from crates.io 0.6.0 | ⚠️ Compliant except the envelope pre-scan: payload decodes are pre-scanned, but the envelope goes through `cachekit-core`'s `ByteStorage::retrieve` (`unpack` in both `cachekit-core-ts` and `cachekit-core-wasm`), which has no step-2 pre-scan in `cachekit-core` 0.6.0, the core both npm 0.2.1 bindings embed (`strings` on every `cachekit-core-ts` 0.2.0 `.node` and the `cachekit-core-wasm` 0.2.0 `.wasm`, 2026-10-24); the pre-scan ships in `cachekit-core` 0.7.0 ([cachekit-core#80](https://github.com/cachekit-io/cachekit-core/pull/80)), and this cell flips when both bindings move to 0.7.0 or later; ✅ since npm 0.2.1 (PRs in the `cachekit-v0.2.1` tag's history, checked 2026-10-24; the Node lane passes at that tag), runs all fourteen `wire-format.json` 1.4.0 [Reject vectors](spec/wire-format.md#reject-vectors) on the envelope read path, asserting the error each row names, in the Node lane (`packages/cachekit/test/protocol/wire-format.protocol.test.ts`) and the Workers lane (`packages/cachekit/test/workers/wire-format.workers.test.ts`) ([cachekit-ts#198](https://github.com/cachekit-io/cachekit-ts/pull/198); 1.4.0 in [cachekit-ts#229](https://github.com/cachekit-io/cachekit-ts/pull/229)), and both [`payload_reject_vectors`](spec/wire-format.md#payload-decode-bounds), refused by the payload pre-scan; `reject_envelope_slots_overclaim` passes in both lanes since 0.2.1 ([cachekit-ts#232](https://github.com/cachekit-io/cachekit-ts/pull/232); `dist/serialization/envelope.js`): the SDK's own header walk keeps a running total of the declared slots and refuses the vector with the envelope pre-scan's own error ([WIRE-9](spec/wire-format.md#wire-9)), before element 1's type check and before core sees the bytes. The walk allocates nothing and admits only complete envelopes; ⚠️ the allocation bound for the size-cap and ratio vectors is unasserted: `cachekit-core` 0.7.0 asserts it in its own unit tests (`src/read_allocation_probe.rs`), but both 0.2.1 bindings embed 0.6.0 | ⚠️ Untested | | Storage container (auto mode)¹⁵ | CK v3 frame (Python-internal) | Plain MessagePack (`rmp` named) — no envelope; ⚠️ decodes cachekit-ts's bare ByteStorage envelope ([WIRE-21](spec/wire-format.md#wire-21)), in every release and on `main`¹⁵ | Bare ByteStorage envelope (default) | — | -| 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 [cachekit-py#550](https://github.com/cachekit-io/cachekit-py/pull/550), unreleased | ❌ 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.1.5 (`.tgz` inspected 2026-10-07): the default path revives none, and writes a `Date` as its ISO string; interop mode revives only `__datetime__`, leaving `__date__` and `__time__` as maps, since JavaScript has no date-only or time-only type; unchanged on `main`, where 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), unreleased) | ⚠️ Untested | +| 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 [cachekit-py#550](https://github.com/cachekit-io/cachekit-py/pull/550), unreleased | ❌ 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:378-379`); 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 [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 — 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 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) 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 in [cachekit-py#537](https://github.com/cachekit-io/cachekit-py/pull/537), unreleased, 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 in [cachekit-py#537](https://github.com/cachekit-io/cachekit-py/pull/537), unreleased, 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) — ⚠️ 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; `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 in [cachekit-py#550](https://github.com/cachekit-io/cachekit-py/pull/550), unreleased; `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 current `python-frame.json` revision, each vendored byte for byte (sha256-pinned) + CI-executed in [cachekit-py#561](https://github.com/cachekit-io/cachekit-py/pull/561), unreleased — ⚠️ 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), unreleased, 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), unreleased, 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), 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; `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), unreleased, 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), unreleased, 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), unreleased: 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 — 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; 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), unreleased: 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), unreleased; `1.2.0` (the `incomplete` reason) in [cachekit-ts#224](https://github.com/cachekit-io/cachekit-ts/pull/224), unreleased, 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 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); `1.4.0` re-vendored byte for byte (sha256-pinned) in [cachekit-ts#229](https://github.com/cachekit-io/cachekit-ts/pull/229), unreleased, 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), unreleased, 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 current revision re-vendored byte for byte (sha256-pinned) in [cachekit-ts#230](https://github.com/cachekit-io/cachekit-ts/pull/230), unreleased, 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), unreleased; `1.3.0` re-vendored byte for byte (sha256-pinned) in [cachekit-ts#222](https://github.com/cachekit-io/cachekit-ts/pull/222), unreleased, 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), unreleased: 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 | +| 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) | ✅ Compliant (CachekitIO backend) from npm 0.2.1 on the TTL and lock-id rules this row tracked (`.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`); a lock response is a lease only when its `lock_id` is a non-empty string ([API-65](spec/saas-api.md#api-65); [cachekit-ts#215](https://github.com/cachekit-io/cachekit-ts/pull/215); `dist/backends/cachekitio-lockable.js`); `src/backends/cachekitio-ttl.test.ts` and `src/backends/cachekitio-lockable.test.ts` pass at tag `cachekit-v0.2.1`. ⚠️ Through 0.1.5 (`.tgz` inspected 2026-10-05) a PUT sends only the legacy `X-TTL` ([API-44](spec/saas-api.md#api-44)), unrounded, omits it for `ttl` 0, storing the entry with no expiry, and sends a TTL over 2,592,000 for the server to reject with `400`; and a `200` lock response whose `lock_id` is absent, empty or not a string counts as a lease, so single-flight is off and the release sends `undefined` as the lock id | ❌ Not implemented | +| SaaS API — cache-key path encoding ([spec](spec/saas-api.md#cache-key-path-encoding)) | ✅ Compliant 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 — 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 in [cachekit-py#537](https://github.com/cachekit-io/cachekit-py/pull/537), unreleased, 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 in [cachekit-py#537](https://github.com/cachekit-io/cachekit-py/pull/537), unreleased, 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) — ⚠️ 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; `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 in [cachekit-py#550](https://github.com/cachekit-io/cachekit-py/pull/550), unreleased; `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 current `python-frame.json` revision, each vendored byte for byte (sha256-pinned) + CI-executed in [cachekit-py#561](https://github.com/cachekit-io/cachekit-py/pull/561), unreleased — ⚠️ 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), unreleased, 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), unreleased, 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), 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; `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), unreleased, 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), unreleased, 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), unreleased: 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, 760 tests); fixture 1.1.0 since npm 0.2.1 ([cachekit-ts#143](https://github.com/cachekit-io/cachekit-ts/pull/143); `'nsapi'` at `dist/serialization/interop.js:39` and `:118` 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 '..'` at `dist/serialization/interop.js:114`, 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 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) (`cachekit-core` 0.7.0+; both npm 0.2.1 bindings embed 0.6.0) 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), 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 current 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 | > [!NOTE] @@ -308,9 +308,9 @@ 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). Because `cachekit-rs` reads plain MessagePack, it decodes another SDK's container that is one well-formed MessagePack document: cachekit-ts's bare ByteStorage envelope (`python-frame.json` `bare_envelope_fed_to_frame_reader`) decodes on every read path of a client without encryption, and a read fails only when the requested type's shape does not fit. That is a WIRE-21 violation for that container, and it holds in every release and on `main`; [`conformance/coverage.md`](conformance/coverage.md) counts cachekit-rs's WIRE-21 vectors as held because they are vendored, not because cachekit-rs refuses them all. crates.io 0.10.0 also decodes a CK frame in auto mode, as the integer 67, a second WIRE-21 violation (its interop mode names the frame); on `main`, auto mode rejects the trailing bytes since [cachekit-rs#140](https://github.com/cachekit-io/cachekit-rs/pull/140), unreleased. 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 `interop-mode.json` is `1.3.0` (44 key, 6 value, 34 error, 6 reader accept and 1 reader reject vectors), which adds key and error vectors for the segment grammar on both segments, out-of-model values nested in a list, a map value or a set, map keys a collator or a length-first sort orders wrongly, a `__proto__` key, strings that start with U+FEFF, integers a float64 cannot hold and small integers passed as `{"$int": …}`, and the two reader groups: documents a value reader must accept though no canonical writer emits them, and a trailing byte it must reject. All three SDKs vendor `1.3.0` byte for byte, in [cachekit-py#537](https://github.com/cachekit-io/cachekit-py/pull/537), [cachekit-rs#152](https://github.com/cachekit-io/cachekit-rs/pull/152) and [cachekit-ts#224](https://github.com/cachekit-io/cachekit-ts/pull/224) (unreleased). This repo's `wire-format.json` is `1.4.0` (LAB-8147): both sides of the `bin8` → `bin16` edge, three constructed envelopes on the `bin16` → `bin32` edge (one a legacy `array32`), eight more `reject_vectors` (step 2's typed decode and pre-scan, an `original_size` with the sign bit set, and a ratio a float32 rounds to exactly 1000), and two new groups: `payload_reject_vectors` and `temporal_sentinel_vectors`. cachekit-py vendors `1.4.0` byte for byte and runs every vector in [cachekit-py#550](https://github.com/cachekit-io/cachekit-py/pull/550) (unreleased), `reject_envelope_slots_overclaim` as a strict expected failure until it pins a `cachekit-core` release with the envelope pre-scan; cachekit-ts vendors it byte for byte in [cachekit-ts#229](https://github.com/cachekit-io/cachekit-ts/pull/229) (unreleased), with one gap held as an expected failure (see the Temporal sentinels row); `reject_envelope_slots_overclaim`, the other, passes since [cachekit-ts#232](https://github.com/cachekit-io/cachekit-ts/pull/232) (unreleased). `1.3.0` added 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 `file-backend.json` is `1.2.0` (LAB-8147), which adds an empty and a binary payload, flag bit 15, reserved values 1 and 255, and two expired entries with an unknown flag or reserved value, which a reader keeps; cachekit-py vendors `1.2.0` byte for byte in [cachekit-py#550](https://github.com/cachekit-io/cachekit-py/pull/550) and cachekit-rs in [cachekit-rs#158](https://github.com/cachekit-io/cachekit-rs/pull/158) (both unreleased), and cachekit-ts vendors `1.2.0` in [cachekit-ts#229](https://github.com/cachekit-io/cachekit-ts/pull/229) (unreleased). `python-frame.json` (LAB-8147) adds frame-check boundary vectors, other SDKs' containers and an `encrypted_read_vectors` group; cachekit-py vendors it byte for byte and runs every frame, error and encrypted read vector in [cachekit-py#550](https://github.com/cachekit-io/cachekit-py/pull/550), and its current revision in [cachekit-py#561](https://github.com/cachekit-io/cachekit-py/pull/561) (both unreleased); cachekit-rs vendors it byte for byte in [cachekit-rs#158](https://github.com/cachekit-io/cachekit-rs/pull/158), and the current revision in [cachekit-rs#160](https://github.com/cachekit-io/cachekit-rs/pull/160) (both unreleased); cachekit-ts vendors it byte for byte for its container vectors in [cachekit-ts#229](https://github.com/cachekit-io/cachekit-ts/pull/229), and its current revision in [cachekit-ts#230](https://github.com/cachekit-io/cachekit-ts/pull/230) (both unreleased). 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.5.0` (`1.4.0` adds a second master key input accept row and two StandardSerializer AAD shapes; `1.5.0` adds the `aad_reject_vectors`, `decrypted_container` and `keyring.configuration` tables). `1.3.0` added 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 runs the `1.3.0` accept and hex reject rows since [cachekit-ts#222](https://github.com/cachekit-io/cachekit-ts/pull/222) and vendors `1.5.0` byte for byte in [cachekit-ts#230](https://github.com/cachekit-io/cachekit-ts/pull/230) (both unreleased). cachekit-rs vendors `1.5.0` byte for byte in [cachekit-rs#160](https://github.com/cachekit-io/cachekit-rs/pull/160) (unreleased). cachekit-py vendors `1.5.0` byte for byte in [cachekit-py#561](https://github.com/cachekit-io/cachekit-py/pull/561) (unreleased) and runs the rows the Test vectors in CI row lists, with two `keyring.configuration` cases as strict expected failures naming [ENC-9](spec/encryption.md#enc-9). This repo's `decode-bounds.json` is `1.2.0` (19 reject, 3 accept), which adds `incomplete_within_slot_budget` and its map twin, documents cut short inside the slot budget, under a new `incomplete` reject reason. All three SDKs vendor `1.2.0` byte for byte, in [cachekit-py#537](https://github.com/cachekit-io/cachekit-py/pull/537), [cachekit-rs#152](https://github.com/cachekit-io/cachekit-rs/pull/152) and [cachekit-ts#224](https://github.com/cachekit-io/cachekit-ts/pull/224) (unreleased). Their previous copies are `1.1.0` (17 reject, 3 accept): cachekit-py's released since 0.20.0 ([cachekit-py#363](https://github.com/cachekit-io/cachekit-py/pull/363)), cachekit-rs's released since 0.8.0 ([cachekit-rs#93](https://github.com/cachekit-io/cachekit-rs/pull/93)), and cachekit-ts's in [cachekit-ts#152](https://github.com/cachekit-io/cachekit-ts/pull/152) (unreleased); the SDKs' 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.3.0` (21 key vectors, the first 10 byte-identical to the v0.5.0 originals): `1.2.0` adds four derived serializer codes, `1.3.0` 4 `error_vectors` and 5 `serializer_object_vectors`. 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)), and `1.3.0` byte for byte in [cachekit-py#561](https://github.com/cachekit-io/cachekit-py/pull/561) (unreleased), running every vector. +> ¹⁶ "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 `interop-mode.json` is `1.3.0` (44 key, 6 value, 34 error, 6 reader accept and 1 reader reject vectors), which adds key and error vectors for the segment grammar on both segments, out-of-model values nested in a list, a map value or a set, map keys a collator or a length-first sort orders wrongly, a `__proto__` key, strings that start with U+FEFF, integers a float64 cannot hold and small integers passed as `{"$int": …}`, and the two reader groups: documents a value reader must accept though no canonical writer emits them, and a trailing byte it must reject. All three SDKs vendor `1.3.0` byte for byte, in [cachekit-py#537](https://github.com/cachekit-io/cachekit-py/pull/537), [cachekit-rs#152](https://github.com/cachekit-io/cachekit-rs/pull/152) and [cachekit-ts#224](https://github.com/cachekit-io/cachekit-ts/pull/224) (the py and rs PRs unreleased; ts since npm 0.2.1). This repo's `wire-format.json` is `1.4.0` (LAB-8147): both sides of the `bin8` → `bin16` edge, three constructed envelopes on the `bin16` → `bin32` edge (one a legacy `array32`), eight more `reject_vectors` (step 2's typed decode and pre-scan, an `original_size` with the sign bit set, and a ratio a float32 rounds to exactly 1000), and two new groups: `payload_reject_vectors` and `temporal_sentinel_vectors`. cachekit-py vendors `1.4.0` byte for byte and runs every vector in [cachekit-py#550](https://github.com/cachekit-io/cachekit-py/pull/550) (unreleased), `reject_envelope_slots_overclaim` as a strict expected failure until it pins a `cachekit-core` release with the envelope pre-scan; cachekit-ts vendors it byte for byte in [cachekit-ts#229](https://github.com/cachekit-io/cachekit-ts/pull/229) (since 0.2.1), with one gap held as an expected failure (see the Temporal sentinels row); `reject_envelope_slots_overclaim`, the other, passes since [cachekit-ts#232](https://github.com/cachekit-io/cachekit-ts/pull/232) (since 0.2.1). `1.3.0` added 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 since 0.2.1 ([cachekit-ts#198](https://github.com/cachekit-io/cachekit-ts/pull/198)), each through its envelope read path; `cachekit-rs` stores no envelope¹⁵. `cachekit-core` runs them through `ByteStorage::retrieve` since 0.7.0, which vendors `wire-format.json` 1.3.0 (`tests/wire_format_vectors.rs`), and asserts the allocation bound of their size-cap and ratio vectors in its unit tests (`src/read_allocation_probe.rs`); both pass from the published `.crate`, run 2026-10-24. 0.6.0 vendors 1.1.0 and has no probe, so neither SDK asserts that bound yet. This repo's `file-backend.json` is `1.2.0` (LAB-8147), which adds an empty and a binary payload, flag bit 15, reserved values 1 and 255, and two expired entries with an unknown flag or reserved value, which a reader keeps; cachekit-py vendors `1.2.0` byte for byte in [cachekit-py#550](https://github.com/cachekit-io/cachekit-py/pull/550) and cachekit-rs in [cachekit-rs#158](https://github.com/cachekit-io/cachekit-rs/pull/158) (both unreleased), and cachekit-ts vendors `1.2.0` in [cachekit-ts#229](https://github.com/cachekit-io/cachekit-ts/pull/229) (since 0.2.1). `python-frame.json` (LAB-8147) adds frame-check boundary vectors, other SDKs' containers and an `encrypted_read_vectors` group; cachekit-py vendors it byte for byte and runs every frame, error and encrypted read vector in [cachekit-py#550](https://github.com/cachekit-io/cachekit-py/pull/550), and its current revision in [cachekit-py#561](https://github.com/cachekit-io/cachekit-py/pull/561) (both unreleased); cachekit-rs vendors it byte for byte in [cachekit-rs#158](https://github.com/cachekit-io/cachekit-rs/pull/158), and the current revision in [cachekit-rs#160](https://github.com/cachekit-io/cachekit-rs/pull/160) (both unreleased); cachekit-ts vendors it byte for byte for its container vectors in [cachekit-ts#229](https://github.com/cachekit-io/cachekit-ts/pull/229), and its current revision in [cachekit-ts#230](https://github.com/cachekit-io/cachekit-ts/pull/230) (both since 0.2.1). 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) (the py and rs PRs unreleased; ts since npm 0.2.1). 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 (the py and rs PRs unreleased; ts since 0.2.1). This repo's `encryption.json` is `1.5.0` (`1.4.0` adds a second master key input accept row and two StandardSerializer AAD shapes; `1.5.0` adds the `aad_reject_vectors`, `decrypted_container` and `keyring.configuration` tables). `1.3.0` added 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 runs the `1.3.0` accept and hex reject rows since [cachekit-ts#222](https://github.com/cachekit-io/cachekit-ts/pull/222) and vendors `1.5.0` byte for byte in [cachekit-ts#230](https://github.com/cachekit-io/cachekit-ts/pull/230) (both since 0.2.1). cachekit-rs vendors `1.5.0` byte for byte in [cachekit-rs#160](https://github.com/cachekit-io/cachekit-rs/pull/160) (unreleased). cachekit-py vendors `1.5.0` byte for byte in [cachekit-py#561](https://github.com/cachekit-io/cachekit-py/pull/561) (unreleased) and runs the rows the Test vectors in CI row lists, with two `keyring.configuration` cases as strict expected failures naming [ENC-9](spec/encryption.md#enc-9). This repo's `decode-bounds.json` is `1.2.0` (19 reject, 3 accept), which adds `incomplete_within_slot_budget` and its map twin, documents cut short inside the slot budget, under a new `incomplete` reject reason. All three SDKs vendor `1.2.0` byte for byte, in [cachekit-py#537](https://github.com/cachekit-io/cachekit-py/pull/537), [cachekit-rs#152](https://github.com/cachekit-io/cachekit-rs/pull/152) and [cachekit-ts#224](https://github.com/cachekit-io/cachekit-ts/pull/224) (the py and rs PRs unreleased; ts since npm 0.2.1). Their previous copies are `1.1.0` (17 reject, 3 accept): cachekit-py's released since 0.20.0 ([cachekit-py#363](https://github.com/cachekit-io/cachekit-py/pull/363)), cachekit-rs's released since 0.8.0 ([cachekit-rs#93](https://github.com/cachekit-io/cachekit-rs/pull/93)), and cachekit-ts's in [cachekit-ts#152](https://github.com/cachekit-io/cachekit-ts/pull/152) (since 0.2.1); the SDKs' 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` since 0.7.0 ([cachekit-core#80](https://github.com/cachekit-io/cachekit-core/pull/80); `tests/decode_bounds_vectors.rs` passes from the published `.crate`, run 2026-10-24). `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.3.0` (21 key vectors, the first 10 byte-identical to the v0.5.0 originals): `1.2.0` adds four derived serializer codes, `1.3.0` 4 `error_vectors` and 5 `serializer_object_vectors`. 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)), and `1.3.0` byte for byte in [cachekit-py#561](https://github.com/cachekit-io/cachekit-py/pull/561) (unreleased), running every vector. > -> ¹⁷ 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. +> ¹⁷ 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. TypeScript's `0.2.1+` floors follow the same rule: npm 0.2.0 (2026-10-20) carries every change those cells cite, but it pins `@cachekit-io/cachekit-core-ts` 0.1.3, which was never published, so it does not install, and npm marks it deprecated in favour of 0.2.1 (registry read 2026-10-24). Do not "correct" a TypeScript floor to 0.2.0. --- @@ -355,10 +355,12 @@ its spec: | cachekit-rs | 0.6.0 (crates.io, 2026-08-03T14:58Z) | `0.4` per the published `Cargo.toml` ([#53](https://github.com/cachekit-io/cachekit-rs/pull/53)) | ✅ since 0.6.0 | | cachekit-ts — NAPI path | 0.1.5 (npm) → exact pin `@cachekit-io/cachekit-core-ts@0.1.2` | **0.2.0** (all five platform `.node` binaries) | ❌ legacy only | | cachekit-ts — Workers path | 0.1.5 (npm) → exact pin `@cachekit-io/cachekit-core-wasm@0.1.1` | **0.3.0** | ❌ legacy only | +| cachekit-ts — NAPI path | 0.2.1 (npm) → exact pin `@cachekit-io/cachekit-core-ts@0.2.0` | **0.6.0** (all five platform `.node` binaries; `strings`, 2026-10-24) | ✅ since 0.2.1 | +| cachekit-ts — Workers path | 0.2.1 (npm) → exact pin `@cachekit-io/cachekit-core-wasm@0.2.0` | **0.6.0** | ✅ since 0.2.1 | - **TypeScript has not shipped the flip on either path.** `@cachekit-io/cachekit@0.1.5` (published 2026-08-03T11:15Z) carries dependency pins byte-identical to 0.1.4's — exact, caret-free pins on `@cachekit-io/cachekit-core-ts@0.1.2` (published 2026-05-17, before core 0.3.0 existed) and `@cachekit-io/cachekit-core-wasm@0.1.1`. `@cachekit-io/cachekit-core-wasm@0.1.2` does embed core 0.4.0, but it published at 14:28Z — **3h13m after** ts 0.1.5 — so no published `cachekit` release pins it. [cachekit-ts#91](https://github.com/cachekit-io/cachekit-ts/pull/91) bumped the source pin and is in 0.1.5's tree, but the NAPI addon it consumes was never republished, so the shipped binary is unchanged. + **TypeScript ships the flip on both paths from npm 0.2.1** (checked 2026-10-24): `ByteStorage.pack` on the published `cachekit-core-ts` 0.2.0 linux-x64 binary and on the `cachekit-core-wasm` 0.2.0 module emits `compressed_data` as msgpack `bin` (`c4`), where `cachekit-core-ts` 0.1.2 and `cachekit-core-wasm` 0.1.1 emit the legacy array (`dc`); the SDK writes its envelope through that `ByteStorage` on both paths. 0.2.0 carries the same pins but does not install (note ¹⁷). **Through 0.1.5 it shipped on neither path.** `@cachekit-io/cachekit@0.1.5` (published 2026-08-03T11:15Z) carries dependency pins byte-identical to 0.1.4's — exact, caret-free pins on `@cachekit-io/cachekit-core-ts@0.1.2` (published 2026-05-17, before core 0.3.0 existed) and `@cachekit-io/cachekit-core-wasm@0.1.1`. `@cachekit-io/cachekit-core-wasm@0.1.2` does embed core 0.4.0, but it published at 14:28Z — **3h13m after** ts 0.1.5 — so no published `cachekit` release pins it. [cachekit-ts#91](https://github.com/cachekit-io/cachekit-ts/pull/91) bumped the source pin and is in 0.1.5's tree, but the NAPI addon it consumes was never republished, so the shipped binary is unchanged. - **This lag is a missing optimisation, not a hazard — there is nothing to sequence.** Because dual-read is mutual (above), a ts instance on core 0.2.0 reads `bin` written by any newer instance, and the legacy envelopes it writes stay readable by everything forever. What TypeScript forgoes until `cachekit-core-ts` is republished is the `bin` encoding's size saving on its own writes (~35% on incompressible payloads, ≤ +1 B on tiny ones). Nor is it a cross-SDK concern: auto-mode stored bytes are SDK-internal and no SDK reads another's ([protocol#11](https://github.com/cachekit-io/protocol/issues/11)). + **This lag is a missing optimisation, not a hazard — there is nothing to sequence.** Because dual-read is mutual (above), a ts instance on core 0.2.0 reads `bin` written by any newer instance, and the legacy envelopes it writes stay readable by everything forever. What TypeScript 0.1.5 and earlier forgo is the `bin` encoding's size saving on its own writes (~35% on incompressible payloads, ≤ +1 B on tiny ones). Nor is it a cross-SDK concern: auto-mode stored bytes are SDK-internal and no SDK reads another's ([protocol#11](https://github.com/cachekit-io/protocol/issues/11)). *An earlier revision of this note claimed core ≤ 0.3.0 readers **reject** `bin` and told operators to sequence the ts republish ahead of any fleet upgrade. That was false in the dangerous direction — it manufactured a migration risk that does not exist — and it contradicted this repo's own `CHANGELOG.md` ("**Not a breaking change** — dual-read is mutual in both directions") and the verified compatibility table in [spec/wire-format.md](spec/wire-format.md#encoding-compatibility-dual-read). Recorded because the operational advice was the visible part.* - Provides: `ByteStorage`, `ZeroKnowledgeEncryptor`, `derive_domain_key`, `derive_tenant_keys` @@ -373,7 +375,7 @@ its spec: - Monorepo: `@cachekit-io/cachekit` (SDK) + `@cachekit-io/cachekit-core-ts` (Rust NAPI bindings) - Redis backend via ioredis, CacheKit SaaS backend via fetch API -- Node-only Memcached (`memjs` optional peer) and File backends via subpath exports; File format is byte-verified against the shared protocol vectors (`file-backend.json`, sha256-pinned, in [cachekit-ts#212](https://github.com/cachekit-io/cachekit-ts/pull/212), unreleased) +- Node-only Memcached (`memjs` optional peer) and File backends via subpath exports; File format is byte-verified against the shared protocol vectors (`file-backend.json`, sha256-pinned, since npm 0.2.1, [cachekit-ts#212](https://github.com/cachekit-io/cachekit-ts/pull/212)) - Encryption via Rust NAPI (AES-256-GCM, HKDF-SHA256, counter-based nonces) - AAD v0x03 compliant with Python cross-SDK test vectors - L1 LRU cache with background refresh, version tokens, namespace invalidation diff --git a/spec/encryption.md b/spec/encryption.md index c5b1ea2..dbb7697 100644 --- a/spec/encryption.md +++ b/spec/encryption.md @@ -351,7 +351,7 @@ configurations an SDK accepts or refuses at load. ## Key Rotation (Keyring) > [!NOTE] -> **Status: implemented in Python, Rust, and TypeScript; released in Python (`cachekit` 0.18.0+) and Rust (`cachekit-rs` 0.8.0+) — TypeScript is on `main` and pending its next release** (published artifacts inspected 2026-09-22, the Rust `.crate`s 2026-09-30 (0.8.0) and 2026-10-03 (0.9.0); decision record: +> **Status: released in Python (`cachekit` 0.18.0+), Rust (`cachekit-rs` 0.8.0+) and TypeScript (`@cachekit-io/cachekit` 0.2.1+; 0.2.0 does not install)** (published artifacts inspected 2026-09-22, the Rust `.crate`s 2026-09-30 (0.8.0) and 2026-10-03 (0.9.0), the npm `.tgz` 2026-10-24; decision record: > [decisions/key-rotation.md](../decisions/key-rotation.md), 2026-07-23; LAB-516). > The shared decrypt helper is `cachekit_core::encryption::keyring::Keyring` > ([cachekit-core#67](https://github.com/cachekit-io/cachekit-core/pull/67)); the SDK From 624c4f0986515eab0d0f6efc1b8374c8a51973ef Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 24 Oct 2026 01:46:38 +1100 Subject: [PATCH 2/2] docs(matrix): address review on the TypeScript 0.2.1 refresh Drop the Python and Rust release status that note 16 newly asserted, scope CACHEKIT_PREVIOUS_MASTER_KEYS to createCache.secure, record that cachekit-ts runs the keyring vectors, and drop line numbers and snapshot counts beside floors. --- changelog.d/20261024_lab-4614.md | 12 +++++++----- sdk-feature-matrix.md | 18 +++++++++--------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/changelog.d/20261024_lab-4614.md b/changelog.d/20261024_lab-4614.md index 72bf612..0dfffa6 100644 --- a/changelog.d/20261024_lab-4614.md +++ b/changelog.d/20261024_lab-4614.md @@ -2,12 +2,14 @@ - `sdk-feature-matrix.md`: every TypeScript claim that called a change "unreleased" or bounded current behaviour "through npm 0.1.5" now names `@cachekit-io/cachekit` 0.2.1 as its floor, or is re-dated against 0.2.1 where 0.2.1 - still lacks the change. Key rotation, hardware-acceleration detection, server-bounded L1 backfill and cache-key path - encoding read ✅ for TypeScript from 0.2.1. The test-vector, wire-format, `minimal` L1, tenancy and SaaS API cells, - notes ¹, ⁴, ⁵, ⁶, ¹⁶ and ¹⁷, and the `bin` rollout note follow. + still lacks the change. Key rotation, hardware-acceleration detection, server-bounded L1 backfill, the SaaS API row + (⚠️ → ✅ on its TTL and lock-id rules) and cache-key path encoding read ✅ for TypeScript from 0.2.1. The + test-vector, wire-format, `minimal` L1 and tenancy cells, notes ¹, ⁴, ⁵, ⁶, ¹⁶ and ¹⁷, and the `bin` rollout note + follow. - Note ¹⁷ records that npm 0.2.0 carries the same changes but does not install, so no floor names it. -- Unchanged, because both 0.2.1 bindings embed `cachekit-core` 0.6.0: TypeScript's envelope pre-scan caveat, its - aarch64 hardware-acceleration false positive, and its temporal-sentinel ❌. Note ⁶ and note ¹⁶ now say that +- Unchanged, because both 0.2.1 bindings embed `cachekit-core` 0.6.0: TypeScript's envelope pre-scan caveat and its + aarch64 hardware-acceleration false positive. TypeScript's temporal-sentinel ❌ also stays, re-dated against 0.2.1; that + gap is in the SDK's own serializer, so a core 0.7.0 republish does not close it. Note ⁶ and note ¹⁶ now say that `cachekit-core` 0.7.0 ships the aarch64 probe, the envelope pre-scan and the allocation probe, and that `cachekit-rs` 0.11.1+ inherits the aarch64 fix. - `spec/encryption.md` and `decisions/key-rotation.md`: the keyring is released in all three SDKs; TypeScript's floor diff --git a/sdk-feature-matrix.md b/sdk-feature-matrix.md index 960ae18..dab26b7 100644 --- a/sdk-feature-matrix.md +++ b/sdk-feature-matrix.md @@ -80,7 +80,7 @@ | HKDF-SHA256 key derivation | ✅ via Rust FFI | ✅ | ✅ via NAPI (Rust) | 🔜 Planned | | Per-tenant key isolation | ✅ | ✅ | ✅ via TenantKeys NAPI | 🔜 Planned | | AAD v0x03 (cache_key binding) | ✅ 0.23.0+ — the AAD `cache_key` is the [logical backend key](spec/encryption.md#additional-authenticated-data-aad), a prefixing backend's key prefix included (`MemcachedBackend` `key_prefix`, the tenant-scoped Redis backend's `t:{tenant}:`); wheel inspected and `tests/critical/test_aad_backend_key_prefix.py` run against the 0.23.0 and 0.24.0 wheels 2026-10-19. Through 0.22.0 that prefix was left out ([cachekit-py#518](https://github.com/cachekit-io/cachekit-py/pull/518)). Interop mode, which refuses prefixing backends, binds the bare key | ✅ | ✅ | ❌ | -| Key rotation | ✅ 0.18.0+ — keyring; derived-key fingerprint selection⁵ | ✅ `cachekit-rs` 0.8.0+ — keyring; sequential current-first attempts; previous keys via `from_env()`, the builder, or `::secure_from_env` from 0.9.0 (0.8.0's `::secure_from_env` ignores them; `.crate`s inspected 2026-09-30 and 2026-10-03)⁵ | ✅ `@cachekit-io/cachekit` 0.2.1+¹⁷ — keyring; sequential current-first attempts; previous keys via `encryption.previousMasterKeys`, `createCache.secure`'s `previousMasterKeys` or `CACHEKIT_PREVIOUS_MASTER_KEYS` (`dist/intents-core.js:89` and `:217-224`; `.tgz` inspected 2026-10-24); absent from 0.1.5⁵ | ❌ | +| Key rotation | ✅ 0.18.0+ — keyring; derived-key fingerprint selection⁵ | ✅ `cachekit-rs` 0.8.0+ — keyring; sequential current-first attempts; previous keys via `from_env()`, the builder, or `::secure_from_env` from 0.9.0 (0.8.0's `::secure_from_env` ignores them; `.crate`s inspected 2026-09-30 and 2026-10-03)⁵ | ✅ `@cachekit-io/cachekit` 0.2.1+¹⁷ — keyring; sequential current-first attempts; previous keys via `encryption.previousMasterKeys`, `createCache.secure`'s `previousMasterKeys`, or `CACHEKIT_PREVIOUS_MASTER_KEYS` (read by `createCache.secure` only; `createSecure` in `dist/intents-core.js`, `.tgz` inspected 2026-10-24); absent from 0.1.5⁵ | ❌ | | **Tamper / wrong-key failure mode** | ⚠️ **fail-OPEN by default** — warn + recompute; switchable with `CACHEKIT_ENCRYPTION_FAIL_CLOSED=true`¹⁸ | ✅ **Fails closed** — `decrypt_or_evict(…)?` drops the L1 copy and propagates (`SecureCache::decrypt_or_evict` in `client.rs`), and `#[cachekit(secure)]` emits no fail-open arm (`fail_open_arm` in `expand`, `cachekit-macros/src/lib.rs`; 0.8.0 crates). From `cachekit-rs` 0.10.0 every read on a client with encryption configured takes that path, plain `get` and `#[cachekit]` without `secure` included | ⚠️ **fail-OPEN on reads, drops writes with only a rate-limited warning, no fail-closed switch**⁸ | — | | **Does the `secure` API enforce encryption?** | ✅ Raises without a key | ✅ `secure_cache()`¹¹ returns `Err` | ✅ **`cache.secure.wrap()` throws `ConfigurationError` at wrap time** on any instance without `encryption` configured — instance and `withExecutionContext(ctx)` view alike (before this check, an unconditional alias for `wrap()`, so on an instance without configured encryption a "secure" registration cached plaintext, CWE-311); see [Intent-preset semantics](#intent-preset-semantics-parity-not-presence) | — | | Hardware acceleration detection | ✅ surfaced (`EncryptionWrapper.hardware_acceleration_enabled`)⁶ | ✅ `cachekit-rs` 0.8.0+ — surfaced (`SecureCache::hardware_acceleration_enabled()`; `.crate` inspected 2026-09-30)⁶ | ✅ `@cachekit-io/cachekit` 0.2.1+ — surfaced (`EncryptionManager.isHardwareAccelerated()`; `.tgz` inspected 2026-10-24); `true` on every aarch64 build, since both bindings embed `cachekit-core` 0.6.0⁶ | N/A | @@ -90,7 +90,7 @@ > AAD v0x03 is required for protocol compliance. SDKs without it cannot safely interoperate with encrypted payloads from compliant SDKs — the auth tag will fail verification. Python, Rust, and TypeScript construction was code-verified byte-identical on 2026-07-21 (LAB-272); the normative byte layout and the frozen `True`/`False` compressed tokens (protocol#12) are defined in [spec/encryption.md](spec/encryption.md#additional-authenticated-data-aad). Python's auto serializers append the optional `original_type` fifth component; Rust, TypeScript, and interop mode always emit exactly four. When encryption is enabled but a stored entry claims plaintext, Python never returns it — the entry is converted to a miss and evicted rather than raising (LAB-241, [cachekit-py#215](https://github.com/cachekit-io/cachekit-py/pull/215)). `cachekit` 0.9.0 through 0.22.0 violates [ENC-2](spec/encryption.md#enc-2) and [ENC-3](spec/encryption.md#enc-3): after a decrypt, `ArrowSerializer` sniffs the plaintext and returns the DataFrame from bare Arrow IPC instead of hard-failing on the mismatch with its configured checksummed container. From 0.23.0 ([cachekit-py#517](https://github.com/cachekit-io/cachekit-py/pull/517)) it is refused (`TestArrowPostDecryptContainer` in `tests/unit/test_decrypt_fail_policy.py`, run against the 0.23.0 and 0.24.0 wheels 2026-10-19): the read fails with `EncryptionError` (corruption class), so it is a miss and the entry is evicted, including under fail-closed. `cachekit` 0.9.0 through 0.22.0 also violates [ENC-2](spec/encryption.md#enc-2) and [ENC-3](spec/encryption.md#enc-3) on a config-drift read: a handler built with `serializer="auto"` (or its alias `"pythonic"`) and encryption off, given an encrypted entry whose stored header names that serializer, decrypts it with its master key (`master_key=` or `CACHEKIT_MASTER_KEY`), then `AutoSerializer` sniffs the plaintext (0.9.0 and 0.22.0 wheels run 2026-10-09). An encrypted entry stored under another serializer name fails the serializer-name check before it is decrypted. From 0.23.0 ([cachekit-py#521](https://github.com/cachekit-io/cachekit-py/pull/521)), `EncryptionWrapper` refuses `AutoSerializer` at construction (`TestEncryptionWrapperRefusesUnfixedFormatSerializer::test_auto_serializer_refused` in `tests/unit/test_encryption_security_invariants.py`, run against the 0.23.0 and 0.24.0 wheels 2026-10-19). On the drift read that refusal is raised inside the handler's deserialize step, which wraps it as `SerializationError` for the read site to classify; no test yet asserts what the caller sees. `cachekit` 0.1.0 through 0.22.0 also violates [ENC-2](spec/encryption.md#enc-2) and [ENC-3](spec/encryption.md#enc-3) through the public `EncryptionWrapper`: built directly with `serializer=AutoSerializer()`, it decrypts and then lets `AutoSerializer` sniff the plaintext (0.1.0 and 0.22.0 wheels run 2026-10-09). In 0.1.0 through 0.4.0 the wrapper's default serializer was `AutoSerializer`, so a bare `EncryptionWrapper(master_key=...)` does the same, and so does every encrypted read by a cache handler built with `encryption=True`, whatever serializer it is configured with (0.1.0 and 0.4.0 wheels run 2026-10-09); from 0.6.1, the first PyPI release after 0.4.0, neither reaches `AutoSerializer` (wheel run 2026-10-09). From 0.23.0 ([cachekit-py#521](https://github.com/cachekit-io/cachekit-py/pull/521)), `EncryptionWrapper` refuses `AutoSerializer` at construction (`TestEncryptionWrapperRefusesUnfixedFormatSerializer::test_auto_serializer_refused` in `tests/unit/test_encryption_security_invariants.py`, run against the 0.23.0 and 0.24.0 wheels 2026-10-19). > [!WARNING] -> ⁵ **Keyring rotation is released in Python, Rust and TypeScript** — `cachekit` 0.18.0+ on PyPI ([cachekit-py#261](https://github.com/cachekit-io/cachekit-py/pull/261); wheel inspected 2026-09-22, `previous_master_keys` present), `cachekit-rs` 0.8.0+ on crates.io ([cachekit-rs#63](https://github.com/cachekit-io/cachekit-rs/pull/63); `.crate` inspected 2026-09-30, `previous_master_keys` present, drain signal `SecureCache::previous_key_hits()`) and `@cachekit-io/cachekit` 0.2.1+ on npm ([cachekit-ts#103](https://github.com/cachekit-io/cachekit-ts/pull/103); `.tgz` inspected 2026-10-24, `previousMasterKeys` and `CACHEKIT_PREVIOUS_MASTER_KEYS` in `dist/intents-core.js`; 0.2.0 does not install, note ¹⁷). **In Rust, `CACHEKIT_PREVIOUS_MASTER_KEYS` is read by `from_env()` and, from `cachekit-rs` 0.9.0 (`.crate` inspected 2026-10-03), by `CacheKit::secure_from_env(url)`**, which validates it as `CachekitConfig::from_env()` does, so a Redis fleet that builds its `secure` clients through `secure_from_env` can follow the env-var runbook as written. Two `secure` paths still take no previous keys from the environment: the explicit-key `CacheKit::secure(url, master_key_hex)`, and `secure_from_env` on 0.8.0, which reads `CACHEKIT_MASTER_KEY` and ignores the previous-keys variable without an error. Because Rust fails closed, every old-key read on such a client becomes an error at promotion. On those paths, read both variables with `cachekit::CachekitConfig::from_env()` — it needs no API key, builds no backend, and hex-decodes and validates the previous keys — and pass its `master_key` and `previous_master_keys` to `CacheKitBuilder::encryption_from_bytes_with_previous(current, previous, "default")` on the builder the preset returns. That call replaces the preset's encryption layer, so it needs the current key again, decoded bytes (never the ASCII bytes of a hex string, which derive a key no other SDK derives, with no error), and the tenant `"default"` the preset uses. From 0.9.0 it also requires every key, current and previous, to be exactly 32 bytes, and returns an error for any other length. A fleet with a longer key either rotates to exactly 32-byte keys — the only length every SDK accepts — or builds through `secure_from_env`, which takes hex keys of 32 bytes or more, current and previous. TypeScript npm 0.1.5 and earlier have no keyring (0.1.5 inspected 2026-09-22): `CACHEKIT_PREVIOUS_MASTER_KEYS` is ignored and rotating the master key invalidates every encrypted entry, so a TypeScript fleet runs 0.2.1+ before it rotates. Each SDK that has it accepts one current encryption key plus up to three decrypt-only previous keys, rejects a configuration that repeats the current key in that list, writes only with the current key, and keeps reading old-key entries until the operator removes that key — retirement is manual, not TTL-driven. Python selects a keyring entry by the stored fingerprint of its HKDF-derived per-tenant encryption key; Rust and TypeScript attempt the current key followed by decrypt-only keys with identical AAD. The protocol reference verifier ([`tools/encryption-verify.py`](tools/encryption-verify.py)) checks the [keyring vectors](test-vectors/encryption.json): both entries decrypt under `[k2, k1]` at the declared entry, the k1 entry is rejected under `[k2]`, and each stored fingerprint selects the HKDF-derived (not master) key; no SDK consumes these vectors yet. Use the [key rotation runbook](https://docs.cachekit.io/concepts/key-rotation/) (normative source: [decisions/key-rotation.md § Runbooks](decisions/key-rotation.md#runbooks-normative-for-docs)) for the required three-phase rollout and compromise response. +> ⁵ **Keyring rotation is released in Python, Rust and TypeScript** — `cachekit` 0.18.0+ on PyPI ([cachekit-py#261](https://github.com/cachekit-io/cachekit-py/pull/261); wheel inspected 2026-09-22, `previous_master_keys` present), `cachekit-rs` 0.8.0+ on crates.io ([cachekit-rs#63](https://github.com/cachekit-io/cachekit-rs/pull/63); `.crate` inspected 2026-09-30, `previous_master_keys` present, drain signal `SecureCache::previous_key_hits()`) and `@cachekit-io/cachekit` 0.2.1+ on npm ([cachekit-ts#103](https://github.com/cachekit-io/cachekit-ts/pull/103); `.tgz` inspected 2026-10-24, `previousMasterKeys` and `CACHEKIT_PREVIOUS_MASTER_KEYS` in `dist/intents-core.js`; for 0.2.0 see note ¹⁷). **In Rust, `CACHEKIT_PREVIOUS_MASTER_KEYS` is read by `from_env()` and, from `cachekit-rs` 0.9.0 (`.crate` inspected 2026-10-03), by `CacheKit::secure_from_env(url)`**, which validates it as `CachekitConfig::from_env()` does, so a Redis fleet that builds its `secure` clients through `secure_from_env` can follow the env-var runbook as written. Two `secure` paths still take no previous keys from the environment: the explicit-key `CacheKit::secure(url, master_key_hex)`, and `secure_from_env` on 0.8.0, which reads `CACHEKIT_MASTER_KEY` and ignores the previous-keys variable without an error. Because Rust fails closed, every old-key read on such a client becomes an error at promotion. On those paths, read both variables with `cachekit::CachekitConfig::from_env()` — it needs no API key, builds no backend, and hex-decodes and validates the previous keys — and pass its `master_key` and `previous_master_keys` to `CacheKitBuilder::encryption_from_bytes_with_previous(current, previous, "default")` on the builder the preset returns. That call replaces the preset's encryption layer, so it needs the current key again, decoded bytes (never the ASCII bytes of a hex string, which derive a key no other SDK derives, with no error), and the tenant `"default"` the preset uses. From 0.9.0 it also requires every key, current and previous, to be exactly 32 bytes, and returns an error for any other length. A fleet with a longer key either rotates to exactly 32-byte keys — the only length every SDK accepts — or builds through `secure_from_env`, which takes hex keys of 32 bytes or more, current and previous. TypeScript npm 0.1.5 and earlier have no keyring (0.1.5 inspected 2026-09-22): `CACHEKIT_PREVIOUS_MASTER_KEYS` is ignored and rotating the master key invalidates every encrypted entry, so a TypeScript fleet runs 0.2.1+ before it rotates. **In TypeScript 0.2.1, `CACHEKIT_PREVIOUS_MASTER_KEYS` is read only by `createCache.secure`**, and only when no `previousMasterKeys` option is passed; `createCache.io` and the base `createCache` take previous keys only through `encryption.previousMasterKeys`, so a fleet that builds those handles and follows the env-var runbook gets a handle holding only the current key, which cannot read old-key entries after promotion. Each SDK that has it accepts one current encryption key plus up to three decrypt-only previous keys, rejects a configuration that repeats the current key in that list, writes only with the current key, and keeps reading old-key entries until the operator removes that key — retirement is manual, not TTL-driven. Python selects a keyring entry by the stored fingerprint of its HKDF-derived per-tenant encryption key; Rust and TypeScript attempt the current key followed by decrypt-only keys with identical AAD. The protocol reference verifier ([`tools/encryption-verify.py`](tools/encryption-verify.py)) checks the [keyring vectors](test-vectors/encryption.json): both entries decrypt under `[k2, k1]` at the declared entry, the k1 entry is rejected under `[k2]`, and each stored fingerprint selects the HKDF-derived (not master) key; cachekit-ts runs them in its Workers lane since npm 0.2.1 ([cachekit-ts#211](https://github.com/cachekit-io/cachekit-ts/pull/211)). Use the [key rotation runbook](https://docs.cachekit.io/concepts/key-rotation/) (normative source: [decisions/key-rotation.md § Runbooks](decisions/key-rotation.md#runbooks-normative-for-docs)) for the required three-phase rollout and compromise response. > > ¹⁸ **Python's encrypted read path is fail-OPEN by default.** `EncryptionWrapper(fail_closed=False)` (`EncryptionWrapper.__init__` in `serializers/encryption_wrapper.py`) from `encryption_fail_closed: bool = Field(default=False)` (`CachekitConfig.encryption_fail_closed` in `config/settings.py`): on a key-fingerprint mismatch the default is a warning plus a recompute, not a raise — that flag gates the fingerprint pre-check — and the same setting drives the AES-GCM authentication-failure policy through `handle_decrypt_failure` (called from `CacheOperationHandler._handle_l2_read_error` and `_handle_l2_read_error_async` in `cache_handler.py`; resolved in `CacheSerializationHandler.__init__`; L1 path in `create_cache_wrapper`'s `sync_wrapper` / `async_wrapper`, `decorators/wrapper.py`). Both default to recompute. Set `CACHEKIT_ENCRYPTION_FAIL_CLOSED=true` if you are relying on hard errors as your wrong-key or tamper alarm — otherwise the signal is a log line and a billable miss. (Separately, a stored entry that claims plaintext while encryption is enabled is never returned to the caller: it is converted to a miss and evicted, deliberately, for plaintext→encrypted migration — LAB-241, [cachekit-py#215](https://github.com/cachekit-io/cachekit-py/pull/215).) > @@ -104,7 +104,7 @@ > > Degradation is on unless explicitly disabled (`degradationEnabled = …degradation !== false`, read the same way by the `CacheImpl` constructor in `cache-core.ts` and the `ReliabilityExecutor` constructor in `reliability/executor.ts`) and `createCache.secure()` / `.production()` / `.io()` all set it `true` (`PRODUCTION_RELIABILITY` in `intents-core.ts`); only `minimal` sets `false`, and `minimal` carries no encryption. **There is no `failClosed` option anywhere in cachekit-ts** — Python's `CACHEKIT_ENCRYPTION_FAIL_CLOSED` has no counterpart, so the only lever is `reliability: { degradation: false }`, which also gives up backend-outage degradation. If you rely on a thrown error as your tamper, wrong-key or nonce alarm, TypeScript raises none with degradation on. The signals are the `errors_total` counter (metrics on), the rate-limited write warning and the L1 decrypt log; an L2 read that fails to decrypt shows up only in the counter. > -> ⁶ AES hardware detection lives in the shared core — `ZeroKnowledgeEncryptor::hardware_acceleration_enabled()` in `cachekit-core/src/encryption/core.rs`: a runtime `is_x86_feature_detected!("aes")` probe on x86/x86_64; on aarch64 it returns `cfg!(target_feature = "neon")`, which every aarch64 target enables, so it is `true` on every aarch64 build whether or not the CPU has the Crypto Extension — a Raspberry Pi 4 (Cortex-A72 without the Crypto Extension) reports `true` while `ring` runs software AES. That holds for every published `cachekit-core` through 0.6.0 (0.4.0, 0.5.0 and 0.6.0 included). `cachekit-core` 0.7.0 (crates.io, 2026-10-21) ships the fix, [cachekit-core#77](https://github.com/cachekit-io/cachekit-core/pull/77) (LAB-4650): on aarch64 it calls `is_aarch64_feature_detected!("aes")` (`src/encryption/core.rs:263` of the published `.crate`, inspected 2026-10-24). Always `false` on wasm32 (no AES instructions to detect). It is **informational only** — `ring`/`aes-gcm` choose their implementation independently of the flag. Published artifacts (registries checked 2026-09-29; crates.io re-checked 2026-09-30): Python surfaces it (`EncryptionWrapper.hardware_acceleration_enabled`, in `get_encryption_info()` and the init log); `cachekit-rs` 0.8.0+ re-exports it as `EncryptionLayer::hardware_acceleration_enabled()`, forwarded by `SecureCache` ([cachekit-rs#80](https://github.com/cachekit-io/cachekit-rs/pull/80)); 0.8.0 through 0.11.0 require `cachekit-core` `^0.6`, which resolves to 0.6.0 (the only 0.6.x published), so they inherit the aarch64 false positive above, and 0.11.1+ (crates.io, 2026-10-23) requires `^0.7` and inherits the fix (registry dependency lists read 2026-10-24); 0.7.0 and earlier never re-export it. `@cachekit-io/cachekit` 0.2.1+ surfaces it ([cachekit-ts#132](https://github.com/cachekit-io/cachekit-ts/pull/132)): `TenantKeys.hardwareAccelerationEnabled()` on the NAPI and wasm bindings (`index.d.ts:108` of `cachekit-core-ts` 0.2.0, `index.d.ts:50` of `cachekit-core-wasm` 0.2.0) and `EncryptionManager.isHardwareAccelerated()` (`dist/encryption/manager-core.js:265`), which initialises on demand and returns `null` — unknown, not `false` — when the installed binding predates the accessor (`.tgz`s inspected 2026-10-24). Both 0.2.1 bindings embed `cachekit-core` 0.6.0 (`strings` on every `.node` and the `.wasm`, 2026-10-24), so TypeScript still reports `true` on every aarch64 build. 0.1.5 and earlier expose nothing on either binding — `N/A` for TypeScript was wrong, since both bindings run the same Rust core. The wasm32 always-`false` claim is asserted by cachekit-ts's workerd test (`encryption.protocol.workers.test.ts`), and the Rust accessor is pinned to `is_x86_feature_detected!("aes")` on x86_64 by the `hardware_acceleration_reports_core_detection` test in the published 0.8.0 crate's `src/encryption.rs`. TypeScript's aarch64 false positive ends with a release whose bindings embed `cachekit-core` 0.7.0 or later. +> ⁶ AES hardware detection lives in the shared core — `ZeroKnowledgeEncryptor::hardware_acceleration_enabled()` in `cachekit-core/src/encryption/core.rs`: a runtime `is_x86_feature_detected!("aes")` probe on x86/x86_64; on aarch64 it returns `cfg!(target_feature = "neon")`, which every aarch64 target enables, so it is `true` on every aarch64 build whether or not the CPU has the Crypto Extension — a Raspberry Pi 4 (Cortex-A72 without the Crypto Extension) reports `true` while `ring` runs software AES. That holds for every published `cachekit-core` through 0.6.0 (0.4.0, 0.5.0 and 0.6.0 included). `cachekit-core` 0.7.0 (crates.io, 2026-10-21) ships the fix, [cachekit-core#77](https://github.com/cachekit-io/cachekit-core/pull/77) (LAB-4650): on aarch64 it calls `is_aarch64_feature_detected!("aes")` (`src/encryption/core.rs:263` of the published `.crate`, inspected 2026-10-24). Always `false` on wasm32 (no AES instructions to detect). It is **informational only** — `ring`/`aes-gcm` choose their implementation independently of the flag. Published artifacts (registries checked 2026-09-29; crates.io re-checked 2026-09-30): Python surfaces it (`EncryptionWrapper.hardware_acceleration_enabled`, in `get_encryption_info()` and the init log); `cachekit-rs` 0.8.0+ re-exports it as `EncryptionLayer::hardware_acceleration_enabled()`, forwarded by `SecureCache` ([cachekit-rs#80](https://github.com/cachekit-io/cachekit-rs/pull/80)); 0.8.0 through 0.11.0 require `cachekit-core` `^0.6`, which resolves to 0.6.0 (the only 0.6.x published), so they inherit the aarch64 false positive above, and 0.11.1+ (crates.io, 2026-10-23) requires `^0.7` and inherits the fix (registry dependency lists read 2026-10-24); 0.7.0 and earlier never re-export it. `@cachekit-io/cachekit` 0.2.1+ surfaces it ([cachekit-ts#132](https://github.com/cachekit-io/cachekit-ts/pull/132)): `TenantKeys.hardwareAccelerationEnabled()` on the NAPI and wasm bindings (`index.d.ts` of `cachekit-core-ts` 0.2.0 and of `cachekit-core-wasm` 0.2.0) and `EncryptionManager.isHardwareAccelerated()` (`dist/encryption/manager-core.js`), which initialises on demand and returns `null` — unknown, not `false` — when the installed binding predates the accessor (`.tgz`s inspected 2026-10-24). Both 0.2.1 bindings embed `cachekit-core` 0.6.0 (`strings` on every `.node` and the `.wasm`, 2026-10-24), so TypeScript still reports `true` on every aarch64 build. 0.1.5 and earlier expose nothing on either binding — `N/A` for TypeScript was wrong, since both bindings run the same Rust core. The wasm32 always-`false` claim is asserted by cachekit-ts's workerd test (`encryption.protocol.workers.test.ts`), and the Rust accessor is pinned to `is_x86_feature_detected!("aes")` on x86_64 by the `hardware_acceleration_reports_core_detection` test in the published 0.8.0 crate's `src/encryption.rs`. TypeScript's aarch64 false positive ends with a release whose bindings embed `cachekit-core` 0.7.0 or later. --- @@ -262,7 +262,7 @@ The four shared preset names configure **different things per SDK**. Each cell i | `minimal`: integrity checksums | **off** (`integrity_checking=False`) | n/a — values carry no envelope | **on** (ByteStorage on by default) | | `minimal`: reliability | circuit breaker / timeout off, backpressure on | **off** — the `reliability` layer is only attached by `production`/`secure`¹¹/`io` | circuit breaker neutered (∞ threshold), no retry, degradation off | | Encrypted preset | `.secure(master_key=…)` — hex string, ≥64 hex chars (exactly 64 recommended); falls back to `CACHEKIT_MASTER_KEY` | `::secure(url, master_key_hex)`¹¹ — hex string, ≥ 32 B (exactly 32 recommended); `::secure_from_env(url)` reads `CACHEKIT_MASTER_KEY` — `cachekit-rs` 0.8.0+ (`.crate` inspected 2026-09-30); raw bytes, argument only, through 0.7.0 | `.secure({ masterKey })` — hex; falls back to `CACHEKIT_MASTER_KEY` | -| Tenancy on the encrypted preset | `tenant_extractor` callable, `single_tenant_mode`, `fail_closed` tri-state | fixed `"default"` tenant (override via builder) | `tenantId` string. Through npm 0.1.5 (`.tgz` executed 2026-10-06) an empty one is accepted at construction, and key derivation then fails on every operation that encrypts, inside the reliability executor: with `reliability.degradation: false` `set()` and `wrap()` throw `EncryptionError`; with the default `true` nothing raises, every write is skipped and `wrap()` re-runs the function on every call, so nothing is ever cached; from 0.2.1 ([cachekit-ts#226](https://github.com/cachekit-io/cachekit-ts/pull/226); `dist/encryption/manager-core.js:79`, `.tgz` inspected 2026-10-24; `src/encryption/manager-core.test.ts` passes at tag `cachekit-v0.2.1`) it is rejected at construction with `ConfigurationError` | +| Tenancy on the encrypted preset | `tenant_extractor` callable, `single_tenant_mode`, `fail_closed` tri-state | fixed `"default"` tenant (override via builder) | `tenantId` string. Through npm 0.1.5 (`.tgz` executed 2026-10-06) an empty one is accepted at construction, and key derivation then fails on every operation that encrypts, inside the reliability executor: with `reliability.degradation: false` `set()` and `wrap()` throw `EncryptionError`; with the default `true` nothing raises, every write is skipped and `wrap()` re-runs the function on every call, so nothing is ever cached; from 0.2.1 ([cachekit-ts#226](https://github.com/cachekit-io/cachekit-ts/pull/226); `dist/encryption/manager-core.js`, `.tgz` inspected 2026-10-24; `src/encryption/manager-core.test.ts` passes at tag `cachekit-v0.2.1`) it is rejected at construction with `ConfigurationError` | | `io`: credentials | `api_key` argument **or** `CACHEKIT_API_KEY`; `backend=` raises `ConfigurationError` — `cachekit` 0.20.0+; env only through 0.19.0 | `api_key` argument **or** `CACHEKIT_API_KEY` via `io_from_env()`, which reads no other variable — `cachekit-rs` 0.8.0+; argument only through 0.7.0 | `apiKey` option **or** `CACHEKIT_API_KEY` | | `CACHEKIT_MASTER_KEY` auto-enables encryption | only `@cache.secure` (key fallback) — `cachekit` 0.21.0+. In `cachekit` 0.21.0, every other preset except `.local` also raises `ConfigurationError` at construction when a key is present and no intent is stated (no `encryption=`, or an `EncryptionConfig` without `enabled=`). That error is [migration step 3](spec/intent-presets.md#encryption-activation)'s one-release transitional gate: the spec's steady state constructs such a cache as plaintext, so state `encryption=` rather than rely on the error. Through 0.20.0: every serialized preset that stated no intent and was passed neither `master_key=` nor `tenant_extractor=` | only `CacheKit::from_env()` and, from 0.8.0, `::secure_from_env(url)` | only `createCache.secure()` | @@ -295,12 +295,12 @@ its spec: | Key generation (Blake2b) | ✅ Compliant | N/A auto mode¹⁴ — interop/v1 keygen ✅ merged ([#33](https://github.com/cachekit-io/cachekit-rs/pull/33)); `#[cachekit]` mints interop keys ([#35](https://github.com/cachekit-io/cachekit-rs/pull/35)) | ✅ Compliant | ⚠️ Untested | | Wire format (ByteStorage) | ⚠️ Compliant¹⁵ except the envelope pre-scan: payload decodes are pre-scanned (`unpackb_bounded`), but the envelope goes through `cachekit-core`'s `ByteStorage::retrieve`, which has no [Retrieve Flow](spec/wire-format.md#retrieve-flow) step-2 pre-scan in any published core; it is on core `main` ([cachekit-core#80](https://github.com/cachekit-io/cachekit-core/pull/80)), unreleased, and this cell flips when cachekit-py bumps to a core release that carries it; ✅ runs all six `wire-format.json` 1.3.0 [Reject vectors](spec/wire-format.md#reject-vectors) through `ByteStorage.retrieve` since 0.21.0 ([cachekit-py#448](https://github.com/cachekit-io/cachekit-py/pull/448); `TestRejectVectors` in `tests/unit/protocol/test_envelope_wire_vectors.py`), asserting the error each row names, except that core shares one error between its zero-length and ratio checks, so the zero-length vector asserts the ratio message; all fourteen `wire-format.json` 1.4.0 reject vectors in [cachekit-py#550](https://github.com/cachekit-io/cachekit-py/pull/550), unreleased, each asserting the error its row names (the five step-2 typed-decode vectors the typed-decode error, `reject_original_size_sign_bit` the range-checked `u32` decode), with `reject_envelope_slots_overclaim` a strict expected failure (WIRE-9) until cachekit-py pins a core release with the pre-scan, and both `payload_reject_vectors` asserting the structural guard's own error after the envelope read; ⚠️ the allocation bound for the size-cap and ratio vectors needs a probe on `cachekit-core`'s allocator, which no core release has | 🚧 unreleased — Canonical (`cachekit-core`), unused for stored values¹⁵; step-2 pre-scan on core `main` ([cachekit-core#80](https://github.com/cachekit-io/cachekit-core/pull/80)): `ByteStorage::retrieve` and `validate` pre-scan the envelope bytes before the typed `rmp_serde::from_slice` into `StorageEnvelope`; absent from crates.io 0.6.0 | ⚠️ Compliant except the envelope pre-scan: payload decodes are pre-scanned, but the envelope goes through `cachekit-core`'s `ByteStorage::retrieve` (`unpack` in both `cachekit-core-ts` and `cachekit-core-wasm`), which has no step-2 pre-scan in `cachekit-core` 0.6.0, the core both npm 0.2.1 bindings embed (`strings` on every `cachekit-core-ts` 0.2.0 `.node` and the `cachekit-core-wasm` 0.2.0 `.wasm`, 2026-10-24); the pre-scan ships in `cachekit-core` 0.7.0 ([cachekit-core#80](https://github.com/cachekit-io/cachekit-core/pull/80)), and this cell flips when both bindings move to 0.7.0 or later; ✅ since npm 0.2.1 (PRs in the `cachekit-v0.2.1` tag's history, checked 2026-10-24; the Node lane passes at that tag), runs all fourteen `wire-format.json` 1.4.0 [Reject vectors](spec/wire-format.md#reject-vectors) on the envelope read path, asserting the error each row names, in the Node lane (`packages/cachekit/test/protocol/wire-format.protocol.test.ts`) and the Workers lane (`packages/cachekit/test/workers/wire-format.workers.test.ts`) ([cachekit-ts#198](https://github.com/cachekit-io/cachekit-ts/pull/198); 1.4.0 in [cachekit-ts#229](https://github.com/cachekit-io/cachekit-ts/pull/229)), and both [`payload_reject_vectors`](spec/wire-format.md#payload-decode-bounds), refused by the payload pre-scan; `reject_envelope_slots_overclaim` passes in both lanes since 0.2.1 ([cachekit-ts#232](https://github.com/cachekit-io/cachekit-ts/pull/232); `dist/serialization/envelope.js`): the SDK's own header walk keeps a running total of the declared slots and refuses the vector with the envelope pre-scan's own error ([WIRE-9](spec/wire-format.md#wire-9)), before element 1's type check and before core sees the bytes. The walk allocates nothing and admits only complete envelopes; ⚠️ the allocation bound for the size-cap and ratio vectors is unasserted: `cachekit-core` 0.7.0 asserts it in its own unit tests (`src/read_allocation_probe.rs`), but both 0.2.1 bindings embed 0.6.0 | ⚠️ Untested | | Storage container (auto mode)¹⁵ | CK v3 frame (Python-internal) | Plain MessagePack (`rmp` named) — no envelope; ⚠️ decodes cachekit-ts's bare ByteStorage envelope ([WIRE-21](spec/wire-format.md#wire-21)), in every release and on `main`¹⁵ | Bare ByteStorage envelope (default) | — | -| 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 [cachekit-py#550](https://github.com/cachekit-io/cachekit-py/pull/550), unreleased | ❌ 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:378-379`); 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 | +| 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 [cachekit-py#550](https://github.com/cachekit-io/cachekit-py/pull/550), unreleased | ❌ 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 [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) | ✅ Compliant (CachekitIO backend) from npm 0.2.1 on the TTL and lock-id rules this row tracked (`.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`); 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 [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) | ✅ 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`); a lock response is a lease only when its `lock_id` is a non-empty string ([API-65](spec/saas-api.md#api-65); [cachekit-ts#215](https://github.com/cachekit-io/cachekit-ts/pull/215); `dist/backends/cachekitio-lockable.js`); `src/backends/cachekitio-ttl.test.ts` and `src/backends/cachekitio-lockable.test.ts` pass at tag `cachekit-v0.2.1`. ⚠️ Through 0.1.5 (`.tgz` inspected 2026-10-05) a PUT sends only the legacy `X-TTL` ([API-44](spec/saas-api.md#api-44)), unrounded, omits it for `ttl` 0, storing the entry with no expiry, and sends a TTL over 2,592,000 for the server to reject with `400`; and a `200` lock response whose `lock_id` is absent, empty or not a string counts as a lease, so single-flight is off and the release sends `undefined` as the lock id | ❌ Not implemented | | SaaS API — cache-key path encoding ([spec](spec/saas-api.md#cache-key-path-encoding)) | ✅ Compliant 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 — 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 in [cachekit-py#537](https://github.com/cachekit-io/cachekit-py/pull/537), unreleased, 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 in [cachekit-py#537](https://github.com/cachekit-io/cachekit-py/pull/537), unreleased, 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) — ⚠️ 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; `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 in [cachekit-py#550](https://github.com/cachekit-io/cachekit-py/pull/550), unreleased; `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 current `python-frame.json` revision, each vendored byte for byte (sha256-pinned) + CI-executed in [cachekit-py#561](https://github.com/cachekit-io/cachekit-py/pull/561), unreleased — ⚠️ 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), unreleased, 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), unreleased, 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), 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; `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), unreleased, 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), unreleased, 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), unreleased: 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, 760 tests); fixture 1.1.0 since npm 0.2.1 ([cachekit-ts#143](https://github.com/cachekit-io/cachekit-ts/pull/143); `'nsapi'` at `dist/serialization/interop.js:39` and `:118` 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 '..'` at `dist/serialization/interop.js:114`, 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 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) (`cachekit-core` 0.7.0+; both npm 0.2.1 bindings embed 0.6.0) 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), 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 current 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 | +| 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 in [cachekit-py#537](https://github.com/cachekit-io/cachekit-py/pull/537), unreleased, 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 in [cachekit-py#537](https://github.com/cachekit-io/cachekit-py/pull/537), unreleased, 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) — ⚠️ 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; `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 in [cachekit-py#550](https://github.com/cachekit-io/cachekit-py/pull/550), unreleased; `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 current `python-frame.json` revision, each vendored byte for byte (sha256-pinned) + CI-executed in [cachekit-py#561](https://github.com/cachekit-io/cachekit-py/pull/561), unreleased — ⚠️ 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), unreleased, 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), unreleased, 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), 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; `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), unreleased, 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), unreleased, 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), unreleased: 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 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) (`cachekit-core` 0.7.0+; both npm 0.2.1 bindings embed 0.6.0) 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), 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 current 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 | > [!NOTE] @@ -308,7 +308,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). Because `cachekit-rs` reads plain MessagePack, it decodes another SDK's container that is one well-formed MessagePack document: cachekit-ts's bare ByteStorage envelope (`python-frame.json` `bare_envelope_fed_to_frame_reader`) decodes on every read path of a client without encryption, and a read fails only when the requested type's shape does not fit. That is a WIRE-21 violation for that container, and it holds in every release and on `main`; [`conformance/coverage.md`](conformance/coverage.md) counts cachekit-rs's WIRE-21 vectors as held because they are vendored, not because cachekit-rs refuses them all. crates.io 0.10.0 also decodes a CK frame in auto mode, as the integer 67, a second WIRE-21 violation (its interop mode names the frame); on `main`, auto mode rejects the trailing bytes since [cachekit-rs#140](https://github.com/cachekit-io/cachekit-rs/pull/140), unreleased. 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 `interop-mode.json` is `1.3.0` (44 key, 6 value, 34 error, 6 reader accept and 1 reader reject vectors), which adds key and error vectors for the segment grammar on both segments, out-of-model values nested in a list, a map value or a set, map keys a collator or a length-first sort orders wrongly, a `__proto__` key, strings that start with U+FEFF, integers a float64 cannot hold and small integers passed as `{"$int": …}`, and the two reader groups: documents a value reader must accept though no canonical writer emits them, and a trailing byte it must reject. All three SDKs vendor `1.3.0` byte for byte, in [cachekit-py#537](https://github.com/cachekit-io/cachekit-py/pull/537), [cachekit-rs#152](https://github.com/cachekit-io/cachekit-rs/pull/152) and [cachekit-ts#224](https://github.com/cachekit-io/cachekit-ts/pull/224) (the py and rs PRs unreleased; ts since npm 0.2.1). This repo's `wire-format.json` is `1.4.0` (LAB-8147): both sides of the `bin8` → `bin16` edge, three constructed envelopes on the `bin16` → `bin32` edge (one a legacy `array32`), eight more `reject_vectors` (step 2's typed decode and pre-scan, an `original_size` with the sign bit set, and a ratio a float32 rounds to exactly 1000), and two new groups: `payload_reject_vectors` and `temporal_sentinel_vectors`. cachekit-py vendors `1.4.0` byte for byte and runs every vector in [cachekit-py#550](https://github.com/cachekit-io/cachekit-py/pull/550) (unreleased), `reject_envelope_slots_overclaim` as a strict expected failure until it pins a `cachekit-core` release with the envelope pre-scan; cachekit-ts vendors it byte for byte in [cachekit-ts#229](https://github.com/cachekit-io/cachekit-ts/pull/229) (since 0.2.1), with one gap held as an expected failure (see the Temporal sentinels row); `reject_envelope_slots_overclaim`, the other, passes since [cachekit-ts#232](https://github.com/cachekit-io/cachekit-ts/pull/232) (since 0.2.1). `1.3.0` added 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 since 0.2.1 ([cachekit-ts#198](https://github.com/cachekit-io/cachekit-ts/pull/198)), each through its envelope read path; `cachekit-rs` stores no envelope¹⁵. `cachekit-core` runs them through `ByteStorage::retrieve` since 0.7.0, which vendors `wire-format.json` 1.3.0 (`tests/wire_format_vectors.rs`), and asserts the allocation bound of their size-cap and ratio vectors in its unit tests (`src/read_allocation_probe.rs`); both pass from the published `.crate`, run 2026-10-24. 0.6.0 vendors 1.1.0 and has no probe, so neither SDK asserts that bound yet. This repo's `file-backend.json` is `1.2.0` (LAB-8147), which adds an empty and a binary payload, flag bit 15, reserved values 1 and 255, and two expired entries with an unknown flag or reserved value, which a reader keeps; cachekit-py vendors `1.2.0` byte for byte in [cachekit-py#550](https://github.com/cachekit-io/cachekit-py/pull/550) and cachekit-rs in [cachekit-rs#158](https://github.com/cachekit-io/cachekit-rs/pull/158) (both unreleased), and cachekit-ts vendors `1.2.0` in [cachekit-ts#229](https://github.com/cachekit-io/cachekit-ts/pull/229) (since 0.2.1). `python-frame.json` (LAB-8147) adds frame-check boundary vectors, other SDKs' containers and an `encrypted_read_vectors` group; cachekit-py vendors it byte for byte and runs every frame, error and encrypted read vector in [cachekit-py#550](https://github.com/cachekit-io/cachekit-py/pull/550), and its current revision in [cachekit-py#561](https://github.com/cachekit-io/cachekit-py/pull/561) (both unreleased); cachekit-rs vendors it byte for byte in [cachekit-rs#158](https://github.com/cachekit-io/cachekit-rs/pull/158), and the current revision in [cachekit-rs#160](https://github.com/cachekit-io/cachekit-rs/pull/160) (both unreleased); cachekit-ts vendors it byte for byte for its container vectors in [cachekit-ts#229](https://github.com/cachekit-io/cachekit-ts/pull/229), and its current revision in [cachekit-ts#230](https://github.com/cachekit-io/cachekit-ts/pull/230) (both since 0.2.1). 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) (the py and rs PRs unreleased; ts since npm 0.2.1). 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 (the py and rs PRs unreleased; ts since 0.2.1). This repo's `encryption.json` is `1.5.0` (`1.4.0` adds a second master key input accept row and two StandardSerializer AAD shapes; `1.5.0` adds the `aad_reject_vectors`, `decrypted_container` and `keyring.configuration` tables). `1.3.0` added 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 runs the `1.3.0` accept and hex reject rows since [cachekit-ts#222](https://github.com/cachekit-io/cachekit-ts/pull/222) and vendors `1.5.0` byte for byte in [cachekit-ts#230](https://github.com/cachekit-io/cachekit-ts/pull/230) (both since 0.2.1). cachekit-rs vendors `1.5.0` byte for byte in [cachekit-rs#160](https://github.com/cachekit-io/cachekit-rs/pull/160) (unreleased). cachekit-py vendors `1.5.0` byte for byte in [cachekit-py#561](https://github.com/cachekit-io/cachekit-py/pull/561) (unreleased) and runs the rows the Test vectors in CI row lists, with two `keyring.configuration` cases as strict expected failures naming [ENC-9](spec/encryption.md#enc-9). This repo's `decode-bounds.json` is `1.2.0` (19 reject, 3 accept), which adds `incomplete_within_slot_budget` and its map twin, documents cut short inside the slot budget, under a new `incomplete` reject reason. All three SDKs vendor `1.2.0` byte for byte, in [cachekit-py#537](https://github.com/cachekit-io/cachekit-py/pull/537), [cachekit-rs#152](https://github.com/cachekit-io/cachekit-rs/pull/152) and [cachekit-ts#224](https://github.com/cachekit-io/cachekit-ts/pull/224) (the py and rs PRs unreleased; ts since npm 0.2.1). Their previous copies are `1.1.0` (17 reject, 3 accept): cachekit-py's released since 0.20.0 ([cachekit-py#363](https://github.com/cachekit-io/cachekit-py/pull/363)), cachekit-rs's released since 0.8.0 ([cachekit-rs#93](https://github.com/cachekit-io/cachekit-rs/pull/93)), and cachekit-ts's in [cachekit-ts#152](https://github.com/cachekit-io/cachekit-ts/pull/152) (since 0.2.1); the SDKs' 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` since 0.7.0 ([cachekit-core#80](https://github.com/cachekit-io/cachekit-core/pull/80); `tests/decode_bounds_vectors.rs` passes from the published `.crate`, run 2026-10-24). `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.3.0` (21 key vectors, the first 10 byte-identical to the v0.5.0 originals): `1.2.0` adds four derived serializer codes, `1.3.0` 4 `error_vectors` and 5 `serializer_object_vectors`. 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)), and `1.3.0` byte for byte in [cachekit-py#561](https://github.com/cachekit-io/cachekit-py/pull/561) (unreleased), running every vector. +> ¹⁶ "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 `interop-mode.json` is `1.3.0` (44 key, 6 value, 34 error, 6 reader accept and 1 reader reject vectors), which adds key and error vectors for the segment grammar on both segments, out-of-model values nested in a list, a map value or a set, map keys a collator or a length-first sort orders wrongly, a `__proto__` key, strings that start with U+FEFF, integers a float64 cannot hold and small integers passed as `{"$int": …}`, and the two reader groups: documents a value reader must accept though no canonical writer emits them, and a trailing byte it must reject. All three SDKs vendor `1.3.0` byte for byte, in [cachekit-py#537](https://github.com/cachekit-io/cachekit-py/pull/537), [cachekit-rs#152](https://github.com/cachekit-io/cachekit-rs/pull/152) and [cachekit-ts#224](https://github.com/cachekit-io/cachekit-ts/pull/224) (ts since npm 0.2.1). This repo's `wire-format.json` is `1.4.0` (LAB-8147): both sides of the `bin8` → `bin16` edge, three constructed envelopes on the `bin16` → `bin32` edge (one a legacy `array32`), eight more `reject_vectors` (step 2's typed decode and pre-scan, an `original_size` with the sign bit set, and a ratio a float32 rounds to exactly 1000), and two new groups: `payload_reject_vectors` and `temporal_sentinel_vectors`. cachekit-py vendors `1.4.0` byte for byte and runs every vector in [cachekit-py#550](https://github.com/cachekit-io/cachekit-py/pull/550) (unreleased), `reject_envelope_slots_overclaim` as a strict expected failure until it pins a `cachekit-core` release with the envelope pre-scan; cachekit-ts vendors it byte for byte in [cachekit-ts#229](https://github.com/cachekit-io/cachekit-ts/pull/229) (since 0.2.1), with one gap held as an expected failure (see the Temporal sentinels row); `reject_envelope_slots_overclaim`, the other, passes since [cachekit-ts#232](https://github.com/cachekit-io/cachekit-ts/pull/232) (since 0.2.1). `1.3.0` added 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 since 0.2.1 ([cachekit-ts#198](https://github.com/cachekit-io/cachekit-ts/pull/198)), each through its envelope read path; `cachekit-rs` stores no envelope¹⁵. `cachekit-core` runs them through `ByteStorage::retrieve` since 0.7.0, which vendors `wire-format.json` 1.3.0 (`tests/wire_format_vectors.rs`), and asserts the allocation bound of their size-cap and ratio vectors in its unit tests (`src/read_allocation_probe.rs`); both pass from the published `.crate`, run 2026-10-24. 0.6.0 vendors 1.1.0 and has no probe, so neither SDK asserts that bound yet. This repo's `file-backend.json` is `1.2.0` (LAB-8147), which adds an empty and a binary payload, flag bit 15, reserved values 1 and 255, and two expired entries with an unknown flag or reserved value, which a reader keeps; cachekit-py vendors `1.2.0` byte for byte in [cachekit-py#550](https://github.com/cachekit-io/cachekit-py/pull/550) and cachekit-rs in [cachekit-rs#158](https://github.com/cachekit-io/cachekit-rs/pull/158) (both unreleased), and cachekit-ts vendors `1.2.0` in [cachekit-ts#229](https://github.com/cachekit-io/cachekit-ts/pull/229) (since 0.2.1). `python-frame.json` (LAB-8147) adds frame-check boundary vectors, other SDKs' containers and an `encrypted_read_vectors` group; cachekit-py vendors it byte for byte and runs every frame, error and encrypted read vector in [cachekit-py#550](https://github.com/cachekit-io/cachekit-py/pull/550), and its current revision in [cachekit-py#561](https://github.com/cachekit-io/cachekit-py/pull/561) (both unreleased); cachekit-rs vendors it byte for byte in [cachekit-rs#158](https://github.com/cachekit-io/cachekit-rs/pull/158), and the current revision in [cachekit-rs#160](https://github.com/cachekit-io/cachekit-rs/pull/160) (both unreleased); cachekit-ts vendors it byte for byte for its container vectors in [cachekit-ts#229](https://github.com/cachekit-io/cachekit-ts/pull/229), and its current revision in [cachekit-ts#230](https://github.com/cachekit-io/cachekit-ts/pull/230) (both since 0.2.1). 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) (ts since npm 0.2.1). 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 (ts since 0.2.1). This repo's `encryption.json` is `1.5.0` (`1.4.0` adds a second master key input accept row and two StandardSerializer AAD shapes; `1.5.0` adds the `aad_reject_vectors`, `decrypted_container` and `keyring.configuration` tables). `1.3.0` added 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 runs the `1.3.0` accept and hex reject rows since [cachekit-ts#222](https://github.com/cachekit-io/cachekit-ts/pull/222) and vendors `1.5.0` byte for byte in [cachekit-ts#230](https://github.com/cachekit-io/cachekit-ts/pull/230) (both since 0.2.1). cachekit-rs vendors `1.5.0` byte for byte in [cachekit-rs#160](https://github.com/cachekit-io/cachekit-rs/pull/160) (unreleased). cachekit-py vendors `1.5.0` byte for byte in [cachekit-py#561](https://github.com/cachekit-io/cachekit-py/pull/561) (unreleased) and runs the rows the Test vectors in CI row lists, with two `keyring.configuration` cases as strict expected failures naming [ENC-9](spec/encryption.md#enc-9). This repo's `decode-bounds.json` is `1.2.0` (19 reject, 3 accept), which adds `incomplete_within_slot_budget` and its map twin, documents cut short inside the slot budget, under a new `incomplete` reject reason. All three SDKs vendor `1.2.0` byte for byte, in [cachekit-py#537](https://github.com/cachekit-io/cachekit-py/pull/537), [cachekit-rs#152](https://github.com/cachekit-io/cachekit-rs/pull/152) and [cachekit-ts#224](https://github.com/cachekit-io/cachekit-ts/pull/224) (ts since npm 0.2.1). Their previous copies are `1.1.0` (17 reject, 3 accept): cachekit-py's released since 0.20.0 ([cachekit-py#363](https://github.com/cachekit-io/cachekit-py/pull/363)), cachekit-rs's released since 0.8.0 ([cachekit-rs#93](https://github.com/cachekit-io/cachekit-rs/pull/93)), and cachekit-ts's in [cachekit-ts#152](https://github.com/cachekit-io/cachekit-ts/pull/152) (since 0.2.1); the SDKs' 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` since 0.7.0 ([cachekit-core#80](https://github.com/cachekit-io/cachekit-core/pull/80); `tests/decode_bounds_vectors.rs` passes from the published `.crate`, run 2026-10-24). `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.3.0` (21 key vectors, the first 10 byte-identical to the v0.5.0 originals): `1.2.0` adds four derived serializer codes, `1.3.0` 4 `error_vectors` and 5 `serializer_object_vectors`. 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)), and `1.3.0` byte for byte in [cachekit-py#561](https://github.com/cachekit-io/cachekit-py/pull/561) (unreleased), running every vector. > > ¹⁷ 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. TypeScript's `0.2.1+` floors follow the same rule: npm 0.2.0 (2026-10-20) carries every change those cells cite, but it pins `@cachekit-io/cachekit-core-ts` 0.1.3, which was never published, so it does not install, and npm marks it deprecated in favour of 0.2.1 (registry read 2026-10-24). Do not "correct" a TypeScript floor to 0.2.0. @@ -358,7 +358,7 @@ its spec: | cachekit-ts — NAPI path | 0.2.1 (npm) → exact pin `@cachekit-io/cachekit-core-ts@0.2.0` | **0.6.0** (all five platform `.node` binaries; `strings`, 2026-10-24) | ✅ since 0.2.1 | | cachekit-ts — Workers path | 0.2.1 (npm) → exact pin `@cachekit-io/cachekit-core-wasm@0.2.0` | **0.6.0** | ✅ since 0.2.1 | - **TypeScript ships the flip on both paths from npm 0.2.1** (checked 2026-10-24): `ByteStorage.pack` on the published `cachekit-core-ts` 0.2.0 linux-x64 binary and on the `cachekit-core-wasm` 0.2.0 module emits `compressed_data` as msgpack `bin` (`c4`), where `cachekit-core-ts` 0.1.2 and `cachekit-core-wasm` 0.1.1 emit the legacy array (`dc`); the SDK writes its envelope through that `ByteStorage` on both paths. 0.2.0 carries the same pins but does not install (note ¹⁷). **Through 0.1.5 it shipped on neither path.** `@cachekit-io/cachekit@0.1.5` (published 2026-08-03T11:15Z) carries dependency pins byte-identical to 0.1.4's — exact, caret-free pins on `@cachekit-io/cachekit-core-ts@0.1.2` (published 2026-05-17, before core 0.3.0 existed) and `@cachekit-io/cachekit-core-wasm@0.1.1`. `@cachekit-io/cachekit-core-wasm@0.1.2` does embed core 0.4.0, but it published at 14:28Z — **3h13m after** ts 0.1.5 — so no published `cachekit` release pins it. [cachekit-ts#91](https://github.com/cachekit-io/cachekit-ts/pull/91) bumped the source pin and is in 0.1.5's tree, but the NAPI addon it consumes was never republished, so the shipped binary is unchanged. + **TypeScript ships the flip on both paths from npm 0.2.1** (checked 2026-10-24): `ByteStorage.pack` on the published `cachekit-core-ts` 0.2.0 linux-x64 binary and on the `cachekit-core-wasm` 0.2.0 module emits `compressed_data` as msgpack `bin` (`c4`), where `cachekit-core-ts` 0.1.2 and `cachekit-core-wasm` 0.1.1 emit the legacy array (`dc`); the SDK writes its envelope through that `ByteStorage` on both paths. For 0.2.0 see note ¹⁷. **Through 0.1.5 it shipped on neither path.** `@cachekit-io/cachekit@0.1.5` (published 2026-08-03T11:15Z) carries dependency pins byte-identical to 0.1.4's — exact, caret-free pins on `@cachekit-io/cachekit-core-ts@0.1.2` (published 2026-05-17, before core 0.3.0 existed) and `@cachekit-io/cachekit-core-wasm@0.1.1`. `@cachekit-io/cachekit-core-wasm@0.1.2` does embed core 0.4.0, but it published at 14:28Z — **3h13m after** ts 0.1.5 — so no published `cachekit` release pins it. [cachekit-ts#91](https://github.com/cachekit-io/cachekit-ts/pull/91) bumped the source pin and is in 0.1.5's tree, but the NAPI addon it consumes was never republished, so the shipped binary is unchanged. **This lag is a missing optimisation, not a hazard — there is nothing to sequence.** Because dual-read is mutual (above), a ts instance on core 0.2.0 reads `bin` written by any newer instance, and the legacy envelopes it writes stay readable by everything forever. What TypeScript 0.1.5 and earlier forgo is the `bin` encoding's size saving on its own writes (~35% on incompressible payloads, ≤ +1 B on tiny ones). Nor is it a cross-SDK concern: auto-mode stored bytes are SDK-internal and no SDK reads another's ([protocol#11](https://github.com/cachekit-io/protocol/issues/11)).