core/docs/decisions/ADR-0220-engine-identity-vs-build-provenance.md
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

293 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ADR-0220 — Engine identity vs. build provenance (`code_revision` in the identity hash)
Status: Accepted (ratified 2026-06-15 by repository owner). PR A merged; PR B
merged (#772); PR C — identity/provenance split — implemented on
`feat/identity-substrate-hash-split`.
Date: 2026-06-15
Relates: ADR-0146 (engine-state persistence), ADR-0156 (atomic checkpoint),
ADR-0157 (revision-mismatch warning), ADR-0219 (generation-dir atomic checkpoint),
L11 identity continuity (`core/engine_identity.py`, commit `f2dac1dc`),
[L10-runtime-model-scope](./L10-runtime-model-scope.md)
> This ADR **documents a contradiction between two already-ratified decisions and
> proposes how to resolve it.** It is the "PR A" of a 3-part sequence (A = this
> brief, B = safe operator ergonomics, C = the identity/provenance semantics
> change). It changes no runtime behavior. Per project doctrine (*address
> critiques, don't waive them*; *fix upstream, not beside*), the semantics change
> (C) must not ship before this decision is accepted.
## Context — the incident
`core always-on` refused to resume a 26 396-turn lived life:
```
checkpoint was written under 75f3bb75649d…
but this build computes b0f89456fe26…
```
The checkpoint (`engine_state/manifest.json`, now backed up under
`engine_state/_life_backup_c6d0e2a_f5c6914d/`) was stamped at revision
`c6d0e2a92004`; the running build is `f5c6914d0083`. Under the always-on
daemon's forced strict-continuity config the load guard raised
`IdentityContinuityError` and the daemon exited (`core/cli.py:393-401`,
exit code 2).
This was diagnosed and operationally resolved (back up the runtime files —
**not** the directory; see "Operator ergonomics" below — then start a fresh
life). The remaining question is architectural: **is the refusal correct
behavior, or a defect in how engine identity is composed?**
## The contradiction (grounded)
`EngineIdentity` is `sha256(canonical(ratified_substrate))`, where
`ratified_substrate` is the five ratified personality packs **plus the code
revision** (`core/engine_identity.py:92-99`):
```python
substrate["code_revision"] = git_revision # engine_identity.py:99
```
But the module's own docstring states the opposite contract
(`core/engine_identity.py:9-11`):
> "It is bumped only by a ratified change to the identity substrate … NOT by
> lived learning."
A git commit is not a ratified substrate change, yet it bumps the identity.
This collides head-on with **ADR-0157**, which already ruled that a revision
mismatch on load is a **non-fatal warning**, not control flow. That decision is
live in `engine_state/__init__.py:419-433` (`load_manifest` emits an ADR-0157
`RuntimeWarning` on `written_at_revision` mismatch and loads anyway —
*"reboot is recovery, not control flow"*). So the **same git revision** drives
two opposite policies:
| Field | Where | Same-rev change ⇒ |
|---|---|---|
| `written_at_revision` (provenance) | `engine_state/__init__.py:350` | **warn**, load anyway (ADR-0157) |
| `code_revision` (inside identity hash) | `engine_identity.py:99` | **hard raise** under strict (`runtime.py:790-804`) |
The provenance field and the identity input are **the same value from the same
source** — `get_git_revision()` (`engine_state/__init__.py:121-141`) — wearing
two contradictory hats.
### Verified facts (black-box, against `f5c6914d0083`)
- The divergence is **pure code-revision.** Recomputing
`engine_identity_for_config(RuntimeConfig(), "c6d0e2a92004")` reproduces
`75f3bb75…637a29fee` **byte-exactly**; with `"f5c6914d0083"` it is
`b0f89456…8bd6`. All five ratified pack SHAs are identical across the two
revisions (the default packs — `default_general_v1`, `core_safety_axes_v1`,
`default_general_ethics_v1`, `default_neutral_v1`, `default_unanchored_v1`
have identical blob hashes at both commits). Nothing about *who the engine is*
changed; only the build did.
- The guard works as designed: identity computed at init
(`runtime.py:749-752`), compared to the stamped value
(`runtime.py:787`); under `strict_identity_continuity` it **raises**
`IdentityContinuityError`, otherwise it **warns and sets the queryable
`identity_continuity_break` flag** (`runtime.py:790-804`). The always-on
daemon forces strict (`chat/always_on_daemon.py:45-49`).
## Why this is a defect, not just naming
It is tempting to call this an optics/naming tension. It is sharper than that:
1. **It defeats the stated telos during normal development.** Because the daemon
forces `strict_identity_continuity=True`, **every commit between daemon
restarts** makes the engine refuse to resume the same life — directly
contradicting both the "one continuous life" telos and the module's own
"bumped only by a ratified change" contract. (The failure is *fail-closed and
safe* — it refuses rather than silently forking — but the conservatism falls
on exactly the wrong axis.)
2. **`code_revision` is simultaneously over- and under-sensitive as an identity
input:**
- **Over:** flips on every commit, even a docs-only or test-only commit that
cannot change runtime behavior (proven above — packs byte-identical).
- **Under:** `get_git_revision()` is **HEAD-only** — an uncommitted/dirty
working tree does **not** change the revision. You can edit operator code
and the daemon still believes it is the same life. It is also a **12-char
short prefix** (`--short=12`, collision-possible vs. the full SHA), and it
returns the literal string `"unknown"` when git is unavailable — collapsing
*all* builds in a git-less environment to one shared identity.
A signal that is both too sensitive (every commit) and too coarse
(ignores uncommitted edits, truncates the SHA, degrades to a constant) is a
poor input to a "who am I" hash.
## Decision drivers
- Preserve the L11 guarantee that **distinct ratified substrate ⇒ distinct
identity** (identity is load-bearing and falsifiable).
- Keep the guard **fail-closed**: a genuinely unsafe resume must still be
refused, not silently accepted.
- Stop a behavior-neutral code bump from forking the engine's identity.
- Do not weaken the L10 same-life proof surface or the lineage invariants.
## Options
**O1 — Status quo (block on any revision change).** Honest about "code may have
changed → don't resume," but contradicts ADR-0157 and the docstring, and makes
continuous-life development impractical. *Rejected as the resting state.*
**O2 — Demote code_revision to a warning everywhere (drop it from the identity
hash).** Identity = ratified packs only; build revision becomes provenance only,
exactly like `written_at_revision` already is under ADR-0157. Simple and
internally consistent. **Risk:** a code change that genuinely alters
serialization/semantics of lived state would then resume silently under the same
identity (mitigated by ADR-0219's schema versioning + ADR-0157's warning, but
worth stating explicitly).
**O3 — Split the hashes (recommended).**
```
identity_substrate_hash = sha256(ratified identity/safety/ethics/register/anchor_lens packs) # "who am I"
build_provenance_hash = code_revision (+ optionally a content hash of the runtime) # "which build"
```
Then the resume policy becomes an **explicit, separately-governed** question
rather than an accident of putting `code_revision` inside identity:
```
same identity_substrate_hash + different build_provenance:
→ warn + stamp provenance, resume (default; matches ADR-0157)
→ OR require an explicit operator opt-in / migration / fork (strict)
```
**O4 — Migration/fork command.** Orthogonal to O1O3: give the operator a
first-class way to carry a life across an intended identity change
(`engine-state fork`), instead of manual file surgery. Complements whichever of
O2/O3 is chosen.
## Decision (ratified 2026-06-15)
Adopt **O3 (split)**, with the **resume policy defaulting to warn-and-resume (O2
semantics) and strict-mode requiring explicit operator intent** for a
build-provenance-only divergence. A genuine *substrate* divergence (different
ratified packs) still refuses under strict — that is the real "different
identity". Sequence:
- **PR A — this brief.** No code. *(merged)*
- **PR B — safe operator ergonomics (no identity-semantics change).** *(merged, #772)*
- **PR C — identity/provenance semantics.** *(this branch)* Splits the hash so
`engine_identity` = ratified packs only (NO `code_revision`); re-points the
continuity guard at that substrate hash; records `code_revision` as
provenance only (the manifest already carries `written_at_revision`); adds an
identity-scheme marker so pre-split checkpoints (which folded `code_revision`
in) migrate via warn-and-re-stamp rather than a flag-day strict break; and
updates the L11 lineage / L10 soak proof surface — including a new test that a
behavior-neutral commit (same packs, different rev) no longer breaks
continuity, while keeping "distinct packs ⇒ distinct identity" green.
### Honesty note on the split
The split is **not a rename.** It is the load-bearing semantics change and
touches: the continuity guard (`runtime.py:787-804`), the manifest schema
(`engine_state/__init__.py:350-353`), the lineage tests
(`tests/test_engine_identity_lineage.py`, `tests/test_identity_continuity_proof.py`,
which key off the single `engine_identity` field and assert
`engine_identity == parent_engine_identity` for a stable life), and the L10 soak
runner (`evals/l10_always_on/runner.py:113`). PR C must keep the L11
"distinct packs ⇒ distinct identity" test green and add a test that a
behavior-neutral commit no longer breaks continuity. Note that
`build_provenance`-as-a-distinct-on-disk-field **already exists**
(`written_at_revision`); what does not yet exist is an
`identity_substrate_hash` computed *without* `code_revision`.
## PR B — safe operator ergonomics (no semantics change)
Two changes, shippable before the O3 decision, that would have prevented the
incident's confusion:
1. **`core always-on --engine-state PATH`.** A trivial wire-through:
`run_daemon` already accepts `engine_state_path: Path | None`
(`chat/always_on_daemon.py:132,152`); `cmd_always_on` simply never passes it
(`core/cli.py:384-390`), so it falls back to `_DEFAULT_DIR`. One
`add_argument` + one kwarg surfaces the existing per-state-root concept so an
operator can run a per-branch dev life without touching the package dir.
(Note: a `CORE_ENGINE_STATE_DIR` env var already does this
`engine_state/__init__.py:52-56` — so the flag is a second selection path
to keep consistent, and `--no-load-state` already provides an *ephemeral*
fresh start. The genuinely missing piece is a *persisted* fresh life under a
new dir while preserving the old.)
2. **A humane `IdentityContinuityError` recovery message.** Today the handler
(`core/cli.py:393-401`) states the mismatch but gives no recovery path.
⚠️ **Footgun to avoid (this corrects the originally-suggested message).** Do
**not** tell the operator to `mv engine_state …` or `rm -rf engine_state`.
When `CORE_ENGINE_STATE_DIR` is unset, the default engine-state directory
**is** the git-tracked `engine_state/` **Python package**
(`_DEFAULT_DIR = parents[1] / "engine_state"`; `engine_state/__init__.py` is
the only tracked file there, runtime data is gitignored alongside it). Moving
or deleting it removes `engine_state/__init__.py`, breaking
`from engine_state import …` across ~20 files — including the very
`core always-on` command the message tells the operator to re-run
(`core/cli.py:341`).
The correct recovery guidance:
```
This checkpoint belongs to a different engine life (the ratified substrate
or build revision changed since it was written).
Options:
1. Resume the old life — run the build that wrote it:
git checkout <checkpoint_revision> # e.g. c6d0e2a92004
core always-on
2. Start a fresh persisted life under a separate state dir
(does NOT touch the old one):
core always-on --engine-state ./engine_state_<name>
(or: CORE_ENGINE_STATE_DIR=./engine_state_<name> core always-on)
3. Start fresh in place — clear ONLY the runtime state, never the package:
back up/remove the runtime files inside the engine-state dir
(manifest.json, the current pointer + gen-*/ dirs, recognizers.jsonl,
discovery_candidates.jsonl, session_state.json, proposals.jsonl),
leaving engine_state/__init__.py intact.
```
The message should print the actual `<checkpoint_revision>` from the stamped
manifest so option 1 is copy-pasteable.
### "Start fresh in place" — clear the directory, not just the manifest
Removing only `manifest.json` is sufficient **only in the legacy flat layout**
(where `EngineStateStore.exists()` keys on `manifest.json`,
`engine_state/__init__.py:456`). In the **production ADR-0219 generation-dir
layout**, `exists()` keys on the `current` pointer, so a manifest-only removal
still leaves `exists()==True` and `load_recognizers()`/
`load_discovery_candidates()` ingest the prior life's derived state. Worse,
`proposals.jsonl` is loaded independent of the `exists()` gate
(`runtime.py:761-763`). A correct in-place fresh start clears **all** runtime
files listed above. (The incident's dir was flat and was fully cleared, so its
fresh start is clean.)
## What this ADR does **not** change
- It does not modify `core/engine_identity.py`, the guard, or the manifest
schema. PR B touches only the CLI surface (flag + message). PR C is gated on
acceptance of O3.
- It does not weaken `strict_identity_continuity`; it proposes re-pointing what
identity *means* so strictness lands on substrate, not build.
## Corrected non-issues (so reviewers don't chase them)
- **The stale `always_on.lock` is not a hazard.** The single-instance lock is an
advisory `fcntl.flock` the kernel releases on process death; the lock *file* is
intentionally never unlinked (`chat/always_on_daemon.py:55-64`). A dead
holder's marker does not block a new daemon.
- **`engine_state/` is not scanned by the architectural-invariant suite** —
there is no INV risk from runtime files living beside the package.
## Evidence appendix
```
# pure-code-revision proof (RuntimeConfig defaults, packs byte-identical):
engine_identity_for_config(RuntimeConfig(), "c6d0e2a92004") == 75f3bb75…637a29fee # stamped, old life
engine_identity_for_config(RuntimeConfig(), "f5c6914d0083") == b0f89456…8bd6 # current build
git diff --stat c6d0e2a92004 f5c6914d0083 -- packs/{identity,safety,ethics,register,anchor_lens} == (empty)
# the two-hats-on-one-value relationship:
get_git_revision() ──▶ code_revision (hashed into engine_identity) engine_identity.py:99 → strict raise
└─▶ written_at_revision (provenance, unhashed) __init__.py:350 → ADR-0157 warn
```