feat(engine-state): schema-version migration discipline (L10 step-2)

Versioned additive-optional migration (L10 scoping step-2 ruling): a checkpoint
schema bump is a recorded lineage transition, not death-and-rebirth.

- engine_state.load_manifest() now REFUSES (IncompatibleEngineStateError) a
  checkpoint whose schema_version > this build's _SCHEMA_VERSION, and tolerates
  <= current (older/equal read any missing newer fields via additive-optional
  defaults). Never silently mis-loads newer state.
- chat.runtime._load_engine_state() loads the manifest FIRST so the version
  refusal gates before any recognizers/candidates are read.
- DerivedRecognizer.from_json documents the additive-optional convention
  (new fields .get-defaulted + omitted-when-default), mirroring DiscoveryCandidate.

Tests (TDD): refuses newer schema_version; tolerates older. Prerequisite for the
L10 continuity spike's P2 byte-identity gate (it may now assume a fixed schema
within a run, with version bumps handled explicitly).
This commit is contained in:
Shay 2026-06-05 07:37:58 -07:00
parent 3911db66a3
commit 0b19e08306
4 changed files with 69 additions and 2 deletions

View file

@ -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

View file

@ -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"]

View file

@ -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"]),

View file

@ -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