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
|
store = self._engine_state_store
|
||||||
if store is None:
|
if store is None:
|
||||||
return
|
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()
|
recognizers = store.load_recognizers()
|
||||||
self._recognizer_registry = RecognizerRegistry.from_recognizers(recognizers)
|
self._recognizer_registry = RecognizerRegistry.from_recognizers(recognizers)
|
||||||
self._pending_candidates = store.load_discovery_candidates()
|
self._pending_candidates = store.load_discovery_candidates()
|
||||||
manifest = store.load_manifest() or {}
|
|
||||||
self._turn_count = int(manifest.get("turn_count", 0))
|
self._turn_count = int(manifest.get("turn_count", 0))
|
||||||
# W-024 / ADR-0158 — buffer reboot event for emission when sink attaches.
|
# W-024 / ADR-0158 — buffer reboot event for emission when sink attaches.
|
||||||
from engine_state import _git_revision
|
from engine_state import _git_revision
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,14 @@ def _git_revision() -> str:
|
||||||
return get_git_revision()
|
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:
|
class EngineStateStore:
|
||||||
def __init__(self, path: Path | None = None) -> None:
|
def __init__(self, path: Path | None = None) -> None:
|
||||||
self.path = path or _DEFAULT_DIR
|
self.path = path or _DEFAULT_DIR
|
||||||
|
|
@ -169,6 +177,17 @@ class EngineStateStore:
|
||||||
if not content:
|
if not content:
|
||||||
return None
|
return None
|
||||||
manifest = json.loads(content)
|
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.
|
# W-023 / ADR-0157 — revision-mismatch warning per ADR-0146 §Risks line 127.
|
||||||
# Never refuse to load; reboot is recovery, not control flow.
|
# Never refuse to load; reboot is recovery, not control flow.
|
||||||
stored_rev = manifest.get("written_at_revision", "unknown")
|
stored_rev = manifest.get("written_at_revision", "unknown")
|
||||||
|
|
@ -188,4 +207,4 @@ class EngineStateStore:
|
||||||
return (self.path / "manifest.json").exists()
|
return (self.path / "manifest.json").exists()
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["EngineStateStore", "get_git_revision"]
|
__all__ = ["EngineStateStore", "IncompatibleEngineStateError", "get_git_revision"]
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,11 @@ class DerivedRecognizer:
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_json(cls, payload: str) -> "DerivedRecognizer":
|
def from_json(cls, payload: str) -> "DerivedRecognizer":
|
||||||
raw = json.loads(payload)
|
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(
|
return cls(
|
||||||
pattern=tuple(_pattern_element_from_dict(element) for element in raw["pattern"]),
|
pattern=tuple(_pattern_element_from_dict(element) for element in raw["pattern"]),
|
||||||
teaching_set_id=str(raw["teaching_set_id"]),
|
teaching_set_id=str(raw["teaching_set_id"]),
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from chat.runtime import ChatRuntime
|
from chat.runtime import ChatRuntime
|
||||||
from engine_state import EngineStateStore
|
from engine_state import EngineStateStore
|
||||||
from recognition.anti_unifier import Constant, DerivedRecognizer, TypedSlot
|
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())
|
store.save_discovery_candidates(store.load_discovery_candidates())
|
||||||
|
|
||||||
assert (tmp_path / "discovery_candidates.jsonl").read_bytes() == first
|
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