diff --git a/chat/runtime.py b/chat/runtime.py index 4071bcb6..46abec71 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -681,10 +681,13 @@ class ChatRuntime: store = self._engine_state_store if store is None: return + # Schema-version compatibility gates the whole load: load_manifest() + # refuses (raises) a checkpoint written by newer code BEFORE we read any + # recognizers/candidates (L10 step-2 migration discipline). + manifest = store.load_manifest() or {} recognizers = store.load_recognizers() self._recognizer_registry = RecognizerRegistry.from_recognizers(recognizers) self._pending_candidates = store.load_discovery_candidates() - manifest = store.load_manifest() or {} self._turn_count = int(manifest.get("turn_count", 0)) # W-024 / ADR-0158 — buffer reboot event for emission when sink attaches. from engine_state import _git_revision diff --git a/engine_state/__init__.py b/engine_state/__init__.py index 1d15b1c6..66fcbb3a 100644 --- a/engine_state/__init__.py +++ b/engine_state/__init__.py @@ -106,6 +106,14 @@ def _git_revision() -> str: return get_git_revision() +class IncompatibleEngineStateError(RuntimeError): + """Raised when an engine-state checkpoint was written by a NEWER schema than + this build supports (L10 step-2 migration discipline). Older/equal versions + are tolerated via additive-optional defaults; a newer checkpoint is refused + rather than silently mis-loaded. + """ + + class EngineStateStore: def __init__(self, path: Path | None = None) -> None: self.path = path or _DEFAULT_DIR @@ -169,6 +177,17 @@ class EngineStateStore: if not content: return None manifest = json.loads(content) + # L10 step-2 migration discipline: tolerate schema_version <= current + # (additive-optional fields read via defaults); REFUSE a newer checkpoint + # rather than silently mis-load state written by code we don't understand. + stored_version = manifest.get("schema_version", 0) + if not isinstance(stored_version, int) or stored_version > _SCHEMA_VERSION: + raise IncompatibleEngineStateError( + f"engine_state manifest schema_version {stored_version!r} is newer " + f"than this build supports ({_SCHEMA_VERSION}). Refusing to load: a " + "newer checkpoint cannot be safely read by older code. Run the " + "matching build, or clear engine_state/ explicitly." + ) # W-023 / ADR-0157 — revision-mismatch warning per ADR-0146 §Risks line 127. # Never refuse to load; reboot is recovery, not control flow. stored_rev = manifest.get("written_at_revision", "unknown") @@ -188,4 +207,4 @@ class EngineStateStore: return (self.path / "manifest.json").exists() -__all__ = ["EngineStateStore", "get_git_revision"] +__all__ = ["EngineStateStore", "IncompatibleEngineStateError", "get_git_revision"] diff --git a/recognition/anti_unifier.py b/recognition/anti_unifier.py index b27c32b2..6170f53a 100644 --- a/recognition/anti_unifier.py +++ b/recognition/anti_unifier.py @@ -78,6 +78,11 @@ class DerivedRecognizer: @classmethod def from_json(cls, payload: str) -> "DerivedRecognizer": raw = json.loads(payload) + # L10 engine_state migration discipline (step-2): the v1 keys below are + # required. Any field ADDED in a later schema_version must be read via + # raw.get(name, default) and omitted-when-default in as_dict(), so old + # checkpoints load without migration and un-evolved records stay + # byte-identical (cf. DiscoveryCandidate's C1 fields for the pattern). return cls( pattern=tuple(_pattern_element_from_dict(element) for element in raw["pattern"]), teaching_set_id=str(raw["teaching_set_id"]), diff --git a/tests/test_adr_0146_engine_state.py b/tests/test_adr_0146_engine_state.py index 7aaa37a2..65bb6c28 100644 --- a/tests/test_adr_0146_engine_state.py +++ b/tests/test_adr_0146_engine_state.py @@ -2,6 +2,8 @@ from __future__ import annotations import json +import pytest + from chat.runtime import ChatRuntime from engine_state import EngineStateStore from recognition.anti_unifier import Constant, DerivedRecognizer, TypedSlot @@ -194,3 +196,41 @@ def test_discovery_store_save_load_save_is_idempotent(tmp_path) -> None: store.save_discovery_candidates(store.load_discovery_candidates()) assert (tmp_path / "discovery_candidates.jsonl").read_bytes() == first + + +# --- L10 step-2: schema-version migration discipline (added 2026-06-05) -------- +# Versioned additive-optional migration: tolerate schema_version <= current +# (older/equal checkpoints read any missing newer fields via defaults), REFUSE +# schema_version > current (never silently mis-load a checkpoint written by code +# we don't understand). A version bump is a recorded lineage transition, not a +# death-and-rebirth. + + +def test_load_manifest_refuses_newer_schema_version(tmp_path) -> None: + from engine_state import IncompatibleEngineStateError + + store = EngineStateStore(tmp_path) + (tmp_path / "manifest.json").write_text( + json.dumps( + {"schema_version": 999, "turn_count": 3, "written_at_revision": "deadbeef"} + ), + encoding="utf-8", + ) + + with pytest.raises(IncompatibleEngineStateError): + store.load_manifest() + + +def test_load_manifest_tolerates_older_schema_version(tmp_path) -> None: + store = EngineStateStore(tmp_path) + (tmp_path / "manifest.json").write_text( + json.dumps( + {"schema_version": 0, "turn_count": 5, "written_at_revision": "unknown"} + ), + encoding="utf-8", + ) + + manifest = store.load_manifest() + + assert manifest is not None + assert manifest["turn_count"] == 5