From ad7b379bc01e1c037ed198fadc4e972413418a86 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Tue, 21 Jul 2026 01:28:40 +1000 Subject: [PATCH] test(protocol): vendor + byte-verify auto-mode cache-key vectors (LAB-425) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit protocol's test-vectors/cache-keys.json (10 auto-mode key vectors, stamped cachekit-py v0.5.0) was verified by no CI anywhere — key stability has been unenforced for seven minor versions. A silently changed auto-mode key orphans every deployed cache entry: a 100% miss storm, billed as misses. Vendor the fixture (sha256-pinned, same pattern as the interop fixture) at tests/unit/protocol/fixtures/cache-keys.json — protocol @ f0672c1 — and assert every vector's FULL 7-segment key against CacheKeyGenerator in default CI, reproducing the vectors' __main__ module path by stubbing __module__/__qualname__ on a throwaway function. All 10 vectors pass byte-exact on v0.12.0 (verified before vendoring): no compat break existed, so the protocol-side change is a metadata re-stamp only. Co-authored-by: multica-agent --- tests/unit/protocol/fixtures/cache-keys.json | 153 ++++++++++++++++++ tests/unit/protocol/test_cache_key_vectors.py | 77 +++++++++ 2 files changed, 230 insertions(+) create mode 100644 tests/unit/protocol/fixtures/cache-keys.json create mode 100644 tests/unit/protocol/test_cache_key_vectors.py diff --git a/tests/unit/protocol/fixtures/cache-keys.json b/tests/unit/protocol/fixtures/cache-keys.json new file mode 100644 index 0000000..304fae8 --- /dev/null +++ b/tests/unit/protocol/fixtures/cache-keys.json @@ -0,0 +1,153 @@ +{ + "version": "1.0.0", + "generator": "cachekit-py v0.12.0", + "ci_verification": "Vendored (sha256-pinned) and byte-verified against CacheKeyGenerator in cachekit-py default CI: tests/unit/protocol/test_cache_key_vectors.py", + "note": "Keys include __main__ module because vectors were generated at top level. Cross-SDK implementations should substitute their own module path \u2014 only the args hash portion must match.", + "key_format": "ns:{namespace}:func:{module}.{qualname}:args:{blake2b_256_hex}:{ic_flag}{serializer_code}", + "hash_algorithm": "blake2b-256 of msgpack([normalized_args, sorted_kwargs])", + "vectors": [ + { + "name": "single_integer", + "namespace": "test", + "function_module": "__main__", + "function_qualname": "get_user", + "args": [ + 42 + ], + "kwargs": {}, + "integrity_checking": true, + "serializer_type": "std", + "expected_key": "ns:test:func:__main__.get_user:args:3870b2ea5735ae639ded9450ef117768db676f037bec636503796c5b81095153:1s" + }, + { + "name": "single_string", + "namespace": "test", + "function_module": "__main__", + "function_qualname": "get_user", + "args": [ + "hello" + ], + "kwargs": {}, + "integrity_checking": true, + "serializer_type": "std", + "expected_key": "ns:test:func:__main__.get_user:args:07ed6e7b87ff98f70efe4f4a8f082fa79415b9f670be68f3ada1f42244b5b6de:1s" + }, + { + "name": "multiple_args", + "namespace": "test", + "function_module": "__main__", + "function_qualname": "process", + "args": [ + 1, + "two", + 3.0 + ], + "kwargs": {}, + "integrity_checking": true, + "serializer_type": "std", + "expected_key": "ns:test:func:__main__.process:args:465e6bc8edd493c64748a0c405106db14d2bb481989bf1849481f1343982e179:1s" + }, + { + "name": "kwargs_only", + "namespace": "test", + "function_module": "__main__", + "function_qualname": "get_user", + "args": [], + "kwargs": { + "user_id": 42, + "include_profile": true + }, + "integrity_checking": true, + "serializer_type": "std", + "expected_key": "ns:test:func:__main__.get_user:args:14570849f0d99524f4149319d37fa0c298278b8f1724c12d6cb7631efaf86f0e:1s" + }, + { + "name": "mixed_args_kwargs", + "namespace": "test", + "function_module": "__main__", + "function_qualname": "get_user", + "args": [ + "alice" + ], + "kwargs": { + "age": 30 + }, + "integrity_checking": true, + "serializer_type": "std", + "expected_key": "ns:test:func:__main__.get_user:args:573b0961d0bf4e7207c6628a3f9e42c97a0cd01e276d8904ec5af6c877cd599e:1s" + }, + { + "name": "no_namespace", + "namespace": null, + "function_module": "__main__", + "function_qualname": "get_user", + "args": [ + 1 + ], + "kwargs": {}, + "integrity_checking": true, + "serializer_type": "std", + "expected_key": "func:__main__.get_user:args:386979f533ce537f0c42d385c8174948ebd58566ad81b32bebb830a187cb4387:1s" + }, + { + "name": "empty_args", + "namespace": "test", + "function_module": "__main__", + "function_qualname": "get_user", + "args": [], + "kwargs": {}, + "integrity_checking": true, + "serializer_type": "std", + "expected_key": "ns:test:func:__main__.get_user:args:f9cf3864b6e929eb73f84cf6d69409e0bd7575f8cf6feafe3a543b0f7267b2b2:1s" + }, + { + "name": "none_arg", + "namespace": "test", + "function_module": "__main__", + "function_qualname": "get_user", + "args": [ + null + ], + "kwargs": {}, + "integrity_checking": true, + "serializer_type": "std", + "expected_key": "ns:test:func:__main__.get_user:args:073b96f1817ee2b2a26b6cee56401999e4dc2d8836d596927d0aad3e338c2f0b:1s" + }, + { + "name": "boolean_args", + "namespace": "test", + "function_module": "__main__", + "function_qualname": "process", + "args": [ + true, + false + ], + "kwargs": {}, + "integrity_checking": true, + "serializer_type": "std", + "expected_key": "ns:test:func:__main__.process:args:982385ac5333a35e2d5d68638aafe47ba31d36c1f3cd60d0ed57396e3c638334:1s" + }, + { + "name": "nested_dict", + "namespace": "test", + "function_module": "__main__", + "function_qualname": "get_user", + "args": [ + { + "user": { + "name": "alice", + "ids": [ + 1, + 2, + 3 + ] + } + } + ], + "kwargs": {}, + "integrity_checking": true, + "serializer_type": "std", + "expected_key": "ns:test:func:__main__.get_user:args:14a3433f54d70b4cc452c3a140f8c3a0979acb3dc2baf7238dc4e6675265f3a8:1s" + } + ] +} diff --git a/tests/unit/protocol/test_cache_key_vectors.py b/tests/unit/protocol/test_cache_key_vectors.py new file mode 100644 index 0000000..c78303c --- /dev/null +++ b/tests/unit/protocol/test_cache_key_vectors.py @@ -0,0 +1,77 @@ +"""Byte-verification of auto-mode cache keys against the protocol test vectors. + +Fixture: tests/unit/protocol/fixtures/cache-keys.json, vendored from +cachekit-io/protocol @ f0672c1cf2a3bbd2ba3f4760b6d1406a4357aab9 +(sha256 4a0ae13dfa745a1a2505f2a10148db7d55f23f9b01636000cf477e805b86450e). +Regenerate ONLY by re-copying from the protocol repo — never by hand. + +Each vector pins the full 7-segment auto-mode key +(``ns:{ns}:func:{module}.{qualname}:args:{blake2b_256_hex}:{ic_flag}{code}``) +for a given (args, kwargs, namespace, integrity_checking, serializer_type) +tuple. The vectors were generated at top level, so the module path is +``__main__`` — reproduced here by stubbing ``__module__``/``__qualname__`` +on a throwaway function, which lets the test assert the FULL key, not just +the args-hash segment. + +A failure here is a key-stability break to triage, never a fixture to +silently regenerate: a changed auto-mode key orphans every existing cache +entry (silent 100% miss storm, billed as misses under metered pricing). +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import Any + +import pytest + +from cachekit.key_generator import CacheKeyGenerator + +FIXTURE_PATH = Path(__file__).parent / "fixtures" / "cache-keys.json" +FIXTURE_SHA256 = "4a0ae13dfa745a1a2505f2a10148db7d55f23f9b01636000cf477e805b86450e" + +VECTORS = json.loads(FIXTURE_PATH.read_text(encoding="utf-8")) + +# Part of the conformance claim: a fixture update that adds or removes +# vectors must be a conscious change, not a silent drift. +EXPECTED_VECTOR_COUNT = 10 + + +def test_fixture_integrity(): + """The vendored fixture is byte-identical to the pinned protocol revision.""" + digest = hashlib.sha256(FIXTURE_PATH.read_bytes()).hexdigest() + assert digest == FIXTURE_SHA256, ( + f"fixtures/cache-keys.json sha256 {digest} != pinned {FIXTURE_SHA256}. " + "If the protocol vectors were intentionally updated, refresh the pin AND the count." + ) + + +def test_vector_count(): + assert len(VECTORS["vectors"]) == EXPECTED_VECTOR_COUNT + + +@pytest.mark.parametrize("vector", VECTORS["vectors"], ids=lambda v: v["name"]) +def test_cache_key_vectors(vector: dict[str, Any]): + """CacheKeyGenerator reproduces every pinned auto-mode key byte-for-byte.""" + + def stub() -> None: # pragma: no cover - identity carrier, never called + pass + + stub.__module__ = vector["function_module"] + stub.__qualname__ = vector["function_qualname"] + + key = CacheKeyGenerator().generate_key( + stub, + tuple(vector["args"]), + vector["kwargs"], + namespace=vector["namespace"], + integrity_checking=vector["integrity_checking"], + serializer_type=vector["serializer_type"], + ) + assert key == vector["expected_key"], ( + f"auto-mode key drift for vector {vector['name']!r} — this breaks key " + "stability for every deployed cache entry; triage the generator change, " + "do NOT regenerate the vectors." + )