diff --git a/changelog.d/20261009_lab-8295.md b/changelog.d/20261009_lab-8295.md new file mode 100644 index 0000000..3fb1aa5 --- /dev/null +++ b/changelog.d/20261009_lab-8295.md @@ -0,0 +1,16 @@ +### Conformance — cachekit-py vendors encryption.json 1.5.0, cache-keys.json 1.3.0 and the current python-frame.json (LAB-8295) + +- [`conformance/sdks.json`](conformance/sdks.json) reads cachekit-py at + [`d49b984`](https://github.com/cachekit-io/cachekit-py/commit/d49b984ea9560ae936d8215a17ab537cc3994a82), the merge + commit of [cachekit-py#561](https://github.com/cachekit-io/cachekit-py/pull/561), which vendors `encryption.json` + `1.5.0`, recorded for the first time, and re-vendors `cache-keys.json` `1.3.0` and the current `python-frame.json`, + all byte for byte. Every cachekit-py copy was re-hashed at that commit, and only those three changed. In + [`conformance/coverage.md`](conformance/coverage.md), cachekit-py's `KEY-2`, `KEY-6` to `KEY-9` move to `covered`; + `ENC-1` to `ENC-3`, `ENC-7`, `ENC-9`, `KEY-10`, `PRE-19`, `PRE-23`, `PRE-27` and `PRE-29` move from `uncovered` to + `partial (gap)`; and `KEY-1`, `KEY-3` to `KEY-5`, `ENC-5`, `ENC-6`, `ENC-10`, `PRE-16` to `PRE-18`, `PRE-22`, + `PRE-25`, `PRE-26`, `PRE-28`, `PRE-31`, `PRE-32` and `IOP-19` from a partial vector count to `partial (gap)`. +- [Feature matrix](sdk-feature-matrix.md#protocol-compliance) **Test vectors in CI** row records cachekit-py's three + copies (unreleased), with one deviation: two `keyring.configuration` cases run as strict expected failures naming + [ENC-9](spec/encryption.md#enc-9), because cachekit-py refuses the current key repeated in + `CACHEKIT_PREVIOUS_MASTER_KEYS` only on the first call when that key is given as `master_key=`. Note ¹⁶ records all three + copies and gives this repo's `cache-keys.json` as `1.3.0`, with what `1.2.0` and `1.3.0` add. diff --git a/conformance/coverage.md b/conformance/coverage.md index c18bb37..3df2cf5 100644 --- a/conformance/coverage.md +++ b/conformance/coverage.md @@ -12,7 +12,7 @@ How ids are assigned, and what each status means: [README.md](README.md#coverage | SDK | Read at | Vendored fixtures | | :--- | :--- | :--- | -| cachekit-py | [`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-py | [`d49b984`](https://github.com/cachekit-io/cachekit-py/commit/d49b984ea9560ae936d8215a17ab537cc3994a82) | `cache-keys.json` 1.3.0, `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-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 | @@ -30,7 +30,7 @@ How ids are assigned, and what each status means: [README.md](README.md#coverage | SDK | Covered | Partial | Uncovered | Gap | n/a | | :--- | ---: | ---: | ---: | ---: | ---: | -| cachekit-py | 7 | 81 | 15 | 67 | 47 | +| cachekit-py | 12 | 86 | 5 | 67 | 47 | | cachekit-ts | 9 | 78 | 7 | 52 | 71 | | cachekit-rs | 4 | 69 | 3 | 53 | 88 | @@ -40,16 +40,16 @@ Not indexed yet: [`spec/interop-v2.md`](../spec/interop-v2.md). | Id | Requirement | Vectors, tests and gaps | cachekit-py | cachekit-ts | cachekit-rs | | :--- | :--- | :--- | :--- | :--- | :--- | -| [KEY-1](../spec/cache-key-format.md#key-1) | *Serializer Codes*: The code **MUST** be derived from the serializer the cache is configured with, never a fixed default. An SDK that emits one constant … | Binds cachekit-py only.
`cache-keys.json`: `serializer_default`, `serializer_auto`, `serializer_pythonic_alias`, `serializer_orjson`, `serializer_arrow`, `serializer_local`, `serializer_custom_identity`, `serializer_derived_spec_example`, `serializer_derived_leading_zero`, `serializer_custom_identity_orjson`, `serializer_custom_identity_non_ascii`
**Gap:** The vectors give the serializer to key generation as an input; whether an SDK's test keys a call through a cache configured with it, which a cache that hands the generator a fixed default fails, is SDK test code no vector can check. | partial (7/11) | n/a | n/a | -| [KEY-2](../spec/cache-key-format.md#key-2) | *Serializer Codes*: Two serializer identities that the wire format records differently **MUST NOT** be mapped onto one code by construction. The guarantee is probabilistic, not absolute: the derived code carries 16 … | Binds cachekit-py only.
`cache-keys.json`: `serializer_default`, `serializer_auto`, `serializer_orjson`, `serializer_arrow`, `serializer_local`, `serializer_custom_identity`, `serializer_derived_spec_example`, `serializer_derived_leading_zero`, `serializer_custom_identity_orjson`, `serializer_custom_identity_non_ascii`, `object_arrow_serializer`, `object_class_named_auto`, `object_standard_serializer`, `object_orjson_serializer`, `object_auto_serializer`
`python-frame.json`: `std_alias_write`, `pythonic_alias_write` | partial (6/17) | n/a | n/a | -| [KEY-3](../spec/cache-key-format.md#key-3) | *Serializer Codes*: An SDK that offers more than one serializer identity **MUST** record the serializer name in the storage container of every serialized entry it stores under a key in this format … | Binds cachekit-py only.
`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`, `std_alias_write`, `pythonic_alias_write`, `integrity_checking_off_write`, `arrow_compression_off_write`
`tools/python-frame-reference.py:verify`
**Gap:** Encrypted writes are pinned only for the default serializer, so a writer that records the name on encrypted entries only for that serializer passes. | partial (6/12) | n/a | n/a | -| [KEY-4](../spec/cache-key-format.md#key-4) | *Serializer Codes*: … read of a serialized entry under a key in this format, before decoding the payload, such an SDK **MUST** compare the name the container records with the name it would itself record for its configured serializer, and MUST … | Binds cachekit-py only.
`python-frame.json`: `default_saas_write_msgpack_bytestorage`, `default_saas_write_msgpack_bytestorage_bin`, `arrow_dataframe_write`, `auto_serializer_write`, `standard_serializer_instance_write`, `std_alias_write`, `pythonic_alias_write`, `arrow_compression_off_write`, `std_recorded_as_given_frame`, `ciphertext_serializer_name_auto`
**Gap:** A test exercises the check by planting a frame under another configuration's reader: auto_serializer_write, standard_serializer_instance_write and std_recorded_as_given_frame carry the default write's payload, and ciphertext_serializer_name_auto an encrypted write that authenticates under the reader's key. A default-configured reader that skips the check, on plaintext or on encrypted entries, returns the value, and so does one that resolves the recorded name through its alias table before it compares, on plaintext entries; a reader configured with a StandardSerializer() instance must read standard_serializer_instance_write, which one that compares its key identity (:StandardSerializer) instead of the recorded name misses. No encrypted frame records an alias, so a reader whose encrypted path alone resolves names through its alias table passes. A reader that decodes the payload before it compares names reaches the same outcome on every frame, so no vector shows the order, and none records a name that differs from the reader's only in case. | partial (3/10) | n/a | n/a | -| [KEY-5](../spec/cache-key-format.md#key-5) | *Serializer Codes*: … name the container records with the name it would itself record for its configured serializer, and **MUST** reject the entry on mismatch — a miss, never a value. For such an SDK, an entry that records no serializer name is a … | Binds cachekit-py only.
`python-frame.json`: `forged_plaintext_orjson`, `default_saas_write_msgpack_bytestorage_bin`, `std_recorded_as_given_frame`, `auto_serializer_write`, `standard_serializer_instance_write`, `serializer_name_missing`, `serializer_name_empty`, `ciphertext_serializer_name_auto`, `ciphertext_serializer_name_missing`
`tools/python-frame-reference.py:_require_serializer_name`
**Gap:** A reader configured with auto or a StandardSerializer() instance must miss on the default write, whose payload it decodes, which is the reverse pair a test plants. The vectors show which frames a reader must reject, plaintext and encrypted; that the rejection reaches the caller as a miss, not as an error, is what a test asserts when it reads them through its SDK's cache rather than its frame parser. No frame records a name that differs from the reader's only in case, and no encrypted frame records an empty name. | partial (2/9) | n/a | n/a | -| [KEY-6](../spec/cache-key-format.md#key-6) | *Serializer Codes*: Conversely, one identity **MUST** always produce one code. Derive it from the serializer configuration alone — the canonical name the wire format … | Binds cachekit-py only.
`cache-keys.json`: `serializer_default`, `serializer_auto`, `serializer_pythonic_alias`, `serializer_orjson`, `serializer_arrow`, `serializer_local`, `serializer_custom_identity`, `serializer_derived_spec_example`, `serializer_derived_leading_zero`, `serializer_custom_identity_orjson`, `serializer_custom_identity_non_ascii`, `object_arrow_serializer`, `object_class_named_auto`, `object_standard_serializer`, `object_orjson_serializer`, `object_auto_serializer` | partial (7/16) | n/a | n/a | -| [KEY-7](../spec/cache-key-format.md#key-7) | *Serializer Codes*: Deriving the identity. An SDK that accepts alias spellings **MUST** resolve each accepted spelling to exactly one canonical name, and the code, the recorded name, and the serializer that … | Binds cachekit-py only.
`cache-keys.json`: `single_integer`, `serializer_pythonic_alias`
`python-frame.json`: `std_alias_write`, `pythonic_alias_write` | partial (2/4) | n/a | n/a | -| [KEY-8](../spec/cache-key-format.md#key-8) | *Serializer Codes*: … one canonical name, and the code, the recorded name, and the serializer that writes the bytes **MUST** all be that canonical name's, or a key, its stored entry and its bytes can disagree about which serializer wrote it. … | Binds cachekit-py only.
`cache-keys.json`: `single_integer`, `serializer_pythonic_alias`
`python-frame.json`: `std_alias_write`, `pythonic_alias_write` | partial (2/4) | n/a | n/a | -| [KEY-9](../spec/cache-key-format.md#key-9) | *Serializer Codes*: … its bytes can disagree about which serializer wrote it. An SDK that accepts a serializer object **MUST** reduce it to a string identity carrying a marker that no code-table name, alias spelling or accepted serializer name … | Binds cachekit-py only.
`cache-keys.json`: `serializer_custom_identity`, `object_arrow_serializer`, `object_class_named_auto`, `object_standard_serializer`, `object_orjson_serializer`, `object_auto_serializer` | partial (1/6) | n/a | n/a | -| [KEY-10](../spec/cache-key-format.md#key-10) | *Serializer Codes*: … a table code (the Python note below gives `cachekit-py`'s marker). An empty or non-string identity **MUST** be rejected with an error, never mapped to a code: a fallback code is a shared bucket, and a key computed from it … | Binds cachekit-py only.
`cache-keys.json`: `serializer_type_empty`, `serializer_type_null`, `serializer_type_integer`, `serializer_type_truthy_integer`
**Gap:** The rows give the identity to key generation directly, so a cache that turns an empty or missing serializer into its default before key generation sees it passes them; whether a cache configured that way refuses it is SDK test code. | uncovered (0/4) | n/a | n/a | +| [KEY-1](../spec/cache-key-format.md#key-1) | *Serializer Codes*: The code **MUST** be derived from the serializer the cache is configured with, never a fixed default. An SDK that emits one constant … | Binds cachekit-py only.
`cache-keys.json`: `serializer_default`, `serializer_auto`, `serializer_pythonic_alias`, `serializer_orjson`, `serializer_arrow`, `serializer_local`, `serializer_custom_identity`, `serializer_derived_spec_example`, `serializer_derived_leading_zero`, `serializer_custom_identity_orjson`, `serializer_custom_identity_non_ascii`
**Gap:** The vectors give the serializer to key generation as an input; whether an SDK's test keys a call through a cache configured with it, which a cache that hands the generator a fixed default fails, is SDK test code no vector can check. | partial (gap) | n/a | n/a | +| [KEY-2](../spec/cache-key-format.md#key-2) | *Serializer Codes*: Two serializer identities that the wire format records differently **MUST NOT** be mapped onto one code by construction. The guarantee is probabilistic, not absolute: the derived code carries 16 … | Binds cachekit-py only.
`cache-keys.json`: `serializer_default`, `serializer_auto`, `serializer_orjson`, `serializer_arrow`, `serializer_local`, `serializer_custom_identity`, `serializer_derived_spec_example`, `serializer_derived_leading_zero`, `serializer_custom_identity_orjson`, `serializer_custom_identity_non_ascii`, `object_arrow_serializer`, `object_class_named_auto`, `object_standard_serializer`, `object_orjson_serializer`, `object_auto_serializer`
`python-frame.json`: `std_alias_write`, `pythonic_alias_write` | covered | n/a | n/a | +| [KEY-3](../spec/cache-key-format.md#key-3) | *Serializer Codes*: An SDK that offers more than one serializer identity **MUST** record the serializer name in the storage container of every serialized entry it stores under a key in this format … | Binds cachekit-py only.
`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`, `std_alias_write`, `pythonic_alias_write`, `integrity_checking_off_write`, `arrow_compression_off_write`
`tools/python-frame-reference.py:verify`
**Gap:** Encrypted writes are pinned only for the default serializer, so a writer that records the name on encrypted entries only for that serializer passes. | partial (gap) | n/a | n/a | +| [KEY-4](../spec/cache-key-format.md#key-4) | *Serializer Codes*: … read of a serialized entry under a key in this format, before decoding the payload, such an SDK **MUST** compare the name the container records with the name it would itself record for its configured serializer, and MUST … | Binds cachekit-py only.
`python-frame.json`: `default_saas_write_msgpack_bytestorage`, `default_saas_write_msgpack_bytestorage_bin`, `arrow_dataframe_write`, `auto_serializer_write`, `standard_serializer_instance_write`, `std_alias_write`, `pythonic_alias_write`, `arrow_compression_off_write`, `std_recorded_as_given_frame`, `ciphertext_serializer_name_auto`
**Gap:** A test exercises the check by planting a frame under another configuration's reader: auto_serializer_write, standard_serializer_instance_write and std_recorded_as_given_frame carry the default write's payload, and ciphertext_serializer_name_auto an encrypted write that authenticates under the reader's key. A default-configured reader that skips the check, on plaintext or on encrypted entries, returns the value, and so does one that resolves the recorded name through its alias table before it compares, on plaintext entries; a reader configured with a StandardSerializer() instance must read standard_serializer_instance_write, which one that compares its key identity (:StandardSerializer) instead of the recorded name misses. No encrypted frame records an alias, so a reader whose encrypted path alone resolves names through its alias table passes. A reader that decodes the payload before it compares names reaches the same outcome on every frame, so no vector shows the order, and none records a name that differs from the reader's only in case. | partial (gap) | n/a | n/a | +| [KEY-5](../spec/cache-key-format.md#key-5) | *Serializer Codes*: … name the container records with the name it would itself record for its configured serializer, and **MUST** reject the entry on mismatch — a miss, never a value. For such an SDK, an entry that records no serializer name is a … | Binds cachekit-py only.
`python-frame.json`: `forged_plaintext_orjson`, `default_saas_write_msgpack_bytestorage_bin`, `std_recorded_as_given_frame`, `auto_serializer_write`, `standard_serializer_instance_write`, `serializer_name_missing`, `serializer_name_empty`, `ciphertext_serializer_name_auto`, `ciphertext_serializer_name_missing`
`tools/python-frame-reference.py:_require_serializer_name`
**Gap:** A reader configured with auto or a StandardSerializer() instance must miss on the default write, whose payload it decodes, which is the reverse pair a test plants. The vectors show which frames a reader must reject, plaintext and encrypted; that the rejection reaches the caller as a miss, not as an error, is what a test asserts when it reads them through its SDK's cache rather than its frame parser. No frame records a name that differs from the reader's only in case, and no encrypted frame records an empty name. | partial (gap) | n/a | n/a | +| [KEY-6](../spec/cache-key-format.md#key-6) | *Serializer Codes*: Conversely, one identity **MUST** always produce one code. Derive it from the serializer configuration alone — the canonical name the wire format … | Binds cachekit-py only.
`cache-keys.json`: `serializer_default`, `serializer_auto`, `serializer_pythonic_alias`, `serializer_orjson`, `serializer_arrow`, `serializer_local`, `serializer_custom_identity`, `serializer_derived_spec_example`, `serializer_derived_leading_zero`, `serializer_custom_identity_orjson`, `serializer_custom_identity_non_ascii`, `object_arrow_serializer`, `object_class_named_auto`, `object_standard_serializer`, `object_orjson_serializer`, `object_auto_serializer` | covered | n/a | n/a | +| [KEY-7](../spec/cache-key-format.md#key-7) | *Serializer Codes*: Deriving the identity. An SDK that accepts alias spellings **MUST** resolve each accepted spelling to exactly one canonical name, and the code, the recorded name, and the serializer that … | Binds cachekit-py only.
`cache-keys.json`: `single_integer`, `serializer_pythonic_alias`
`python-frame.json`: `std_alias_write`, `pythonic_alias_write` | covered | n/a | n/a | +| [KEY-8](../spec/cache-key-format.md#key-8) | *Serializer Codes*: … one canonical name, and the code, the recorded name, and the serializer that writes the bytes **MUST** all be that canonical name's, or a key, its stored entry and its bytes can disagree about which serializer wrote it. … | Binds cachekit-py only.
`cache-keys.json`: `single_integer`, `serializer_pythonic_alias`
`python-frame.json`: `std_alias_write`, `pythonic_alias_write` | covered | n/a | n/a | +| [KEY-9](../spec/cache-key-format.md#key-9) | *Serializer Codes*: … its bytes can disagree about which serializer wrote it. An SDK that accepts a serializer object **MUST** reduce it to a string identity carrying a marker that no code-table name, alias spelling or accepted serializer name … | Binds cachekit-py only.
`cache-keys.json`: `serializer_custom_identity`, `object_arrow_serializer`, `object_class_named_auto`, `object_standard_serializer`, `object_orjson_serializer`, `object_auto_serializer` | covered | n/a | n/a | +| [KEY-10](../spec/cache-key-format.md#key-10) | *Serializer Codes*: … a table code (the Python note below gives `cachekit-py`'s marker). An empty or non-string identity **MUST** be rejected with an error, never mapped to a code: a fallback code is a shared bucket, and a key computed from it … | Binds cachekit-py only.
`cache-keys.json`: `serializer_type_empty`, `serializer_type_null`, `serializer_type_integer`, `serializer_type_truthy_integer`
**Gap:** The rows give the identity to key generation directly, so a cache that turns an empty or missing serializer into its default before key generation sees it passes them; whether a cache configured that way refuses it is SDK test code. | partial (gap) | n/a | n/a | | [KEY-11](../spec/cache-key-format.md#key-11) | *Serializer Codes*: … its identity does not, configurations that write different bytes and would otherwise share a key **MUST** be keyed under different `ns:` namespaces, no namespace counting as one. Sharing an identity, they share a code, so a … | Binds application code.
**Gap:** Which namespace an application keys each configuration under is its own choice, which no vector or SDK test can observe. | n/a | n/a | n/a | | [KEY-12](../spec/cache-key-format.md#key-12) | *Serializer Codes*: … tell them apart: one is served the other's bytes as a hit — wrong data, not an eviction. The `ns:` **MUST** also covers distinct identities that share one recorded name and write different bytes, because the 16-bit code is NOT … | Binds application code.
**Gap:** As KEY-11, for distinct identities that share one recorded name: the namespace is the application's choice, which no vector or SDK test can observe. | n/a | n/a | n/a | @@ -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) | 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-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. | partial (gap) | 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. | partial (gap) | 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. | partial (gap) | 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 (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-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 (gap) | 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 (gap) | 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. | partial (gap) | 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) | 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) | +| [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. | partial (gap) | 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 (gap) | partial (gap) | partial (gap) | ## spec/file-backend-format.md @@ -103,23 +103,23 @@ 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 (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-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 (gap) | 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 (gap) | 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 (gap) | 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. | partial (gap) | 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 (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-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 (gap) | 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. | partial (gap) | 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 (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 (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-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 (gap) | 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 (gap) | 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. | partial (gap) | 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 (gap) | 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. | partial (gap) | 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) | -| [PRE-32](../spec/intent-presets.md#pre-32) | *Encryption Activation*: … encryption option MUST cause every operation on the constructed client to encrypt — an SDK **MUST NOT** accept the option, report success, and leave any read or write path unencrypted (including a build where the … | `interop-mode.json`: `interop_encryption_roundtrip`, `issue_example_object`
`encryption.json`: `default_tenant_interop`
**Gap:** As PRE-31, for an option that is accepted and then ignored on some path, or a read path that also serves plaintext; a build without the encryption capability is reached only by a test run in that build. | partial (2/3) | partial (gap) | partial (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 (gap) | partial (gap) | partial (gap) | +| [PRE-32](../spec/intent-presets.md#pre-32) | *Encryption Activation*: … encryption option MUST cause every operation on the constructed client to encrypt — an SDK **MUST NOT** accept the option, report success, and leave any read or write path unencrypted (including a build where the … | `interop-mode.json`: `interop_encryption_roundtrip`, `issue_example_object`
`encryption.json`: `default_tenant_interop`
**Gap:** As PRE-31, for an option that is accepted and then ignored on some path, or a read path that also serves plaintext; a build without the encryption capability is reached only by a test run in that build. | partial (gap) | partial (gap) | partial (gap) | | [PRE-33](../spec/intent-presets.md#pre-33) | *Encryption Activation*: … (including a build where the encryption capability is compiled out); an unsupported combination **MUST** be rejected at construction, never silently ignored. | **Gap:** No vector holds an encryption option an SDK cannot honour, so nothing here catches an SDK that accepts one instead of rejecting it. | gap | gap | gap | | [PRE-34](../spec/intent-presets.md#pre-34) | *Encryption Activation*: The presence of `CACHEKIT_MASTER_KEY` **MUST NOT** change the encryption state of `minimal`, `production` or `io` — no constructor is exempt, including an explicit … | `interop-mode.json`: `issue_example_object`
**Gap:** The vector's bytes are a plaintext interop entry a test can plant and read through minimal, production or io built with CACHEKIT_MASTER_KEY set, which a client that decrypts its reads because the variable is present fails. That is the read path only: a client that encrypts its writes because of the variable, and reads by trying to decrypt and falling back to plaintext, passes. The vector also gives the bytes a non-encrypting write must store, so only a test that writes through the client with the variable set and inspects the stored bytes catches it; a test that drives the encoder alone does not, and setting the variable at all is SDK test code no vector can check. | partial (gap) | partial (gap) | partial (gap) | | [PRE-35](../spec/intent-presets.md#pre-35) | *Encryption Activation*: … encryption on its own — it can only supply a key that a subsequent explicit call consumes. It **MUST NOT** infer encryption state from the key's mere presence any more than any other constructor does. `CACHEKIT_MASTER_KEY`'s … | Binds cachekit-rs only.
`interop-mode.json`: `issue_example_object`
**Gap:** As PRE-34, for a configure-from-environment constructor with CACHEKIT_MASTER_KEY set. | n/a | n/a | 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 (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 (gap) | 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 1adb24a..8bf79d2 100644 --- a/conformance/sdks.json +++ b/conformance/sdks.json @@ -1,15 +1,16 @@ { "cachekit-py": { "repository": "https://github.com/cachekit-io/cachekit-py", - "commit": "ddecec3f5897d387f194441446d7b085d688c40e", + "commit": "d49b984ea9560ae936d8215a17ab537cc3994a82", "fixtures": { - "cache-keys.json": "e4c4174217757686069224a6f4553a1af7816191f689a865498f1cb4969856bb", + "cache-keys.json": "e75fb8f98d0e54fcb0d586cae72b8aa6129ed082f254539b450a47ba8e96167a", "decode-bounds.json": "c52c27f724fe138e63440dc0306936b48fe389e2bd823c058b86b30d854e2e2e", + "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 96d2b45..7b9b2b4 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; 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 | +| Test vectors in CI¹⁶ | ✅ interop/v1 (full set, incl. AAD + encryption through the real stack) — fixture 1.1.0 (`ns`/`nsapi` namespace reservation) since PyPI 0.20.0 ([cachekit-py#350](https://github.com/cachekit-io/cachekit-py/pull/350)); fixture 1.2.0 (no `..` in a segment) since 0.21.0 ([cachekit-py#391](https://github.com/cachekit-io/cachekit-py/pull/391)); fixture 1.3.0 vendored byte for byte (sha256-pinned) + CI-executed in [cachekit-py#537](https://github.com/cachekit-io/cachekit-py/pull/537), unreleased, its key, value and error vectors and both reader groups through the interop key derivation and value reader; `decode-bounds.json` vendored + CI-executed since [cachekit-py#276](https://github.com/cachekit-io/cachekit-py/pull/276) (LAB-2503); `1.1.0` since 0.20.0 ([cachekit-py#363](https://github.com/cachekit-io/cachekit-py/pull/363)); `1.2.0` (the `incomplete` reason) vendored byte for byte (sha256-pinned) + CI-executed in [cachekit-py#537](https://github.com/cachekit-io/cachekit-py/pull/537), unreleased, both `incomplete` vectors included; every version asserting the structural guard's own error for every reject vector at every payload read path (the [Decode bounds](spec/interop-mode.md#decode-bounds) MUST) — ⚠️ except the envelope entry point (`ByteStorage::retrieve`), which has no guard to assert until cachekit-py moves to a core release carrying [cachekit-core#80](https://github.com/cachekit-io/cachekit-core/pull/80) (see the Wire format row); `path-encoding.json` vendored + CI-executed since 0.20.0 ([cachekit-py#364](https://github.com/cachekit-io/cachekit-py/pull/364)); fixture 1.1.0 (empty-key row) since 0.21.0 ([cachekit-py#397](https://github.com/cachekit-io/cachekit-py/pull/397)); `path-encoding.json` 1.2.0 and `freshness-headers.json` 1.0.0 vendored byte for byte (sha256-pinned) + CI-executed in [cachekit-py#522](https://github.com/cachekit-io/cachekit-py/pull/522), unreleased; `cache-keys.json` 1.1.0 ([cachekit-py#460](https://github.com/cachekit-io/cachekit-py/pull/460)), `file-backend.json` 1.1.0 ([cachekit-py#431](https://github.com/cachekit-io/cachekit-py/pull/431)) and `wire-format.json` 1.3.0 ([cachekit-py#448](https://github.com/cachekit-io/cachekit-py/pull/448); its reject vectors: see the Wire format row) vendored byte for byte (sha256-pinned) + CI-executed, each since 0.21.0; `wire-format.json` 1.4.0 (every new accept, constructed, reject, payload-reject and temporal vector), `file-backend.json` 1.2.0 (every `miss_preserve` vector on all five read paths, the expired flagged entries included, and every `return_payload` vector) and `python-frame.json` (first vendor: every frame vector through `SerializationWrapper.unwrap`, every error vector at the check its `rejected_by` names, the CK frame through the interop reader, and every `encrypted_read_vectors` frame failing closed under both tamper policies with the tenant resolved by the reader) vendored byte for byte (sha256-pinned) + CI-executed in [cachekit-py#550](https://github.com/cachekit-io/cachekit-py/pull/550), unreleased; `encryption.json` `1.5.0` (first vendor: both `master_key_input` accept rows and every reject row, every `keyring.configuration` row, every `aad_reject_vectors` row failing authentication, and the `decrypted_container` rows for its checksummed Arrow and orjson readers returning no value), `cache-keys.json` `1.3.0` (every key, error and serializer-object vector) and the current `python-frame.json` revision, each vendored byte for byte (sha256-pinned) + CI-executed in [cachekit-py#561](https://github.com/cachekit-io/cachekit-py/pull/561), unreleased — ⚠️ except two `keyring.configuration` cases, `keyring_current_key_decrypt_only` and its uppercase twin, each with the current key given as `master_key=`, which run as strict expected failures naming [ENC-9](spec/encryption.md#enc-9): cachekit-py refuses the current key repeated in `CACHEKIT_PREVIOUS_MASTER_KEYS` only on the first call, not at load | ✅ interop/v1 (full set) since [#33](https://github.com/cachekit-io/cachekit-rs/pull/33) — fixture 1.1.0 since `cachekit-rs` 0.8.0 ([cachekit-rs#89](https://github.com/cachekit-io/cachekit-rs/pull/89); `.crate` inspected 2026-09-30); fixture 1.2.0 since 0.9.0 ([cachekit-rs#98](https://github.com/cachekit-io/cachekit-rs/pull/98); `.crate` inspected 2026-10-06); fixture 1.3.0 vendored byte for byte (sha256-pinned) + CI-executed in [cachekit-rs#152](https://github.com/cachekit-io/cachekit-rs/pull/152), unreleased, both reader groups through `interop::deserialize` and every value vector read back through it; `decode-bounds.json` vendored + CI-executed since [cachekit-rs#73](https://github.com/cachekit-io/cachekit-rs/pull/73) (LAB-2503; default CI green on `main`); `1.1.0` since 0.8.0 ([cachekit-rs#93](https://github.com/cachekit-io/cachekit-rs/pull/93)); `1.2.0` (the `incomplete` reason) in [cachekit-rs#152](https://github.com/cachekit-io/cachekit-rs/pull/152), unreleased, asserting the structural guard's own `decode bound:` error for every reject vector through both decoders and `get` / `interop_get` / `interop_get_swr` — the guard enforces depth itself since [cachekit-rs#93](https://github.com/cachekit-io/cachekit-rs/pull/93) (no envelope entry point¹⁵); `path-encoding.json` 1.1.0 vendored byte for byte (sha256-pinned) + CI-executed 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), 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)). +> ¹⁶ "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), and its current revision in [cachekit-py#561](https://github.com/cachekit-io/cachekit-py/pull/561) (both unreleased); cachekit-rs vendors it byte for byte in [cachekit-rs#158](https://github.com/cachekit-io/cachekit-rs/pull/158), and the current revision in [cachekit-rs#160](https://github.com/cachekit-io/cachekit-rs/pull/160) (both unreleased); cachekit-ts vendors it byte for byte for its container vectors in [cachekit-ts#229](https://github.com/cachekit-io/cachekit-ts/pull/229), and its current revision in [cachekit-ts#230](https://github.com/cachekit-io/cachekit-ts/pull/230) (both unreleased). This repo's `path-encoding.json` is `1.2.0` (31 rows, 6 reject); all three SDKs vendor `1.2.0` byte for byte, in [cachekit-py#522](https://github.com/cachekit-io/cachekit-py/pull/522), [cachekit-ts#217](https://github.com/cachekit-io/cachekit-ts/pull/217) and [cachekit-rs#146](https://github.com/cachekit-io/cachekit-rs/pull/146) (unreleased). This repo's `freshness-headers.json` is `1.0.0` (31 rows), and all three SDKs vendor it byte for byte in the same PRs (unreleased). This repo's `encryption.json` is `1.5.0` (`1.4.0` adds a second master key input accept row and two StandardSerializer AAD shapes; `1.5.0` adds the `aad_reject_vectors`, `decrypted_container` and `keyring.configuration` tables). `1.3.0` added master key input rows for [intent-presets.md → Master Key Input](spec/intent-presets.md#master-key-input): a sealed 32-byte key with a leading zero byte, bytes above 7f and every lowercase hex digit, eleven malformed or short hex keys, and five raw keys of the wrong length. cachekit-ts runs the `1.3.0` accept and hex reject rows since [cachekit-ts#222](https://github.com/cachekit-io/cachekit-ts/pull/222) and vendors `1.5.0` byte for byte in [cachekit-ts#230](https://github.com/cachekit-io/cachekit-ts/pull/230) (both unreleased). cachekit-rs vendors `1.5.0` byte for byte in [cachekit-rs#160](https://github.com/cachekit-io/cachekit-rs/pull/160) (unreleased). cachekit-py vendors `1.5.0` byte for byte in [cachekit-py#561](https://github.com/cachekit-io/cachekit-py/pull/561) (unreleased) and runs the rows the Test vectors in CI row lists, with two `keyring.configuration` cases as strict expected failures naming [ENC-9](spec/encryption.md#enc-9). This repo's `decode-bounds.json` is `1.2.0` (19 reject, 3 accept), which adds `incomplete_within_slot_budget` and its map twin, documents cut short inside the slot budget, under a new `incomplete` reject reason. All three SDKs vendor `1.2.0` byte for byte, in [cachekit-py#537](https://github.com/cachekit-io/cachekit-py/pull/537), [cachekit-rs#152](https://github.com/cachekit-io/cachekit-rs/pull/152) and [cachekit-ts#224](https://github.com/cachekit-io/cachekit-ts/pull/224) (unreleased). Their previous copies are `1.1.0` (17 reject, 3 accept): cachekit-py's released since 0.20.0 ([cachekit-py#363](https://github.com/cachekit-io/cachekit-py/pull/363)), cachekit-rs's released since 0.8.0 ([cachekit-rs#93](https://github.com/cachekit-io/cachekit-rs/pull/93)), and cachekit-ts's in [cachekit-ts#152](https://github.com/cachekit-io/cachekit-ts/pull/152) (unreleased); the SDKs' earlier copies were `1.0.0` revisions (13 reject / 2 accept in py and ts, 10 / 2 in rs). `cachekit-core` vendors `1.1.0` too and drives it through `ByteStorage::retrieve`, on core `main` ([cachekit-core#80](https://github.com/cachekit-io/cachekit-core/pull/80)), unreleased. `cache-keys.json` is vendored and CI-verified in cachekit-py since [cachekit-py#229](https://github.com/cachekit-io/cachekit-py/pull/229) (LAB-425). This repo's copy is `1.3.0` (21 key vectors, the first 10 byte-identical to the v0.5.0 originals): `1.2.0` adds four derived serializer codes, `1.3.0` 4 `error_vectors` and 5 `serializer_object_vectors`. cachekit-py vendors `1.1.0` byte for byte since 0.21.0 ([cachekit-py#460](https://github.com/cachekit-io/cachekit-py/pull/460)), and `1.3.0` byte for byte in [cachekit-py#561](https://github.com/cachekit-io/cachekit-py/pull/561) (unreleased), running every vector. > > ¹⁷ 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.