diff --git a/changelog.d/20261009_lab-8699.md b/changelog.d/20261009_lab-8699.md
new file mode 100644
index 0000000..34d2869
--- /dev/null
+++ b/changelog.d/20261009_lab-8699.md
@@ -0,0 +1,13 @@
+### Conformance — cachekit-ts runs `encryption.json` 1.5.0 and the current `python-frame.json` (LAB-8699)
+
+- `conformance/sdks.json` reads cachekit-ts at
+ [`83e0566`](https://github.com/cachekit-io/cachekit-ts/commit/83e0566251b0b0d2cad4557b37b3041c9b2aa9a0),
+ the merge commit of [cachekit-ts#230](https://github.com/cachekit-io/cachekit-ts/pull/230), which re-vendors
+ `encryption.json` 1.5.0 and the current `python-frame.json` byte for byte. Every cachekit-ts copy was re-hashed at
+ that commit, and only those two changed. `conformance/coverage.md` is regenerated. For cachekit-ts, every vector
+ mapped to ENC-1 to ENC-3, ENC-5 to ENC-7, ENC-9, ENC-10, PRE-16 to PRE-18, PRE-22, PRE-25, PRE-28 and IOP-19 is now
+ vendored, so each reads `partial (gap)`, held back from `covered` only by its recorded gap. ENC-1, ENC-2, ENC-3,
+ ENC-7 and ENC-9 move there from `uncovered`, the rest from a partial vector count.
+- The feature matrix records what cachekit-ts does with the new rows, all unreleased until a cachekit-ts release
+ carries them. No row that binds cachekit-ts fails, so the matrix records no deviation. The **Test vectors in CI**
+ cell and note ¹⁶ record both copies.
diff --git a/conformance/coverage.md b/conformance/coverage.md
index 7a3a229..c18bb37 100644
--- a/conformance/coverage.md
+++ b/conformance/coverage.md
@@ -13,7 +13,7 @@ How ids are assigned, and what each status means: [README.md](README.md#coverage
| SDK | Read at | Vendored fixtures |
| :--- | :--- | :--- |
| cachekit-py | [`ddecec3`](https://github.com/cachekit-io/cachekit-py/commit/ddecec3f5897d387f194441446d7b085d688c40e) | `cache-keys.json` 1.1.0 (not the current revision), `decode-bounds.json` 1.2.0, `file-backend.json` 1.2.0, `freshness-headers.json` 1.0.0, `interop-mode.json` 1.3.0, `path-encoding.json` 1.2.0, `python-frame.json` unversioned (not the current revision), `wire-format.json` 1.4.0 |
-| cachekit-ts | [`46f5a6a`](https://github.com/cachekit-io/cachekit-ts/commit/46f5a6a794b9e2bfff1d71a3bf0b31f5666de09e) | `decode-bounds.json` 1.2.0, `encryption.json` 1.3.0 (not the current revision), `file-backend.json` 1.2.0, `freshness-headers.json` 1.0.0, `interop-mode.json` 1.3.0, `path-encoding.json` 1.2.0, `python-frame.json` unversioned (not the current revision), `wire-format.json` 1.4.0 |
+| cachekit-ts | [`83e0566`](https://github.com/cachekit-io/cachekit-ts/commit/83e0566251b0b0d2cad4557b37b3041c9b2aa9a0) | `decode-bounds.json` 1.2.0, `encryption.json` 1.5.0, `file-backend.json` 1.2.0, `freshness-headers.json` 1.0.0, `interop-mode.json` 1.3.0, `path-encoding.json` 1.2.0, `python-frame.json` unversioned, `wire-format.json` 1.4.0 |
| cachekit-rs | [`3a0d241`](https://github.com/cachekit-io/cachekit-rs/commit/3a0d241b99d1593fae28cf1195ec8bdb59a9fddf) | `decode-bounds.json` 1.2.0, `encryption.json` 1.5.0, `file-backend.json` 1.2.0, `freshness-headers.json` 1.0.0, `interop-mode.json` 1.3.0, `path-encoding.json` 1.2.0, `python-frame.json` unversioned |
## Summary
@@ -31,7 +31,7 @@ How ids are assigned, and what each status means: [README.md](README.md#coverage
| SDK | Covered | Partial | Uncovered | Gap | n/a |
| :--- | ---: | ---: | ---: | ---: | ---: |
| cachekit-py | 7 | 81 | 15 | 67 | 47 |
-| cachekit-ts | 9 | 73 | 12 | 52 | 71 |
+| cachekit-ts | 9 | 78 | 7 | 52 | 71 |
| cachekit-rs | 4 | 69 | 3 | 53 | 88 |
Not indexed yet: [`spec/interop-v2.md`](../spec/interop-v2.md).
@@ -62,16 +62,16 @@ Marked not-a-requirement in `spec/cache-key-format.md`:
| Id | Requirement | Vectors, tests and gaps | cachekit-py | cachekit-ts | cachekit-rs |
| :--- | :--- | :--- | :--- | :--- | :--- |
-| [ENC-1](../spec/encryption.md#enc-1) | *AAD v0x03 Format*: … the reconstructed AAD and fails authentication. That failure is the intended behavior: readers **MUST NOT** retry decryption with any alternative AAD input — a different `format` or `compressed` value, presence/absence of … | `encryption.json`: `aad_compressed_false_sealed_true`, `aad_compressed_true_sealed_false`, `aad_format_msgpack_sealed_arrow`, `aad_without_original_type_sealed_with`, `aad_with_original_type_sealed_without`, `aad_key_with_prefix_sealed_without`, `aad_compressed_false_sealed_true_five_components`, `aad_compressed_true_sealed_false_five_components`, `aad_without_original_type_sealed_with_msgpack`, `aad_format_arrow_sealed_msgpack`
**Gap:** After a retry, aad_without_original_type_sealed_with, aad_compressed_false_sealed_true_five_components, aad_without_original_type_sealed_with_msgpack and aad_format_arrow_sealed_msgpack decrypt to a container the reader that presents them reads, so the retry returns a value. aad_compressed_false_sealed_true, aad_compressed_true_sealed_false, aad_format_msgpack_sealed_arrow and aad_compressed_true_sealed_false_five_components decrypt to bytes that reader cannot read, so a retry still ends with no value and only a test that asserts the authentication failure itself, not just an empty read, tells it apart. aad_with_original_type_sealed_without and aad_key_with_prefix_sealed_without decrypt to one MessagePack document with bytes after it, which only an interop reader must refuse (ENC-3's gap), so a test asserts the authentication failure there too. A reader that builds its AAD inputs from configuration presents only the rows its configuration produces: cachekit-ts the four-component rows, the compressed flips one per compression setting, and the prefixed key through a backend key prefix; cachekit-rs and interop mode only the four-component rows with compressed False, cachekit-rs reaching the prefixed key through a namespace and interop mode, which refuses a key prefix, not at all; cachekit-py, which takes the inputs from its frame header, presents every row. A reader that retries only with AADs no row was sealed under passes, such as one that retries with one original_type value swapped for another, original_type msgpack added to a four-component compressed-False AAD, original_type dropped from a compressed-True AAD, the format changed to or from orjson, a key prefix or namespace added, another tenant, or an empty or pre-0x03 AAD. | uncovered (0/10) | uncovered (0/10) | partial (gap) |
-| [ENC-2](../spec/encryption.md#enc-2) | *AAD v0x03 Format*: … failure into a metadata-tamper oracle. Likewise, the post-decryption unenvelope step **MUST** be selected by the reader's configured serializer/mode — never by sniffing the decrypted bytes or falling back between … | `python-frame.json`: `ciphertext_plain_msgpack_not_envelope`, `ciphertext_plain_msgpack_claims_uncompressed`
`encryption.json`: `container_envelope_to_plain_reader`, `container_plain_to_envelope_reader`, `container_bare_arrow_to_arrow_reader`, `container_plain_json_to_orjson_reader`
**Gap:** The envelopes the rows hold are in the bin encoding, so a plain-MessagePack reader that unwraps only the legacy array-of-integers envelope passes. The plain-MessagePack rows are sealed under four-component AADs, so they do not reach cachekit-py's reader with integrity checking off, and no row gives an orjson reader with integrity checking off a checksummed payload. No row gives an Arrow or orjson reader plain MessagePack, or an envelope reader JSON, so a reader that falls back to the other family passes. | uncovered (0/6) | uncovered (0/6) | partial (gap) |
-| [ENC-3](../spec/encryption.md#enc-3) | *AAD v0x03 Format*: … serializer/mode — never by sniffing the decrypted bytes or falling back between containers — and **MUST** hard-fail on a parse mismatch. | `python-frame.json`: `ciphertext_plain_msgpack_not_envelope`, `ciphertext_plain_msgpack_claims_uncompressed`
`encryption.json`: `container_trailing_byte_to_interop_reader`, `container_incomplete_tail_to_interop_reader`, `container_plain_to_envelope_reader`, `container_bare_arrow_to_arrow_reader`, `container_plain_json_to_orjson_reader`
**Gap:** Only interop readers are held to bytes after the document, since spec/interop-mode.md obliges them to consume exactly one: whether a reader of an auto-mode container, plain MessagePack (cachekit-ts with compression off, cachekit-rs) or a ByteStorage envelope, rejects bytes after its document or envelope is not stated, so no row asks it of them (cachekit-core ignores bytes after an envelope, and cachekit-ts refuses them). No row gives an Arrow or orjson reader plain MessagePack, or an envelope reader JSON, so a reader that falls back to the other family on a parse failure passes. | uncovered (0/7) | uncovered (0/7) | partial (gap) |
+| [ENC-1](../spec/encryption.md#enc-1) | *AAD v0x03 Format*: … the reconstructed AAD and fails authentication. That failure is the intended behavior: readers **MUST NOT** retry decryption with any alternative AAD input — a different `format` or `compressed` value, presence/absence of … | `encryption.json`: `aad_compressed_false_sealed_true`, `aad_compressed_true_sealed_false`, `aad_format_msgpack_sealed_arrow`, `aad_without_original_type_sealed_with`, `aad_with_original_type_sealed_without`, `aad_key_with_prefix_sealed_without`, `aad_compressed_false_sealed_true_five_components`, `aad_compressed_true_sealed_false_five_components`, `aad_without_original_type_sealed_with_msgpack`, `aad_format_arrow_sealed_msgpack`
**Gap:** After a retry, aad_without_original_type_sealed_with, aad_compressed_false_sealed_true_five_components, aad_without_original_type_sealed_with_msgpack and aad_format_arrow_sealed_msgpack decrypt to a container the reader that presents them reads, so the retry returns a value. aad_compressed_false_sealed_true, aad_compressed_true_sealed_false, aad_format_msgpack_sealed_arrow and aad_compressed_true_sealed_false_five_components decrypt to bytes that reader cannot read, so a retry still ends with no value and only a test that asserts the authentication failure itself, not just an empty read, tells it apart. aad_with_original_type_sealed_without and aad_key_with_prefix_sealed_without decrypt to one MessagePack document with bytes after it, which only an interop reader must refuse (ENC-3's gap), so a test asserts the authentication failure there too. A reader that builds its AAD inputs from configuration presents only the rows its configuration produces: cachekit-ts the four-component rows, the compressed flips one per compression setting, and the prefixed key through a backend key prefix; cachekit-rs and interop mode only the four-component rows with compressed False, cachekit-rs reaching the prefixed key through a namespace and interop mode, which refuses a key prefix, not at all; cachekit-py, which takes the inputs from its frame header, presents every row. A reader that retries only with AADs no row was sealed under passes, such as one that retries with one original_type value swapped for another, original_type msgpack added to a four-component compressed-False AAD, original_type dropped from a compressed-True AAD, the format changed to or from orjson, a key prefix or namespace added, another tenant, or an empty or pre-0x03 AAD. | uncovered (0/10) | partial (gap) | partial (gap) |
+| [ENC-2](../spec/encryption.md#enc-2) | *AAD v0x03 Format*: … failure into a metadata-tamper oracle. Likewise, the post-decryption unenvelope step **MUST** be selected by the reader's configured serializer/mode — never by sniffing the decrypted bytes or falling back between … | `python-frame.json`: `ciphertext_plain_msgpack_not_envelope`, `ciphertext_plain_msgpack_claims_uncompressed`
`encryption.json`: `container_envelope_to_plain_reader`, `container_plain_to_envelope_reader`, `container_bare_arrow_to_arrow_reader`, `container_plain_json_to_orjson_reader`
**Gap:** The envelopes the rows hold are in the bin encoding, so a plain-MessagePack reader that unwraps only the legacy array-of-integers envelope passes. The plain-MessagePack rows are sealed under four-component AADs, so they do not reach cachekit-py's reader with integrity checking off, and no row gives an orjson reader with integrity checking off a checksummed payload. No row gives an Arrow or orjson reader plain MessagePack, or an envelope reader JSON, so a reader that falls back to the other family passes. | uncovered (0/6) | partial (gap) | partial (gap) |
+| [ENC-3](../spec/encryption.md#enc-3) | *AAD v0x03 Format*: … serializer/mode — never by sniffing the decrypted bytes or falling back between containers — and **MUST** hard-fail on a parse mismatch. | `python-frame.json`: `ciphertext_plain_msgpack_not_envelope`, `ciphertext_plain_msgpack_claims_uncompressed`
`encryption.json`: `container_trailing_byte_to_interop_reader`, `container_incomplete_tail_to_interop_reader`, `container_plain_to_envelope_reader`, `container_bare_arrow_to_arrow_reader`, `container_plain_json_to_orjson_reader`
**Gap:** Only interop readers are held to bytes after the document, since spec/interop-mode.md obliges them to consume exactly one: whether a reader of an auto-mode container, plain MessagePack (cachekit-ts with compression off, cachekit-rs) or a ByteStorage envelope, rejects bytes after its document or envelope is not stated, so no row asks it of them (cachekit-core ignores bytes after an envelope, and cachekit-ts refuses them). No row gives an Arrow or orjson reader plain MessagePack, or an envelope reader JSON, so a reader that falls back to the other family on a parse failure passes. | uncovered (0/7) | partial (gap) | partial (gap) |
| [ENC-4](../spec/encryption.md#enc-4) | *`format` tokens*: New serializers **MUST** register their token here before shipping. Tokens are case-sensitive, and the registry is a writer-side contract: … | **Gap:** A rule on how an SDK ships a serializer, which no vector or test can observe; what it protects on the wire is ENC-5. | gap | gap | gap |
-| [ENC-5](../spec/encryption.md#enc-5) | *`format` tokens*: … — the registry exists so independent implementations of the same entry agree on the bytes. Writers **MUST** emit only registry tokens; readers SHOULD reject tokens outside the registry before attempting decryption. | `interop-mode.json`: `interop_key_aad`, `interop_encryption_roundtrip`
`python-frame.json`: `default_saas_write_msgpack_bytestorage`, `default_saas_write_msgpack_bytestorage_bin`, `arrow_dataframe_write`, `forged_plaintext_orjson`, `ciphertext_key_not_in_keyring`, `ciphertext_other_tenant`, `auto_serializer_write`, `standard_serializer_instance_write`, `integrity_checking_off_write`, `arrow_compression_off_write`
**Gap:** Only cachekit-py's default, AutoSerializer, Arrow and OrjsonSerializer writes on plaintext frames, its default writes on encrypted frames, and the interop AAD are pinned against the token a writer emits: no frame holds an AutoSerializer numpy, Series or DataFrame write, no vector holds an auto-mode cachekit-ts or cachekit-rs write, and encryption.json takes the token as an input, so such a writer that emits an off-registry token passes; no SDK can seal with a fixed nonce, so only a test that decrypts what its write path stores under the aad_hex of the encryption.json vector with its shape catches it. | partial (8/12) | partial (8/12) | partial (gap) |
-| [ENC-6](../spec/encryption.md#enc-6) | *`compressed` tokens*: … Python's `str(bool)` output; that coincidence is now frozen. Implementations in every language **MUST** emit exactly these byte sequences — `true`, `false`, `TRUE`, `1`, `0`, or a raw byte are all non-conformant and fail … | `encryption.json`: `basic_bytes`, `special_cache_key`, `compressed_basic`, `arrow_uncompressed`, `arrow_compressed`, `orjson_uncompressed`, `original_type_numpy`, `standard_serializer_default`, `standard_serializer_integrity_off`, `encrypted_with_k1`, `encrypted_with_k2`, `default_tenant_interop`, `master_key_every_hex_digit`, `master_key_first_byte_80`
`interop-mode.json`: `interop_key_aad`, `interop_encryption_roundtrip`
`tools/encryption-verify.py:verify_sealed_vector`
`tools/interop-crosscheck.mjs:aadV3`
**Gap:** The vectors pin the bytes a renderer emits for a JSON boolean, not the AAD an SDK's write path emits: an SDK whose writer spells the token itself, in stored metadata or on a second code path, and whose reader rebuilds the AAD from that spelling round-trips with itself and passes them through whichever renderer its test drives; no SDK can seal with a fixed nonce, so only a test that decrypts what its write path stores under the aad_hex of the vector with the same shape catches it. | partial (2/16) | partial (13/16) | partial (gap) |
-| [ENC-7](../spec/encryption.md#enc-7) | *Key Rotation (Keyring)*: … master keys — typically previous keys; during a two-phase rollout, the incoming key. SDKs **MUST** reject configuration exceeding the cap at load — never silently truncate. | `encryption.json`: `keyring_three_decrypt_only_keys`, `keyring_four_decrypt_only_keys`
**Gap:** The rows give a configuration, not where it enters: whether a test loads each one through every way its SDK takes decrypt-only keys (CACHEKIT_PREVIOUS_MASTER_KEYS, the programmatic keyring, each constructor that reads them) is SDK test code. | uncovered (0/2) | uncovered (0/2) | partial (gap) |
+| [ENC-5](../spec/encryption.md#enc-5) | *`format` tokens*: … — the registry exists so independent implementations of the same entry agree on the bytes. Writers **MUST** emit only registry tokens; readers SHOULD reject tokens outside the registry before attempting decryption. | `interop-mode.json`: `interop_key_aad`, `interop_encryption_roundtrip`
`python-frame.json`: `default_saas_write_msgpack_bytestorage`, `default_saas_write_msgpack_bytestorage_bin`, `arrow_dataframe_write`, `forged_plaintext_orjson`, `ciphertext_key_not_in_keyring`, `ciphertext_other_tenant`, `auto_serializer_write`, `standard_serializer_instance_write`, `integrity_checking_off_write`, `arrow_compression_off_write`
**Gap:** Only cachekit-py's default, AutoSerializer, Arrow and OrjsonSerializer writes on plaintext frames, its default writes on encrypted frames, and the interop AAD are pinned against the token a writer emits: no frame holds an AutoSerializer numpy, Series or DataFrame write, no vector holds an auto-mode cachekit-ts or cachekit-rs write, and encryption.json takes the token as an input, so such a writer that emits an off-registry token passes; no SDK can seal with a fixed nonce, so only a test that decrypts what its write path stores under the aad_hex of the encryption.json vector with its shape catches it. | partial (8/12) | partial (gap) | partial (gap) |
+| [ENC-6](../spec/encryption.md#enc-6) | *`compressed` tokens*: … Python's `str(bool)` output; that coincidence is now frozen. Implementations in every language **MUST** emit exactly these byte sequences — `true`, `false`, `TRUE`, `1`, `0`, or a raw byte are all non-conformant and fail … | `encryption.json`: `basic_bytes`, `special_cache_key`, `compressed_basic`, `arrow_uncompressed`, `arrow_compressed`, `orjson_uncompressed`, `original_type_numpy`, `standard_serializer_default`, `standard_serializer_integrity_off`, `encrypted_with_k1`, `encrypted_with_k2`, `default_tenant_interop`, `master_key_every_hex_digit`, `master_key_first_byte_80`
`interop-mode.json`: `interop_key_aad`, `interop_encryption_roundtrip`
`tools/encryption-verify.py:verify_sealed_vector`
`tools/interop-crosscheck.mjs:aadV3`
**Gap:** The vectors pin the bytes a renderer emits for a JSON boolean, not the AAD an SDK's write path emits: an SDK whose writer spells the token itself, in stored metadata or on a second code path, and whose reader rebuilds the AAD from that spelling round-trips with itself and passes them through whichever renderer its test drives; no SDK can seal with a fixed nonce, so only a test that decrypts what its write path stores under the aad_hex of the vector with the same shape catches it. | partial (2/16) | partial (gap) | partial (gap) |
+| [ENC-7](../spec/encryption.md#enc-7) | *Key Rotation (Keyring)*: … master keys — typically previous keys; during a two-phase rollout, the incoming key. SDKs **MUST** reject configuration exceeding the cap at load — never silently truncate. | `encryption.json`: `keyring_three_decrypt_only_keys`, `keyring_four_decrypt_only_keys`
**Gap:** The rows give a configuration, not where it enters: whether a test loads each one through every way its SDK takes decrypt-only keys (CACHEKIT_PREVIOUS_MASTER_KEYS, the programmatic keyring, each constructor that reads them) is SDK test code. | uncovered (0/2) | partial (gap) | partial (gap) |
| [ENC-8](../spec/encryption.md#enc-8) | *Key Rotation (Keyring)*: A master key that has ever occupied the current (encrypting) slot **MUST NOT** be re-promoted to current; it may re-appear only in the decrypt-only list. Backing out a rotation means rotating … | Binds application code.
**Gap:** The spec makes this operator-enforced: nothing an SDK holds shows that a key was once current, and ENC-9 is the part an SDK can detect. | n/a | n/a | n/a |
-| [ENC-9](../spec/encryption.md#enc-9) | *Key Rotation (Keyring)*: … before, so this invariant is operator-enforced (treat retired key material as destroyed); SDKs **MUST** reject the detectable subset — a configuration where the current key also appears in the decrypt-only list. | `encryption.json`: `keyring_current_key_decrypt_only`, `keyring_current_key_decrypt_only_uppercase`
**Gap:** As ENC-7, and no row repeats the current key with white space or a 0x prefix, which rule 1 leaves open, so a string comparison that folds case but keeps those passes. | uncovered (0/2) | uncovered (0/2) | partial (gap) |
-| [ENC-10](../spec/encryption.md#enc-10) | *Encryption Flow*: The AAD inputs in step 4 **MUST** reflect what steps 1–2 actually produced — e.g. cachekit-py's default `StandardSerializer` path is `("msgpack", true, … | `interop-mode.json`: `interop_key_aad`, `interop_encryption_roundtrip`
`python-frame.json`: `default_saas_write_msgpack_bytestorage`, `default_saas_write_msgpack_bytestorage_bin`, `arrow_dataframe_write`, `integrity_checking_off_write`, `arrow_compression_off_write`, `auto_serializer_write`, `forged_plaintext_orjson`
`encryption.json`: `standard_serializer_default`, `standard_serializer_integrity_off`
**Gap:** cachekit-py's StandardSerializer writes with integrity checking on and off, and its Arrow writes with compression on and off, are pinned against the compressed flag they record, so a writer that records one flag whatever its container did fails one of each pair; the interop write is pinned against its container. The Arrow frames check frame structure, not IPC bytes, so an Arrow writer whose IPC body compression disagrees with the flag it records passes. No frame holds an AutoSerializer numpy, Series or DataFrame write, whose flag reaches an AAD only through an encryption wrapper built around AutoSerializer by hand, since cachekit-py's cache refuses AutoSerializer under encryption. No vector holds a cachekit-ts write, with compression on or off, or a cachekit-rs write: no SDK can seal with a fixed nonce, so only a test that decrypts what its write path stores under the aad_hex of the vector with its shape (compressed_basic, basic_bytes) catches a flag that disagrees with its container. | partial (6/11) | partial (6/11) | partial (gap) |
+| [ENC-9](../spec/encryption.md#enc-9) | *Key Rotation (Keyring)*: … before, so this invariant is operator-enforced (treat retired key material as destroyed); SDKs **MUST** reject the detectable subset — a configuration where the current key also appears in the decrypt-only list. | `encryption.json`: `keyring_current_key_decrypt_only`, `keyring_current_key_decrypt_only_uppercase`
**Gap:** As ENC-7, and no row repeats the current key with white space or a 0x prefix, which rule 1 leaves open, so a string comparison that folds case but keeps those passes. | uncovered (0/2) | partial (gap) | partial (gap) |
+| [ENC-10](../spec/encryption.md#enc-10) | *Encryption Flow*: The AAD inputs in step 4 **MUST** reflect what steps 1–2 actually produced — e.g. cachekit-py's default `StandardSerializer` path is `("msgpack", true, … | `interop-mode.json`: `interop_key_aad`, `interop_encryption_roundtrip`
`python-frame.json`: `default_saas_write_msgpack_bytestorage`, `default_saas_write_msgpack_bytestorage_bin`, `arrow_dataframe_write`, `integrity_checking_off_write`, `arrow_compression_off_write`, `auto_serializer_write`, `forged_plaintext_orjson`
`encryption.json`: `standard_serializer_default`, `standard_serializer_integrity_off`
**Gap:** cachekit-py's StandardSerializer writes with integrity checking on and off, and its Arrow writes with compression on and off, are pinned against the compressed flag they record, so a writer that records one flag whatever its container did fails one of each pair; the interop write is pinned against its container. The Arrow frames check frame structure, not IPC bytes, so an Arrow writer whose IPC body compression disagrees with the flag it records passes. No frame holds an AutoSerializer numpy, Series or DataFrame write, whose flag reaches an AAD only through an encryption wrapper built around AutoSerializer by hand, since cachekit-py's cache refuses AutoSerializer under encryption. No vector holds a cachekit-ts write, with compression on or off, or a cachekit-rs write: no SDK can seal with a fixed nonce, so only a test that decrypts what its write path stores under the aad_hex of the vector with its shape (compressed_basic, basic_bytes) catches a flag that disagrees with its container. | partial (6/11) | partial (gap) | partial (gap) |
## spec/file-backend-format.md
@@ -103,19 +103,19 @@ Marked not-a-requirement in `spec/cache-key-format.md`:
| [PRE-13](../spec/intent-presets.md#pre-13) | *Reliability Floor*: `production`, `secure` and `io` **MUST** enable the SDK's reliability stack by default — whatever the SDK ships of retry, circuit breaker, backpressure / … | **Gap:** A reliability stack shows only in how a client behaves under backend faults, which no vector reaches, so nothing here catches a production, secure or io preset that ships with it off. | gap | gap | gap |
| [PRE-14](../spec/intent-presets.md#pre-14) | *Reliability Floor*: Every preset's documentation **MUST** state what happens when the backend is unreachable — whether the error propagates to the caller or the wrapped … | **Gap:** A rule on each preset's documentation, which no vector or test can check. | gap | gap | gap |
| [PRE-15](../spec/intent-presets.md#pre-15) | *Encrypted Preset Name*: No SDK MAY introduce a further name or alias for this preset. In particular Python and TypeScript **MUST NOT** add `encrypted` aliases "for parity" — that creates a third spelling, not a second. | Binds cachekit-py, cachekit-ts only.
**Gap:** Which names an SDK exports is API surface no vector can observe, so nothing here catches an encrypted alias. | gap | gap | n/a |
-| [PRE-16](../spec/intent-presets.md#pre-16) | *Master Key Input*: The `secure` preset **MUST** accept the master key as a hex string and MUST derive the key bytes as `hex_decode(string)`. The same string MUST … | `interop-mode.json`: `interop_encryption_roundtrip`
`encryption.json`: `basic_bytes`, `special_cache_key`, `compressed_basic`, `arrow_uncompressed`, `arrow_compressed`, `orjson_uncompressed`, `original_type_numpy`, `standard_serializer_default`, `standard_serializer_integrity_off`, `encrypted_with_k1`, `encrypted_with_k2`, `default_tenant_interop`, `master_key_every_hex_digit`, `master_key_first_byte_80`
`tools/encryption-verify.py:verify_master_key_input`
`tools/encryption-verify.py:verify_wrong_entry_points`
**Gap:** Whether an SDK's test hands the hex string to its secure preset (which a preset that takes only raw bytes fails), rather than decoding it first for a lower-level API, is SDK test code no vector can check. Only default_tenant_interop and the accept rows master_key_every_hex_digit and master_key_first_byte_80 reach a preset that takes no tenant; the others are sealed under named tenants. No vector's verdict turns on an uppercase letter, white space or a 0x prefix, which rule 1 does not settle, so none shows what a preset does with a valid key that holds one. | partial (1/15) | partial (12/15) | partial (gap) |
-| [PRE-17](../spec/intent-presets.md#pre-17) | *Master Key Input*: The `secure` preset MUST accept the master key as a hex string and **MUST** derive the key bytes as `hex_decode(string)`. The same string MUST yield the same key bytes in every SDK. | `interop-mode.json`: `interop_encryption_roundtrip`
`encryption.json`: `basic_bytes`, `special_cache_key`, `compressed_basic`, `arrow_uncompressed`, `arrow_compressed`, `orjson_uncompressed`, `original_type_numpy`, `standard_serializer_default`, `standard_serializer_integrity_off`, `encrypted_with_k1`, `encrypted_with_k2`, `default_tenant_interop`, `master_key_every_hex_digit`, `master_key_first_byte_80`, `master_key_odd_65_digits`, `master_key_odd_63_digits`, `master_key_non_hex_digit`, `master_key_trailing_non_hex`, `master_key_plus_sign`
`tools/encryption-verify.py:verify_master_key_input`
`tools/encryption-verify.py:verify_wrong_entry_points`
**Gap:** Every key the vectors accept is exactly 32 bytes, so a decoder that accepts a longer key and cuts it to 32 bytes passes. No vector's verdict turns on an uppercase letter, white space or a 0x prefix, which rule 1 does not settle, so none shows what a decoder does with a valid key that holds one. Whether a test decodes each string through the preset's own path is SDK test code. | partial (1/20) | partial (17/20) | partial (gap) |
-| [PRE-18](../spec/intent-presets.md#pre-18) | *Master Key Input*: … master key as a hex string and MUST derive the key bytes as `hex_decode(string)`. The same string **MUST** yield the same key bytes in every SDK. | `interop-mode.json`: `interop_encryption_roundtrip`
`encryption.json`: `basic_bytes`, `special_cache_key`, `compressed_basic`, `arrow_uncompressed`, `arrow_compressed`, `orjson_uncompressed`, `original_type_numpy`, `standard_serializer_default`, `standard_serializer_integrity_off`, `encrypted_with_k1`, `encrypted_with_k2`, `default_tenant_interop`, `master_key_every_hex_digit`, `master_key_first_byte_80`, `master_key_odd_65_digits`, `master_key_odd_63_digits`, `master_key_non_hex_digit`, `master_key_trailing_non_hex`, `master_key_plus_sign`
`tools/encryption-verify.py:verify_master_key_input`
`tools/encryption-verify.py:verify_wrong_entry_points`
**Gap:** As PRE-17: SDKs that disagree on a key longer than 32 bytes, or on a valid key that holds an uppercase letter, white space or a 0x prefix, each pass, and whether each test feeds the string through its SDK's own preset path is SDK test code. | partial (1/20) | partial (17/20) | partial (gap) |
+| [PRE-16](../spec/intent-presets.md#pre-16) | *Master Key Input*: The `secure` preset **MUST** accept the master key as a hex string and MUST derive the key bytes as `hex_decode(string)`. The same string MUST … | `interop-mode.json`: `interop_encryption_roundtrip`
`encryption.json`: `basic_bytes`, `special_cache_key`, `compressed_basic`, `arrow_uncompressed`, `arrow_compressed`, `orjson_uncompressed`, `original_type_numpy`, `standard_serializer_default`, `standard_serializer_integrity_off`, `encrypted_with_k1`, `encrypted_with_k2`, `default_tenant_interop`, `master_key_every_hex_digit`, `master_key_first_byte_80`
`tools/encryption-verify.py:verify_master_key_input`
`tools/encryption-verify.py:verify_wrong_entry_points`
**Gap:** Whether an SDK's test hands the hex string to its secure preset (which a preset that takes only raw bytes fails), rather than decoding it first for a lower-level API, is SDK test code no vector can check. Only default_tenant_interop and the accept rows master_key_every_hex_digit and master_key_first_byte_80 reach a preset that takes no tenant; the others are sealed under named tenants. No vector's verdict turns on an uppercase letter, white space or a 0x prefix, which rule 1 does not settle, so none shows what a preset does with a valid key that holds one. | partial (1/15) | partial (gap) | partial (gap) |
+| [PRE-17](../spec/intent-presets.md#pre-17) | *Master Key Input*: The `secure` preset MUST accept the master key as a hex string and **MUST** derive the key bytes as `hex_decode(string)`. The same string MUST yield the same key bytes in every SDK. | `interop-mode.json`: `interop_encryption_roundtrip`
`encryption.json`: `basic_bytes`, `special_cache_key`, `compressed_basic`, `arrow_uncompressed`, `arrow_compressed`, `orjson_uncompressed`, `original_type_numpy`, `standard_serializer_default`, `standard_serializer_integrity_off`, `encrypted_with_k1`, `encrypted_with_k2`, `default_tenant_interop`, `master_key_every_hex_digit`, `master_key_first_byte_80`, `master_key_odd_65_digits`, `master_key_odd_63_digits`, `master_key_non_hex_digit`, `master_key_trailing_non_hex`, `master_key_plus_sign`
`tools/encryption-verify.py:verify_master_key_input`
`tools/encryption-verify.py:verify_wrong_entry_points`
**Gap:** Every key the vectors accept is exactly 32 bytes, so a decoder that accepts a longer key and cuts it to 32 bytes passes. No vector's verdict turns on an uppercase letter, white space or a 0x prefix, which rule 1 does not settle, so none shows what a decoder does with a valid key that holds one. Whether a test decodes each string through the preset's own path is SDK test code. | partial (1/20) | partial (gap) | partial (gap) |
+| [PRE-18](../spec/intent-presets.md#pre-18) | *Master Key Input*: … master key as a hex string and MUST derive the key bytes as `hex_decode(string)`. The same string **MUST** yield the same key bytes in every SDK. | `interop-mode.json`: `interop_encryption_roundtrip`
`encryption.json`: `basic_bytes`, `special_cache_key`, `compressed_basic`, `arrow_uncompressed`, `arrow_compressed`, `orjson_uncompressed`, `original_type_numpy`, `standard_serializer_default`, `standard_serializer_integrity_off`, `encrypted_with_k1`, `encrypted_with_k2`, `default_tenant_interop`, `master_key_every_hex_digit`, `master_key_first_byte_80`, `master_key_odd_65_digits`, `master_key_odd_63_digits`, `master_key_non_hex_digit`, `master_key_trailing_non_hex`, `master_key_plus_sign`
`tools/encryption-verify.py:verify_master_key_input`
`tools/encryption-verify.py:verify_wrong_entry_points`
**Gap:** As PRE-17: SDKs that disagree on a key longer than 32 bytes, or on a valid key that holds an uppercase letter, white space or a 0x prefix, each pass, and whether each test feeds the string through its SDK's own preset path is SDK test code. | partial (1/20) | partial (gap) | partial (gap) |
| [PRE-19](../spec/intent-presets.md#pre-19) | *Master Key Input*: When no key argument is supplied, `secure` **MUST** fall back to `CACHEKIT_MASTER_KEY`. When neither is present it MUST fail at construction (raise / `Err` / throw). It … | `encryption.json`: `default_tenant_interop`
**Gap:** The vector is an entry any secure preset can read with no key or tenant argument, so it catches a preset that ignores CACHEKIT_MASTER_KEY only when an SDK's test supplies the key through that variable and passes no key argument, which is SDK test code no vector can check. | uncovered (0/1) | partial (gap) | partial (gap) |
| [PRE-20](../spec/intent-presets.md#pre-20) | *Master Key Input*: … argument is supplied, `secure` MUST fall back to `CACHEKIT_MASTER_KEY`. When neither is present it **MUST** fail at construction (raise / `Err` / throw). It MUST NOT fall back to plaintext. | **Gap:** No vector stands for a missing key, so nothing here catches a secure preset that constructs with no key and fails only on first use. | gap | gap | gap |
| [PRE-21](../spec/intent-presets.md#pre-21) | *Master Key Input*: … When neither is present it MUST fail at construction (raise / `Err` / throw). It **MUST NOT** fall back to plaintext. | **Gap:** No vector stands for a missing key, so nothing here catches a secure preset that, given no key, builds a cache that stores plaintext. | gap | gap | gap |
-| [PRE-22](../spec/intent-presets.md#pre-22) | *Master Key Input*: Length: an SDK **MUST** accept a key of exactly 32 bytes (64 hex characters) and MUST reject anything shorter. It MAY accept longer keys. … | `interop-mode.json`: `interop_encryption_roundtrip`
`encryption.json`: `basic_bytes`, `special_cache_key`, `compressed_basic`, `arrow_uncompressed`, `arrow_compressed`, `orjson_uncompressed`, `original_type_numpy`, `standard_serializer_default`, `standard_serializer_integrity_off`, `encrypted_with_k1`, `encrypted_with_k2`, `default_tenant_interop`, `master_key_every_hex_digit`, `master_key_first_byte_80`
`tools/encryption-verify.py:verify_master_key_input`
`tools/encryption-verify.py:verify_wrong_entry_points`
**Gap:** As PRE-16, for a preset that refuses a 32-byte key. | partial (1/15) | partial (12/15) | partial (gap) |
+| [PRE-22](../spec/intent-presets.md#pre-22) | *Master Key Input*: Length: an SDK **MUST** accept a key of exactly 32 bytes (64 hex characters) and MUST reject anything shorter. It MAY accept longer keys. … | `interop-mode.json`: `interop_encryption_roundtrip`
`encryption.json`: `basic_bytes`, `special_cache_key`, `compressed_basic`, `arrow_uncompressed`, `arrow_compressed`, `orjson_uncompressed`, `original_type_numpy`, `standard_serializer_default`, `standard_serializer_integrity_off`, `encrypted_with_k1`, `encrypted_with_k2`, `default_tenant_interop`, `master_key_every_hex_digit`, `master_key_first_byte_80`
`tools/encryption-verify.py:verify_master_key_input`
`tools/encryption-verify.py:verify_wrong_entry_points`
**Gap:** As PRE-16, for a preset that refuses a 32-byte key. | partial (1/15) | partial (gap) | partial (gap) |
| [PRE-23](../spec/intent-presets.md#pre-23) | *Master Key Input*: Length: an SDK MUST accept a key of exactly 32 bytes (64 hex characters) and **MUST** reject anything shorter. It MAY accept longer keys. Because TypeScript accepts exactly 32 bytes while Python and Rust … | `encryption.json`: `master_key_31_bytes`, `master_key_24_bytes`, `master_key_16_bytes`, `master_key_31_bytes_and_crlf`, `master_key_31_bytes_with_spaces`, `master_key_0x_and_31_bytes`
`tools/encryption-verify.py:verify_master_key_input`
`tools/encryption-verify.py:verify_wrong_entry_points`
**Gap:** The rows reach whichever hex entry point an SDK's test feeds them to; whether it feeds them to every place a key enters (the preset's argument, CACHEKIT_MASTER_KEY, a companion constructor, a previous key), rather than to one shared decoder, is SDK test code no vector can check. The short keys are 16, 24 and 31 bytes, so a length rule that refuses those and accepts another length under 32 passes. | uncovered (0/6) | partial (gap) | partial (gap) |
| [PRE-24](../spec/intent-presets.md#pre-24) | *Master Key Input*: … Rust accept ≥ 32, portability across SDKs is guaranteed only at exactly 32 bytes — documentation **MUST** recommend exactly 32. | **Gap:** A rule on an SDK's documentation, which no vector or test can check. | gap | gap | gap |
-| [PRE-25](../spec/intent-presets.md#pre-25) | *Master Key Input*: … raw key bytes (the Rust idiom) through a *distinctly named* parameter or method. The hex form **MUST** be available on the preset itself, not only on a lower-level builder. A raw-bytes entry point MUST require exactly 32 … | `interop-mode.json`: `interop_encryption_roundtrip`
`encryption.json`: `basic_bytes`, `special_cache_key`, `compressed_basic`, `arrow_uncompressed`, `arrow_compressed`, `orjson_uncompressed`, `original_type_numpy`, `standard_serializer_default`, `standard_serializer_integrity_off`, `encrypted_with_k1`, `encrypted_with_k2`, `default_tenant_interop`, `master_key_every_hex_digit`, `master_key_first_byte_80`
**Gap:** As PRE-16, when the test hands the hex string to the preset itself: a preset that takes the key only as bytes, while a lower-level builder takes hex, passes a test that drives the builder. | partial (1/15) | partial (12/15) | partial (gap) |
+| [PRE-25](../spec/intent-presets.md#pre-25) | *Master Key Input*: … raw key bytes (the Rust idiom) through a *distinctly named* parameter or method. The hex form **MUST** be available on the preset itself, not only on a lower-level builder. A raw-bytes entry point MUST require exactly 32 … | `interop-mode.json`: `interop_encryption_roundtrip`
`encryption.json`: `basic_bytes`, `special_cache_key`, `compressed_basic`, `arrow_uncompressed`, `arrow_compressed`, `orjson_uncompressed`, `original_type_numpy`, `standard_serializer_default`, `standard_serializer_integrity_off`, `encrypted_with_k1`, `encrypted_with_k2`, `default_tenant_interop`, `master_key_every_hex_digit`, `master_key_first_byte_80`
**Gap:** As PRE-16, when the test hands the hex string to the preset itself: a preset that takes the key only as bytes, while a lower-level builder takes hex, passes a test that drives the builder. | partial (1/15) | partial (gap) | partial (gap) |
| [PRE-26](../spec/intent-presets.md#pre-26) | *Master Key Input*: … MUST be available on the preset itself, not only on a lower-level builder. A raw-bytes entry point **MUST** require exactly 32 bytes and reject anything else — the same 64-ASCII-character hex string that legitimately passes … | Binds cachekit-py, cachekit-rs only.
`interop-mode.json`: `interop_encryption_roundtrip`
`encryption.json`: `master_key_every_hex_digit`, `raw_key_31_bytes`, `raw_key_24_bytes`, `raw_key_16_bytes`, `raw_key_33_bytes`, `raw_key_ascii_hex_string`
`tools/encryption-verify.py:verify_master_key_input`
`tools/encryption-verify.py:verify_wrong_entry_points`
**Gap:** Each vector reaches a raw-bytes entry point only when an SDK's test drives it through one: the 32-byte keys of interop_encryption_roundtrip and master_key_every_hex_digit to be accepted (the second has bytes above 7f, which an entry point that passes the bytes through a text string corrupts), and the raw reject rows of 16, 24, 31 and 33 bytes and the ASCII bytes of a hex string to be refused, so one that refuses those and accepts another length but 32 passes. Whether a test drives them through every raw-bytes entry point the SDK offers, for current and previous keys alike, is SDK test code no vector can check. | partial (1/7) | n/a | partial (gap) |
| [PRE-27](../spec/intent-presets.md#pre-27) | *Master Key Input*: … is domain-separated by `tenant_id` (encryption.md → Tenant Key Derivation). The `secure` preset **MUST** default `tenant_id` to the literal string `"default"` when the caller supplies none, and MUST use that identical … | `encryption.json`: `default_tenant_interop`
`tools/encryption-verify.py:verify_default_tenant`
**Gap:** The vector states its tenant, "default", beside the bytes; whether an SDK's test builds each constructor that makes an encrypting client (the secure preset, a companion or an environment constructor) with no tenant and reads it, which a constructor that resolves "", a namespace or a deployment id fails, is SDK test code no vector can check, and a lower-level manager built with no tenant does not show what a preset passes it. | uncovered (0/1) | partial (gap) | partial (gap) |
-| [PRE-28](../spec/intent-presets.md#pre-28) | *Master Key Input*: … MUST default `tenant_id` to the literal string `"default"` when the caller supplies none, and **MUST** use that identical resolved value for both HKDF derivation and AAD construction (encryption.md → AAD). Two SDKs … | `interop-mode.json`: `interop_encryption_roundtrip`
`encryption.json`: `basic_bytes`, `special_cache_key`, `compressed_basic`, `arrow_uncompressed`, `arrow_compressed`, `orjson_uncompressed`, `original_type_numpy`, `standard_serializer_default`, `standard_serializer_integrity_off`, `encrypted_with_k1`, `encrypted_with_k2`, `default_tenant_interop`, `master_key_every_hex_digit`, `master_key_first_byte_80`
`tools/encryption-verify.py:verify_sealed_vector`
`tools/encryption-verify.py:verify_default_tenant`
`tools/encryption-verify.py:verify_master_key_input`
**Gap:** The vectors fail a reader whose key and AAD use different tenants, the default or a named one, only when its test drives the SDK's own key derivation and AAD builder from one resolved tenant; a test that compares the AAD with a vector's aad_hex, or hands the tenant to each step itself, passes a reader that derives under "default" and builds the AAD under "". The writer side is PRE-29's gap. | partial (1/15) | partial (12/15) | partial (gap) |
+| [PRE-28](../spec/intent-presets.md#pre-28) | *Master Key Input*: … MUST default `tenant_id` to the literal string `"default"` when the caller supplies none, and **MUST** use that identical resolved value for both HKDF derivation and AAD construction (encryption.md → AAD). Two SDKs … | `interop-mode.json`: `interop_encryption_roundtrip`
`encryption.json`: `basic_bytes`, `special_cache_key`, `compressed_basic`, `arrow_uncompressed`, `arrow_compressed`, `orjson_uncompressed`, `original_type_numpy`, `standard_serializer_default`, `standard_serializer_integrity_off`, `encrypted_with_k1`, `encrypted_with_k2`, `default_tenant_interop`, `master_key_every_hex_digit`, `master_key_first_byte_80`
`tools/encryption-verify.py:verify_sealed_vector`
`tools/encryption-verify.py:verify_default_tenant`
`tools/encryption-verify.py:verify_master_key_input`
**Gap:** The vectors fail a reader whose key and AAD use different tenants, the default or a named one, only when its test drives the SDK's own key derivation and AAD builder from one resolved tenant; a test that compares the AAD with a vector's aad_hex, or hands the tenant to each step itself, passes a reader that derives under "default" and builds the AAD under "". The writer side is PRE-29's gap. | partial (1/15) | partial (gap) | partial (gap) |
| [PRE-29](../spec/intent-presets.md#pre-29) | *Master Key Input*: … (encryption.md → AAD). Two SDKs pointed at the same key and the same default `tenant_id` **MUST** derive the same key bytes and produce mutually decryptable ciphertext; an SDK that resolves a different implicit … | `encryption.json`: `default_tenant_interop`
`tools/encryption-verify.py:verify_default_tenant`
**Gap:** The vector pins one reference ciphertext and its derived-key fingerprint, so it shows that an SDK reads under the default tenant, not that what it writes decrypts in another SDK; no SDK can seal with a fixed nonce, so only a write in one SDK read by another catches a writer whose key or AAD differs. | uncovered (0/1) | partial (gap) | partial (gap) |
| [PRE-30](../spec/intent-presets.md#pre-30) | *Encryption Activation*: Encryption **MUST** be activated only by explicit intent: choosing the `secure` preset, or passing an explicit encryption option / builder … | **Gap:** Whether a client encrypts is decided at construction: a planted plaintext entry catches activation by CACHEKIT_MASTER_KEY's presence (PRE-34's case), but nothing here catches activation by any other implicit signal, which a test must know of to provoke. | gap | gap | gap |
| [PRE-31](../spec/intent-presets.md#pre-31) | *Encryption Activation*: … preset (`encryption=True`, `.encryption(...)`, `{ encryption: … }`). An explicit encryption option **MUST** cause every operation on the constructed client to encrypt — an SDK MUST NOT accept the option, report success, and … | `interop-mode.json`: `interop_encryption_roundtrip`, `issue_example_object`
`encryption.json`: `default_tenant_interop`
**Gap:** The ciphertext vectors are stored interop entries a test can plant and read through a client built with an explicit encryption option, which a read path that ignores the option fails. Only a test that plants issue_example_object under that client and expects a miss or an error catches a read path that also serves plaintext. Which read paths a test drives is SDK test code, and no vector shows what a write path stores, since no SDK can seal with a fixed nonce. | partial (2/3) | partial (gap) | partial (gap) |
@@ -175,7 +175,7 @@ Marked not-a-requirement in `spec/intent-presets.md`:
| [IOP-16](../spec/interop-mode.md#iop-16) | *Number canonicalization*: … range (snowflake IDs work). In JavaScript, integers above 2⁵³ MUST be handled as `BigInt`; the SDK **MUST** error on a non-integral-safe `Number` rather than silently rounding. | Binds cachekit-ts only.
`tools/interop-crosscheck.mjs:unsafeNumberSelftest`
**Gap:** The self-test exercises the reference cross-check, not cachekit-ts: Python and Rust read a bare JSON integer beyond 2^53 exactly, so no shared vector can carry the unsafe Number only JavaScript would see. | n/a | uncovered | n/a |
| [IOP-17](../spec/interop-mode.md#iop-17) | *Interop Value Format*: Writers SHOULD emit canonical encoding (shortest forms, sorted map keys) and **MUST** do so to match the published value vectors. Unlike the args profile, the value profile does not apply number … | `interop-mode.json`: `issue_example_object`, `float_value_stays_float64`, `mixed_array`, `datetime_sentinel_value`, `proto_key_value`, `bom_strings_value`
**Gap:** The vectors pin the bytes an SDK's value encoder emits for each value, not the bytes its write path stores: an SDK whose write path serializes through a general MessagePack library instead, taking map order and number widths from the caller's type, passes them through whichever encoder its test drives; only a test that writes each value through the SDK's write path and compares the stored bytes catches it. | partial (gap) | partial (gap) | partial (gap) |
| [IOP-18](../spec/interop-mode.md#iop-18) | *Interop Value Format*: Readers **MUST** accept any well-formed MessagePack document, canonical or not — and MUST consume exactly one document, rejecting … | `interop-mode.json`: `issue_example_object`, `float_value_stays_float64`, `mixed_array`, `datetime_sentinel_value`, `proto_key_value`, `bom_strings_value`, `reader_padded_int_widths`, `reader_padded_headers`, `reader_unsorted_map_keys`, `reader_float32`, `reader_non_string_map_key`, `reader_ext_type`
`decode-bounds.json`: `nested_fixarray_depth_32`, `nested_fixmap_depth_32`, `array16_256_backed_nils`
**Gap:** The reader vectors hold one non-string map key, an integer that fits a fixint, and stock decoders reject other well-formed keys: @msgpack/msgpack 3.1.3 throws on a nil, bool, bin, array or map key and on an integer key written at 64-bit width, and msgpack-python with strict_map_key=False on an array or map key, so a reader built on either passes every vector without accepting every well-formed document. | partial (gap) | partial (gap) | partial (gap) |
-| [IOP-19](../spec/interop-mode.md#iop-19) | *Interop Value Format*: Readers MUST accept any well-formed MessagePack document, canonical or not — and **MUST** consume exactly one document, rejecting trailing bytes. This strictness is load-bearing: a Python auto-mode CK frame … | `interop-mode.json`: `reader_trailing_byte`
`python-frame.json`: `ck_frame_fed_to_interop_reader`
`encryption.json`: `container_trailing_byte_to_interop_reader`, `container_incomplete_tail_to_interop_reader`
**Gap:** Both post-decryption rows end in exactly one byte after the document, so an end-of-input check that tolerates one stray byte after a decrypt passes them; only ck_frame_fed_to_interop_reader, a plaintext read, has more. | partial (2/4) | partial (2/4) | partial (gap) |
+| [IOP-19](../spec/interop-mode.md#iop-19) | *Interop Value Format*: Readers MUST accept any well-formed MessagePack document, canonical or not — and **MUST** consume exactly one document, rejecting trailing bytes. This strictness is load-bearing: a Python auto-mode CK frame … | `interop-mode.json`: `reader_trailing_byte`
`python-frame.json`: `ck_frame_fed_to_interop_reader`
`encryption.json`: `container_trailing_byte_to_interop_reader`, `container_incomplete_tail_to_interop_reader`
**Gap:** Both post-decryption rows end in exactly one byte after the document, so an end-of-input check that tolerates one stray byte after a decrypt passes them; only ck_frame_fed_to_interop_reader, a plaintext read, has more. | partial (2/4) | partial (gap) | partial (gap) |
| [IOP-20](../spec/interop-mode.md#iop-20) | *Interop Value Format*: … does NOT — it deserializes one value and ignores trailing bytes — so a Rust interop reader **MUST** add an explicit end-of-input check (e.g. drive a `Deserializer::from_read_ref` and verify the input is exhausted). On … | Binds cachekit-rs only.
`interop-mode.json`: `reader_trailing_byte`
`python-frame.json`: `ck_frame_fed_to_interop_reader`
`encryption.json`: `container_trailing_byte_to_interop_reader`, `container_incomplete_tail_to_interop_reader`
**Gap:** Both post-decryption rows end in exactly one byte after the document, so an end-of-input check that tolerates one stray byte after a decrypt passes them; only ck_frame_fed_to_interop_reader, a plaintext read, has more. | n/a | n/a | partial (gap) |
| [IOP-21](../spec/interop-mode.md#iop-21) | *SDK Implementation Requirements*: An SDK implementation of interop mode **MUST**: | `interop-mode.json` (all 93)
`interop-mode.json`: `small_int_as_bigint`
**Gap:** The list's demands on segment validation, normalization, value encoding and hashing share the holes still recorded on IOP-2, IOP-8, IOP-10, IOP-13, IOP-16 and IOP-17; no vector reaches the 32-bit length tier (untested by design); binding named and defaulted arguments to their positions is an SDK API property no vector observes; leaving auto mode unchanged is pinned only where an SDK vendors cache-keys.json or wire-format.json. | partial (gap) | partial (gap) | partial (gap) |
| [IOP-22](../spec/interop-mode.md#iop-22) | *SDK Implementation Requirements*: Pass every vector in `test-vectors/interop-mode.json`, including the `error_vectors` (which **MUST** raise) and — when the SDK supports encryption — the `interop_encryption_roundtrip` decrypt. | `interop-mode.json`: `reject_nan`, `reject_infinity`, `reject_negative_infinity`, `reject_int_overflow`, `reject_int_underflow`, `reject_naive_datetime`, `reject_uppercase_namespace`, `reject_colon_in_operation`, `reject_trailing_newline`, `reject_reserved_namespace_ns`, `reject_reserved_namespace_nsapi`, `reject_double_dot_namespace`, `reject_double_dot_operation`, `reject_namespace_too_long`, `reject_operation_too_long`, `reject_leading_dot_namespace`, `reject_leading_hyphen_operation`, `reject_leading_underscore_operation`, `reject_empty_namespace`, `reject_empty_operation`, `reject_non_ascii_digit_namespace`, `reject_non_ascii_letter_operation`, `reject_fullwidth_operation`, `reject_slash_namespace`, `reject_space_operation`, `reject_uppercase_operation`, `reject_colon_in_namespace`, `reject_trailing_newline_operation`, `reject_naive_datetime_nested`, `reject_nan_nested`, `reject_int_overflow_in_list`, `reject_int_underflow_in_map`, `reject_int_overflow_in_map`, `reject_int_underflow_in_list` | covered | covered | covered |
diff --git a/conformance/sdks.json b/conformance/sdks.json
index 8ed3d46..1adb24a 100644
--- a/conformance/sdks.json
+++ b/conformance/sdks.json
@@ -15,15 +15,15 @@
},
"cachekit-ts": {
"repository": "https://github.com/cachekit-io/cachekit-ts",
- "commit": "46f5a6a794b9e2bfff1d71a3bf0b31f5666de09e",
+ "commit": "83e0566251b0b0d2cad4557b37b3041c9b2aa9a0",
"fixtures": {
"decode-bounds.json": "c52c27f724fe138e63440dc0306936b48fe389e2bd823c058b86b30d854e2e2e",
- "encryption.json": "f701951147a47c42a968850fe6cb73a544313728e46f45fec3d811c20ed4b377",
+ "encryption.json": "1a8a3735408675bf9660ce1372f77723a9ffda8084010fa52eb756a330961589",
"file-backend.json": "b7b0c51935a3a00ab340005ae1e091797b14938f92eecae23346bdccf0d12af4",
"freshness-headers.json": "74beb975b6855f52d9dd453279873faf8048a9c3c79a8167cfda099beec5fcd3",
"interop-mode.json": "e1ca6c2361509f347d17f3352e0d7ab4d4b61488737bdf0056bb5769d9794e72",
"path-encoding.json": "807af0d39ccf3f5afda65577787ac5adcd01ff6617468060161c84267fa7e0cb",
- "python-frame.json": "1210a2cdf00ef420e59d4d1c75f4979385ad1cb023181b39f46d7f994761770a",
+ "python-frame.json": "b677d5f14de4a3cd1fa5307d4b46eae0545a20e51e9163f96495ee7ad15600d0",
"wire-format.json": "2f6818903a552a7c09414c1e5c02caf21fa92dcd56ccff5634c8257038e7575c"
}
},
diff --git a/sdk-feature-matrix.md b/sdk-feature-matrix.md
index 101cadf..96d2b45 100644
--- a/sdk-feature-matrix.md
+++ b/sdk-feature-matrix.md
@@ -300,7 +300,7 @@ its spec:
| AAD v0x03 | ⚠️ Compliant (5 components — every auto serializer appends `original_type`; interop mode is the sole 4-component path) except the `cache_key` component behind a prefixing backend: through `cachekit` 0.22.0 it omits the backend's key prefix (`MemcachedBackend` `key_prefix`, the tenant-scoped Redis backend's `t:{tenant}:`), short of the [logical backend key](spec/encryption.md#additional-authenticated-data-aad); bound in [cachekit-py#518](https://github.com/cachekit-io/cachekit-py/pull/518), unreleased (see the Encryption table); and the post-decryption container rule ([ENC-2](spec/encryption.md#enc-2)/[ENC-3](spec/encryption.md#enc-3)): through `cachekit` 0.22.0 an encrypted Arrow read accepts bare Arrow IPC; fixed in [cachekit-py#517](https://github.com/cachekit-io/cachekit-py/pull/517), unreleased (see the Encryption section); and, through `cachekit` 0.22.0, a config-drift read by an `auto` handler with encryption off sniffs the decrypted plaintext of an entry stored under the `auto` serializer name ([ENC-2](spec/encryption.md#enc-2)/[ENC-3](spec/encryption.md#enc-3)); fixed in [cachekit-py#521](https://github.com/cachekit-io/cachekit-py/pull/521), unreleased (see the Encryption section); and, from `cachekit` 0.1.0 through 0.22.0, a directly built `EncryptionWrapper(serializer=AutoSerializer())` sniffs the decrypted plaintext ([ENC-2](spec/encryption.md#enc-2)/[ENC-3](spec/encryption.md#enc-3)); fixed in [cachekit-py#521](https://github.com/cachekit-io/cachekit-py/pull/521), unreleased; and, from `cachekit` 0.1.0 through 0.4.0, the default `EncryptionWrapper` constructor and every encrypted read by a cache handler built with `encryption=True` sniff it too; 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 on `main`, unreleased ([cachekit-rs#100](https://github.com/cachekit-io/cachekit-rs/pull/100), which adds the empty key); rejects `.`, `..`, `health`, `ttl` and `lock` since `cachekit-rs` 0.8.0 ([cachekit-rs#76](https://github.com/cachekit-io/cachekit-rs/pull/76); `.crate` inspected 2026-09-30) | ✅ Compliant on `main`, unreleased ([cachekit-ts#118](https://github.com/cachekit-io/cachekit-ts/pull/118)) | ❌ Not implemented |
-| Test vectors in CI¹⁶ | ✅ interop/v1 (full set, incl. AAD + encryption through the real stack) — fixture 1.1.0 (`ns`/`nsapi` namespace reservation) since PyPI 0.20.0 ([cachekit-py#350](https://github.com/cachekit-io/cachekit-py/pull/350)); fixture 1.2.0 (no `..` in a segment) 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 | ✅ 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 in [cachekit-rs#100](https://github.com/cachekit-io/cachekit-rs/pull/100), unreleased, driving both the `encode_key` and the URL-builder tests; fixture 1.2.0 (encoder-mistake rows) and `freshness-headers.json` 1.0.0, vendored byte for byte (sha256-pinned) + CI-executed in [cachekit-rs#146](https://github.com/cachekit-io/cachekit-rs/pull/146), unreleased, every freshness row run through the `GET` response's header reader (`freshness_from_headers`); `file-backend.json` 1.1.0 vendored byte for byte + CI-executed since 0.9.0 ([cachekit-rs#107](https://github.com/cachekit-io/cachekit-rs/pull/107); `.crate` inspected 2026-10-05), by tests in `src/backend/file.rs` behind the `file` feature, which default CI enables; `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. 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) | ⚠️ 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 | ✅ 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 in [cachekit-rs#100](https://github.com/cachekit-io/cachekit-rs/pull/100), unreleased, driving both the `encode_key` and the URL-builder tests; fixture 1.2.0 (encoder-mistake rows) and `freshness-headers.json` 1.0.0, vendored byte for byte (sha256-pinned) + CI-executed in [cachekit-rs#146](https://github.com/cachekit-io/cachekit-rs/pull/146), unreleased, every freshness row run through the `GET` response's header reader (`freshness_from_headers`); `file-backend.json` 1.1.0 vendored byte for byte + CI-executed since 0.9.0 ([cachekit-rs#107](https://github.com/cachekit-io/cachekit-rs/pull/107); `.crate` inspected 2026-10-05), by tests in `src/backend/file.rs` behind the `file` feature, which default CI enables; `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 |
| 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) (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 two gaps held as expected failures (see the Wire format and Temporal sentinels rows). `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) (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) (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 vendors `1.3.0` byte for byte and runs its accept and hex reject rows in [cachekit-ts#222](https://github.com/cachekit-io/cachekit-ts/pull/222) (unreleased). cachekit-rs vendors `1.5.0` byte for byte in [cachekit-rs#160](https://github.com/cachekit-io/cachekit-rs/pull/160) (unreleased). 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.1.0` (17 vectors; the first 10 byte-identical to the v0.5.0 originals); cachekit-py vendors `1.1.0` byte for byte since 0.21.0 ([cachekit-py#460](https://github.com/cachekit-io/cachekit-py/pull/460)).
+> ¹⁶ "Test vectors in CI" = vectors the SDK's own default CI executes. Beyond the SDKs, this repo's `verify.yml` CI-verifies `interop-mode.json`, `encryption.json`, `python-frame.json`, `file-backend.json` ([`tools/file-backend-reference.py`](tools/file-backend-reference.py)), and — since LAB-423 — `wire-format.json` ([`tools/wire-format-reference.py`](tools/wire-format-reference.py)), and — since LAB-2503 — `decode-bounds.json` ([`tools/decode-bounds-reference.py`](tools/decode-bounds-reference.py), `verify` in both the stdlib and the optional-deps legs), and — since LAB-2879 — `path-encoding.json` ([`tools/path-encoding-verify.py`](tools/path-encoding-verify.py)), and `freshness-headers.json` ([`tools/freshness-headers-verify.py`](tools/freshness-headers-verify.py)) against reference implementations. This repo's `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 two gaps held as expected failures (see the Wire format and Temporal sentinels rows). `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) (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). 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.1.0` (17 vectors; the first 10 byte-identical to the v0.5.0 originals); cachekit-py vendors `1.1.0` byte for byte since 0.21.0 ([cachekit-py#460](https://github.com/cachekit-io/cachekit-py/pull/460)).
>
> ¹⁷ Version cells are **floors** (`X+`), not snapshots — they stay true as new versions publish; check the registry for the current release. Python's floor is the first *installable* one: interop merged under the `v0.13.0` tag, but neither `0.12.0` nor `0.13.0` was ever published to PyPI, so `0.14.0` is the earliest PyPI release containing interop mode. Do not "correct" this to 0.13.0 from the cachekit-py changelog alone.