core/core/engine_identity.py
Shay 512453b6fc
feat(identity): split engine identity from build provenance (ADR-0220 PR C) (#774)
Removes code_revision from the engine-identity hash: engine_identity is now the
sha256 of the 5 ratified packs ONLY. The build revision is provenance (the
manifest's written_at_revision), not identity — so a behavior-neutral rebuild is
the SAME identity and the always-on daemon no longer flag-day strict-breaks on
every commit (the ADR-0220 defect).

Core:
- core/engine_identity.py: ratified_substrate/compute_engine_identity drop the
  git_revision arg (packs-only); add compute_legacy_engine_identity (reproduces
  the pre-split packs+rev hash, for migration verification), ENGINE_IDENTITY_SCHEME=2,
  IdentityReconciliation enum, and reconcile_loaded_identity — the single source of
  truth for the runtime guard AND the workbench reader.
- chat/runtime.py: the load guard reconciles via scheme. Current scheme -> direct
  packs-only compare. Legacy (code_revision-folded) stamp -> a VERIFYING migration:
  reconstruct the legacy hash from the persisted written_at_revision; a match proves
  packs unchanged (warn + re-stamp, resume, no break) — a mismatch means the packs
  genuinely changed (DIVERGED -> strict-refuse). Preserves 'distinct packs => refuse'.
- engine_state/save_manifest: stamp identity_scheme alongside engine_identity
  (additive-optional; no schema_version bump).
- workbench/readers.py: continuity reader uses the same reconcile (no phantom break
  on legacy checkpoints). cli.py break message reworded (packs, not 'build revision').

Callsite cleanup: drop the now-unused git_revision arg + imports across
always_on.py, evals/l10_always_on/runner.py, workbench/readers.py.

Tests:
- test_identity_provenance_split.py (new): 5 reconcile unit proofs + 3 runtime
  integration proofs incl. the wrong=identity defense (legacy stamp of DIFFERENT
  packs still strict-refuses) and the re-stamp migration.
- test_engine_identity.py: invert the code-revision test (rev no longer changes
  identity); assert the substrate is packs-only.
- Restore 3 lineage tests silently red since ADR-0219 (flat-path _manifest helper
  now resolves the gen-dir).
- Update remaining callsites/monkeypatches for the new signature.

Hygiene: .gitignore now covers the ADR-0219 gen-dir runtime files
(current, gen-*/, session_state.json, proposals.jsonl).

Verified: 59 identity/migration/lineage/workbench + 32 L10 + 76 invariants/cli +
34 smoke pass; serving lane SHAs unchanged (no derivation/reliability_gate touch).
2026-06-15 11:38:04 -07:00

226 lines
9.2 KiB
Python

"""EngineIdentity — content-derived identity of the ratified substrate (L11).
``EngineIdentity`` is the sha256 of the canonical serialization of the engine's
ratified PERSONALITY substrate — the active identity / safety / ethics / register
/ anchor-lens pack files. It is **content-derived, NOT entropy-based**: two
engines with the same ratified packs compute the SAME identity, because they ARE
the same engine functionally (substrate is shareable).
This is the "who am I" hash. It is bumped only by a ratified change to the
identity substrate (a new identity pack, a safety-axis change), NOT by lived
learning (recall, teaching) and **NOT by the build revision** — that is the
engine's *experience* / *provenance*, carried separately (the manifest's
``written_at_revision``), not its identity. (ADR-0220: ``code_revision`` was
removed from the hashed tuple — a behavior-neutral rebuild is the SAME identity.)
The git-like lineage chain (parent links on ratification) and the reboot identity
verification build on this primitive.
Migration (ADR-0220): pre-split checkpoints stamped the identity WITH
``code_revision`` folded in (``identity_scheme`` 1 / absent). They are recognised
via the manifest's ``identity_scheme`` marker and migrated by a *verifying
re-stamp* — see ``reconcile_loaded_identity``: the legacy hash is reconstructed
from the persisted ``written_at_revision`` and the CURRENT packs; a match proves
the packs are unchanged (build-provenance-only divergence → resume + re-stamp),
a mismatch means the ratified packs genuinely changed (→ refuse). This avoids a
flag-day break while preserving "distinct ratified packs ⇒ refuse".
Honest scope: this is a *convention* naming the existing per-pack content hashes,
not a new pack format. The ratified teaching corpus / recognizer-registry head can
be folded into the tuple later (additive — a new key changes the identity, which
is the correct semantic for a ratified-substrate change).
"""
from __future__ import annotations
import enum
import hashlib
import json
from pathlib import Path
from typing import Any
from core.config import (
DEFAULT_ANCHOR_LENS,
DEFAULT_ETHICS_PACK,
DEFAULT_IDENTITY_PACK,
DEFAULT_REGISTER_PACK,
RuntimeConfig,
)
# The never-swappable safety pack default (packs/safety/loader.py).
DEFAULT_SAFETY_PACK: str = "core_safety_axes_v1"
#: The current engine-identity stamp scheme (ADR-0220): ``2`` = packs-only hash.
#: A manifest with ``identity_scheme`` absent or ``< 2`` was stamped by the
#: pre-split build that folded ``code_revision`` into the hash (legacy scheme 1).
ENGINE_IDENTITY_SCHEME: int = 2
_PACKS_ROOT = Path(__file__).resolve().parents[1] / "packs"
#: role -> packs/ subdirectory holding ``<pack_id>.json``.
_ROLE_DIRS: dict[str, str] = {
"identity": "identity",
"safety": "safety",
"ethics": "ethics",
"register": "register",
"anchor_lens": "anchor_lens",
}
#: The ratified roles that constitute the engine's identity, in canonical order.
RATIFIED_ROLES: tuple[str, ...] = (
"identity",
"safety",
"ethics",
"register",
"anchor_lens",
)
class EngineIdentityError(RuntimeError):
"""A ratified pack named by the identity tuple could not be resolved."""
class IdentityContinuityError(RuntimeError):
"""A reboot found the stamped checkpoint identity != the recomputed identity.
The ratified *pack* substrate changed while the engine was down, so it would
resume the lived state under a DIFFERENT identity than the checkpoint was
written under. A build-revision-only change is NOT this (ADR-0220) — it
resumes. Raised only under strict identity continuity; the default surfaces a
warning and a queryable break flag (reboot is recovery, not control flow).
"""
class IdentityReconciliation(enum.Enum):
"""The outcome of reconciling a stamped checkpoint identity against the build.
- ``MATCH``: the stamp is the current (packs-only) scheme and equals the
recomputed identity — a clean same-identity resume.
- ``MIGRATED``: the stamp is the legacy (code_revision-folded) scheme, and a
verifying recompute proves the ratified packs are unchanged — the same
identity under an old stamp scheme; resume and re-stamp.
- ``DIVERGED``: the ratified packs genuinely differ (a true different
identity) — refuse under strict, warn + break otherwise.
"""
MATCH = "match"
MIGRATED = "migrated"
DIVERGED = "diverged"
def _pack_content_hash(role: str, pack_id: str) -> str:
path = _PACKS_ROOT / _ROLE_DIRS[role] / f"{pack_id}.json"
if not path.exists():
raise EngineIdentityError(
f"ratified {role} pack not found: {_ROLE_DIRS[role]}/{pack_id}.json"
)
return hashlib.sha256(path.read_bytes()).hexdigest()
def ratified_substrate(pack_ids: dict[str, str]) -> dict[str, Any]:
"""The canonical, auditable identity tuple — the ratified packs ONLY.
``{role: {"pack_id", "sha256"}}`` for every role in ``RATIFIED_ROLES``.
``pack_ids`` must name a pack for every such role. The build revision is
deliberately NOT here (ADR-0220) — it is build provenance, recorded in the
manifest's ``written_at_revision``, not part of the identity.
"""
substrate: dict[str, Any] = {}
for role in RATIFIED_ROLES:
pack_id = pack_ids[role]
substrate[role] = {
"pack_id": pack_id,
"sha256": _pack_content_hash(role, pack_id),
}
return substrate
def _hash_substrate(substrate: dict[str, Any]) -> str:
canonical = json.dumps(
substrate,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def compute_engine_identity(pack_ids: dict[str, str]) -> str:
"""The sha256 EngineIdentity over the ratified-packs-only substrate tuple."""
return _hash_substrate(ratified_substrate(pack_ids))
def compute_legacy_engine_identity(pack_ids: dict[str, str], git_revision: str) -> str:
"""Reproduce the PRE-split (scheme 1) identity: packs **plus** ``code_revision``.
Byte-for-byte equivalent to the old ``compute_engine_identity`` so a legacy
stamp can be reconstructed for the migration check (``reconcile_loaded_identity``).
Do NOT use this to stamp new checkpoints — it exists only to recognise and
verify legacy ones.
"""
substrate = ratified_substrate(pack_ids)
substrate["code_revision"] = git_revision
return _hash_substrate(substrate)
def _resolve_pack_ids(config: RuntimeConfig) -> dict[str, str]:
"""Resolve each ratified role to its active pack id (config override or DEFAULT)."""
return {
"identity": config.identity_pack or DEFAULT_IDENTITY_PACK,
"safety": DEFAULT_SAFETY_PACK, # never-swappable
"ethics": config.ethics_pack or DEFAULT_ETHICS_PACK,
"register": config.register_pack_id or DEFAULT_REGISTER_PACK,
"anchor_lens": config.anchor_lens_id or DEFAULT_ANCHOR_LENS,
}
def engine_identity_for_config(config: RuntimeConfig) -> str:
"""EngineIdentity for a runtime config (resolving every role to its active pack)."""
return compute_engine_identity(_resolve_pack_ids(config))
def ratified_substrate_for_config(config: RuntimeConfig) -> dict[str, Any]:
"""The auditable identity tuple (packs only) for a runtime config."""
return ratified_substrate(_resolve_pack_ids(config))
def reconcile_loaded_identity(
config: RuntimeConfig,
current_identity: str,
*,
stored_identity: str,
stored_scheme: int,
stored_revision: str,
) -> IdentityReconciliation:
"""Reconcile a stamped checkpoint identity against the current build (ADR-0220).
``current_identity`` is the packs-only identity the current build computes for
``config`` (passed in to avoid recomputation). ``stored_*`` come from the
loaded manifest (``engine_identity`` / ``identity_scheme`` / ``written_at_revision``).
The single source of truth for the runtime load guard AND the workbench
continuity reader, so both treat a legacy checkpoint identically:
- Current scheme (``stored_scheme >= ENGINE_IDENTITY_SCHEME``): direct
packs-only compare — ``MATCH`` or ``DIVERGED``.
- Legacy scheme: the stored hash folded ``code_revision`` and is NOT directly
comparable. Reconstruct the legacy hash from the CURRENT packs and the
persisted ``stored_revision``; a match proves the packs are unchanged
(``MIGRATED`` — same identity, build-provenance-only divergence). A mismatch
means the ratified packs genuinely changed (``DIVERGED``). If the revision
is missing we cannot verify, so we conservatively report ``DIVERGED`` rather
than blindly migrating (which would swallow a real pack swap).
"""
if stored_scheme >= ENGINE_IDENTITY_SCHEME:
return (
IdentityReconciliation.MATCH
if stored_identity == current_identity
else IdentityReconciliation.DIVERGED
)
if not stored_revision:
return IdentityReconciliation.DIVERGED
legacy = compute_legacy_engine_identity(_resolve_pack_ids(config), stored_revision)
return (
IdentityReconciliation.MIGRATED
if legacy == stored_identity
else IdentityReconciliation.DIVERGED
)