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:
parent
3911db66a3
commit
0b19e08306
4 changed files with 69 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue