The two Gemini robustness nits raced the #774 merge: the patch landed on the branch after GitHub had merged the pre-patch head, so main shipped the architecture without the migration-input hardening. This re-lands ONLY the robustness fixes — no identity-semantics change. - chat/runtime.py: parse identity_scheme with try/except (TypeError, ValueError) -> fallback to legacy scheme 1; revision str(... or '') so a null becomes '' (unverifiable -> conservative DIVERGED), not the literal 'None'. - workbench/readers.py: stored_revision=written_at_revision or '' so a None revision is handled identically by the shared reconcile helper. - tests: malformed identity_scheme does not crash the load guard (migrates); reader falls back to legacy on malformed scheme; reader missing revision is a conservative break. Verified: 50 identity/migration/reader tests pass. No lane/serving path touched.
250 lines
9.1 KiB
Python
250 lines
9.1 KiB
Python
"""ADR-0220 — identity/provenance split: the new invariants and the migration.
|
|
|
|
Proves the four behavioural claims the split introduces (the in-process reboot
|
|
tests in test_identity_continuity_verification.py CANNOT, because they reboot at a
|
|
constant git revision):
|
|
|
|
1. A behaviour-neutral rebuild (same ratified packs, different build revision) is
|
|
the SAME identity — ``code_revision`` is no longer hashed.
|
|
2. A pre-split (legacy, code_revision-folded) checkpoint whose packs verify
|
|
identical MIGRATES (warn + re-stamp), it does NOT flag-day strict-break.
|
|
3. A genuine ratified-pack divergence STILL refuses under strict — the migration
|
|
tolerance is *verifying*, not blind (the wrong=identity proof obligation:
|
|
this test fails loudly if the migration ever swallowed a real pack swap).
|
|
4. The reconcile decision is the single source of truth (unit-tested directly).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from chat.runtime import ChatRuntime
|
|
from core.cognition.pipeline import CognitiveTurnPipeline
|
|
from core.config import RuntimeConfig
|
|
from core.engine_identity import (
|
|
ENGINE_IDENTITY_SCHEME,
|
|
IdentityContinuityError,
|
|
IdentityReconciliation,
|
|
_resolve_pack_ids,
|
|
compute_engine_identity,
|
|
compute_legacy_engine_identity,
|
|
engine_identity_for_config,
|
|
reconcile_loaded_identity,
|
|
)
|
|
from engine_state import EngineStateStore
|
|
|
|
_DEFAULT_CFG = RuntimeConfig()
|
|
_DEFAULT_PACKS = _resolve_pack_ids(_DEFAULT_CFG)
|
|
_CURRENT_ID = compute_engine_identity(_DEFAULT_PACKS) # packs-only default identity
|
|
|
|
|
|
# --- reconcile_loaded_identity: the single source of truth (fast unit proofs) ---
|
|
|
|
|
|
def test_reconcile_match_on_current_scheme() -> None:
|
|
r = reconcile_loaded_identity(
|
|
_DEFAULT_CFG,
|
|
_CURRENT_ID,
|
|
stored_identity=_CURRENT_ID,
|
|
stored_scheme=ENGINE_IDENTITY_SCHEME,
|
|
stored_revision="any-rev",
|
|
)
|
|
assert r is IdentityReconciliation.MATCH
|
|
|
|
|
|
def test_reconcile_diverged_on_current_scheme_mismatch() -> None:
|
|
r = reconcile_loaded_identity(
|
|
_DEFAULT_CFG,
|
|
_CURRENT_ID,
|
|
stored_identity="deadbeef" * 8,
|
|
stored_scheme=ENGINE_IDENTITY_SCHEME,
|
|
stored_revision="any-rev",
|
|
)
|
|
assert r is IdentityReconciliation.DIVERGED
|
|
|
|
|
|
def test_reconcile_migrates_legacy_stamp_with_same_packs() -> None:
|
|
# A legacy (scheme 1 / absent) stamp of the CURRENT packs at some old rev.
|
|
legacy = compute_legacy_engine_identity(_DEFAULT_PACKS, "old-rev")
|
|
r = reconcile_loaded_identity(
|
|
_DEFAULT_CFG,
|
|
_CURRENT_ID,
|
|
stored_identity=legacy,
|
|
stored_scheme=1,
|
|
stored_revision="old-rev",
|
|
)
|
|
assert r is IdentityReconciliation.MIGRATED
|
|
|
|
|
|
def test_reconcile_refuses_legacy_stamp_with_different_packs() -> None:
|
|
# THE wrong=identity defense: a legacy stamp of a DIFFERENT identity pack must
|
|
# NOT be silently migrated — the packs genuinely diverged.
|
|
other_packs = _resolve_pack_ids(RuntimeConfig(identity_pack="precision_first_v1"))
|
|
legacy_other = compute_legacy_engine_identity(other_packs, "old-rev")
|
|
r = reconcile_loaded_identity(
|
|
_DEFAULT_CFG,
|
|
_CURRENT_ID,
|
|
stored_identity=legacy_other,
|
|
stored_scheme=1,
|
|
stored_revision="old-rev",
|
|
)
|
|
assert r is IdentityReconciliation.DIVERGED
|
|
|
|
|
|
def test_reconcile_legacy_without_revision_is_conservative() -> None:
|
|
# Without the persisted revision the legacy hash can't be reconstructed, so we
|
|
# cannot prove the packs are unchanged -> refuse rather than blindly migrate.
|
|
legacy = compute_legacy_engine_identity(_DEFAULT_PACKS, "old-rev")
|
|
r = reconcile_loaded_identity(
|
|
_DEFAULT_CFG,
|
|
_CURRENT_ID,
|
|
stored_identity=legacy,
|
|
stored_scheme=1,
|
|
stored_revision="",
|
|
)
|
|
assert r is IdentityReconciliation.DIVERGED
|
|
|
|
|
|
# --- runtime integration: migration of a real pre-split checkpoint ---
|
|
|
|
|
|
def _write_legacy_manifest(
|
|
state_dir: Path, *, pack_config: RuntimeConfig, revision: str = "old-rev-001"
|
|
) -> None:
|
|
"""Write a flat (pre-ADR-0219) manifest stamped under the LEGACY scheme — i.e.
|
|
``engine_identity`` folded ``code_revision`` and there is no ``identity_scheme``
|
|
marker, exactly as a pre-split build would have left it."""
|
|
state_dir.mkdir(parents=True, exist_ok=True)
|
|
legacy_id = compute_legacy_engine_identity(_resolve_pack_ids(pack_config), revision)
|
|
(state_dir / "manifest.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"schema_version": 2,
|
|
"turn_count": 4,
|
|
"written_at_revision": revision,
|
|
"engine_identity": legacy_id,
|
|
},
|
|
sort_keys=True,
|
|
indent=2,
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def test_legacy_checkpoint_migrates_under_strict_then_restamps(tmp_path: Path) -> None:
|
|
state_dir = tmp_path / "es"
|
|
_write_legacy_manifest(state_dir, pack_config=RuntimeConfig()) # same (default) packs
|
|
|
|
# Strict mode must NOT flag-day-break a same-packs legacy checkpoint — it
|
|
# migrates with a distinct (non-"break") warning and resumes.
|
|
with pytest.warns(RuntimeWarning, match="migrating engine-identity stamp scheme"):
|
|
runtime = ChatRuntime(
|
|
config=RuntimeConfig(strict_identity_continuity=True),
|
|
engine_state_path=state_dir,
|
|
)
|
|
assert runtime.identity_continuity_break is False
|
|
|
|
# The next checkpoint re-stamps under the packs-only scheme.
|
|
pipe = CognitiveTurnPipeline(runtime=runtime)
|
|
pipe.run("What causes light?")
|
|
manifest = EngineStateStore(state_dir).load_manifest() or {}
|
|
assert manifest["identity_scheme"] == ENGINE_IDENTITY_SCHEME
|
|
assert manifest["engine_identity"] == engine_identity_for_config(RuntimeConfig())
|
|
|
|
|
|
def test_legacy_checkpoint_with_different_packs_refuses_under_strict(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
state_dir = tmp_path / "es"
|
|
# Legacy stamp of a DIFFERENT identity pack — a genuine substrate divergence.
|
|
_write_legacy_manifest(
|
|
state_dir, pack_config=RuntimeConfig(identity_pack="precision_first_v1")
|
|
)
|
|
with pytest.raises(IdentityContinuityError):
|
|
ChatRuntime(
|
|
config=RuntimeConfig(strict_identity_continuity=True),
|
|
engine_state_path=state_dir,
|
|
)
|
|
|
|
|
|
def test_legacy_checkpoint_with_different_packs_breaks_without_strict(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
state_dir = tmp_path / "es"
|
|
_write_legacy_manifest(
|
|
state_dir, pack_config=RuntimeConfig(identity_pack="precision_first_v1")
|
|
)
|
|
with pytest.warns(RuntimeWarning, match="identity continuity break"):
|
|
runtime = ChatRuntime(config=RuntimeConfig(), engine_state_path=state_dir)
|
|
assert runtime.identity_continuity_break is True
|
|
|
|
|
|
# --- malformed / missing manifest fields must not crash the guard or reader ---
|
|
|
|
|
|
def test_malformed_identity_scheme_does_not_crash_the_load_guard(tmp_path: Path) -> None:
|
|
# A non-int identity_scheme must fall back to the legacy scheme (verifying
|
|
# migration), not raise on int() while loading the checkpoint.
|
|
state_dir = tmp_path / "es"
|
|
state_dir.mkdir(parents=True)
|
|
legacy_id = compute_legacy_engine_identity(_DEFAULT_PACKS, "old-rev")
|
|
(state_dir / "manifest.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"schema_version": 2,
|
|
"turn_count": 4,
|
|
"written_at_revision": "old-rev",
|
|
"engine_identity": legacy_id,
|
|
"identity_scheme": "not-an-int", # malformed
|
|
},
|
|
sort_keys=True,
|
|
indent=2,
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
with pytest.warns(RuntimeWarning, match="migrating engine-identity stamp scheme"):
|
|
runtime = ChatRuntime(
|
|
config=RuntimeConfig(strict_identity_continuity=True),
|
|
engine_state_path=state_dir,
|
|
)
|
|
assert runtime.identity_continuity_break is False
|
|
|
|
|
|
def test_reader_malformed_identity_scheme_falls_back_to_legacy() -> None:
|
|
from workbench.readers import _identity_continuity_from_manifest
|
|
|
|
# A legacy stamp of the CURRENT default packs, but identity_scheme is garbage:
|
|
# the reader must fall back to legacy and verify-migrate -> "verified", not crash.
|
|
legacy = compute_legacy_engine_identity(_DEFAULT_PACKS, "r1")
|
|
result = _identity_continuity_from_manifest(
|
|
{
|
|
"schema_version": 2,
|
|
"turn_count": 1,
|
|
"written_at_revision": "r1",
|
|
"engine_identity": legacy,
|
|
"identity_scheme": "not-an-int",
|
|
}
|
|
)
|
|
assert result is not None
|
|
assert result.status == "verified"
|
|
|
|
|
|
def test_reader_missing_revision_is_a_conservative_break() -> None:
|
|
from workbench.readers import _identity_continuity_from_manifest
|
|
|
|
# A legacy stamp with NO written_at_revision can't be verified -> DIVERGED ->
|
|
# "break" (never blindly "verified"); and it must not crash on a None revision.
|
|
legacy = compute_legacy_engine_identity(_DEFAULT_PACKS, "r1")
|
|
result = _identity_continuity_from_manifest(
|
|
{
|
|
"schema_version": 2,
|
|
"turn_count": 1,
|
|
"engine_identity": legacy,
|
|
# no written_at_revision, no identity_scheme
|
|
}
|
|
)
|
|
assert result is not None
|
|
assert result.status == "break"
|