From df2da4bb4dabc89c40b2ab79c276bde4dcb8a199 Mon Sep 17 00:00:00 2001 From: velixio <270455167+velixio@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:26:42 +0530 Subject: [PATCH] fix: attest immutable runtime model versions --- backend/runtime_adapter/README.md | 7 ++++--- backend/runtime_adapter/inventory.py | 16 +++++++++++++++- .../tests/test_runtime_adapter_capabilities.py | 12 +++++++++++- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/backend/runtime_adapter/README.md b/backend/runtime_adapter/README.md index 39475496..6a0eaf5b 100644 --- a/backend/runtime_adapter/README.md +++ b/backend/runtime_adapter/README.md @@ -69,9 +69,10 @@ The Go preflight (`internal/gateway/preflight.go`) fails closed unless: - `catalog_model_id` — the VoiceStudio TTS engine id (`omnivoice`, `voxcpm2`, …) from `services.tts_backend`'s registry. -- `model_version` — the installed Hugging Face revision (40-char commit SHA) - recorded by `services.hf_revisions` (curated pin or the - `voicestudio-revision` marker). +- `model_version` — an immutable catalog version comprising the installed + Hugging Face revision (40-char commit SHA) and the first 16 hex characters + of the attested snapshot digest. This creates a new catalog identity when + snapshot bytes change; it never rewrites an identity retained by a Job. - `model_digest` — `sha256:` computed over the installed snapshot files (sorted relative path + per-file SHA-256), cached next to the repo cache keyed by (revision, file list, sizes, mtimes) so multi-GB weights are diff --git a/backend/runtime_adapter/inventory.py b/backend/runtime_adapter/inventory.py index 4eb8beab..93038ed1 100644 --- a/backend/runtime_adapter/inventory.py +++ b/backend/runtime_adapter/inventory.py @@ -63,6 +63,20 @@ ENGINE_MODEL_REPOS: dict[str, str] = { } +def catalog_model_version(revision: str, model_digest: str) -> str: + """Return the immutable catalog version for an attested model snapshot. + + A Hugging Face revision names source history, not necessarily the exact + snapshot bytes installed on a node. The catalog version therefore carries + a short, deterministic digest suffix. A changed snapshot becomes a new + catalog identity instead of mutating an identity retained by Jobs. + """ + digest = model_digest.removeprefix("sha256:") + if len(revision) != 40 or len(digest) != 64: + raise ValueError("model identity requires a SHA revision and SHA-256 digest") + return f"{revision}+sha256-{digest[:16]}" + + def slots_per_device(default: int = 1) -> int: raw = os.environ.get(SLOTS_ENV, "").strip() try: @@ -233,7 +247,7 @@ class ProductionInventory: return _replace_state(base, STATE_LOADING) return ModelInfo( catalog_model_id=base.catalog_model_id, - model_version=base.model_version, + model_version=catalog_model_version(base.model_version, model_digest), model_digest=model_digest, precisions=base.precisions, features=base.features, diff --git a/backend/tests/test_runtime_adapter_capabilities.py b/backend/tests/test_runtime_adapter_capabilities.py index 744dfd37..02a6e728 100644 --- a/backend/tests/test_runtime_adapter_capabilities.py +++ b/backend/tests/test_runtime_adapter_capabilities.py @@ -27,6 +27,7 @@ from runtime_adapter.inventory import ( STATE_INSTALLED, STATE_LOADING, ModelInfo, + catalog_model_version, ) from runtime_adapter.selfcheck import PreflightError, run_preflight from runtime_adapter.server import prepare_socket @@ -76,7 +77,7 @@ def test_capabilities_report_device_and_ready_model_evidence(tmp_path): by_id = {model.catalog_model_id: model for model in caps.models} ready = by_id[READY_MODEL.catalog_model_id] assert ready.state == pb2.RUNTIME_MODEL_STATE_READY - assert len(ready.model_version) == 40 + assert ready.model_version.startswith("d" * 40 + "+sha256-") assert ready.model_digest.startswith("sha256:") assert list(ready.precisions) # A loading/failed/installed model is reported truthfully, never READY. @@ -144,6 +145,15 @@ def test_snapshot_digest_is_stable_and_content_sensitive(tmp_path): snapshot_digest(tmp_path / "empty-none") +def test_catalog_model_version_changes_when_attested_snapshot_changes(): + revision = "d" * 40 + first = catalog_model_version(revision, "sha256:" + "a" * 64) + second = catalog_model_version(revision, "sha256:" + "b" * 64) + + assert first.startswith(revision + "+sha256-") + assert first != second + + def test_file_sha256_matches_hashlib(tmp_path): import hashlib