The tracked commitment is the REPRODUCIBLE one — a working copy's history and its graph get separate addresses, and only one of them can be compared
Accepted
Context
ADR-029 declares, for tier-1: «Cross-instance verifiability via state_root + witness». On 2026-08-20 that was tested and the tracked artefact could not support it — not because the substrate lacks the number, but because it records the other one.
MEASURED. Three working copies, one byte-identical `core.ncl` (sha256 7924b3e7…). One is this repository's own log, 501 ops accumulated over months and migrated across a redb file-format change; the other two were built from scratch, minutes apart, 247 ops each:
ontoref ops=501 state_root=99ed0a4a…d106a565 ontology_id=d96334… clone a ops=247 state_root=99ed0a4a…d106a565 ontology_id=23dea8… clone b ops=247 state_root=99ed0a4a…d106a565 ontology_id=13e9a8…
One state root. Three ontology ids. `_refs.ncl` — the versioned file, the one qa::substrate-oplog-what-and-why calls «the COMMITMENT» — recorded only the second column.
WHY THE IDS DIFFER, AND WHY THAT IS CORRECT. `ontology_id` is computed from the oplog's DAG heads, and every op carries an HLC stamped from `now_unix_nanos()`. extract.rs says why: «One reconciliation is one moment … the run is addressable as a point in time — `as_of <that instant>` selects exactly the state this reconciliation left behind». Two copies that learned the same graph at different instants genuinely have different histories. That is bitemporality working, not a defect, and making the log deterministic would destroy the property the log exists for.
WHY THE ROOTS AGREE. `commit/tree.rs` builds leaves as `blake3("leaf-v1" || cell_key || len(value) || value)` over `cell_key(entity, attr)`. No tx_time enters the tree. The root is therefore a pure function of the materialised graph — independent of when it was learned, in what order, how many times, or on which file format.
SO THE SUBSTRATE ALREADY COMPUTES BOTH, AND THE SLOT FOR THE SECOND ALREADY EXISTED. `RegistryEntry` carries:
/// Most recent state root verified by this actor for this ontology. /// `None` when the ontology has been registered but its current /// state has not yet been confirmed. pub verified_state_root_hex: Option<String>,
Grep for its writers: four, and all four write `None` — one production constructor, one in-crate test, two test fixtures. Nothing computes it, nothing reads it, and it appears in no `_refs.ncl` and no schema. The design resolved this and shipped the resolution unwired. It READS as resolved, which is why it went unnoticed: the struct says so and the doc-comment even explains what `None` means. Only the absence of writers gives it away.
WHAT IT COST, IN THE ORDINARY CASE. A fresh clone touching `core.ncl` meets the substrate-freshness gate, which shells `--extract-dry-run` and fails when `attrs_asserted + attrs_retracted > 0`. Measured on a clone carrying the versioned `_refs.ncl` and no log (the log is gitignored by design):
{"attrs_asserted":1463,"attrs_retracted":0,…}
There is no drift. The graph is identical. It fails because the question asked is «does my log differ from the graph», and a fresh copy has no log. Unblocking it means reconciling, which mints a third `ontology_id` and dirties a versioned file with a value that will differ again on the next machine. The tracked commitment was becoming a per-workstation number.
THIS IS THE SECOND INSTANCE OF ONE SHAPE IN ONE DAY. adr-099 retired a store that was written and never read. This is a field that was declared and never written. Both are surfaces that report a capability no path exercises, and neither failed anything while it was wrong.
Decision
`_refs.ncl` records BOTH addresses, and the graph's address is the one a verifier compares.
{ name = "ontoref-core", ontology_id = "…", # this copy's HISTORY — DAG heads, per-copy state_root = "…", # the GRAPH — Merkle over cells, reproducible oplog_path = "…", classification = 'Type1Protocol, }
BOTH, NEVER ONE. They answer different questions and neither substitutes for the other. `ontology_id` addresses the sequence of learning events this working copy holds; it is what `as_of <instant>` resolves against and what a cross-project `fetch_cell_witness` names. `state_root` addresses the graph those events arrived at. Dropping the first would break bitemporal queries; dropping the second is the state this ADR corrects.
THE PREVIEW MUST PROMISE WHAT THE APPLICATION DELIVERS. `--extract-dry-run` now reports `state_root` too, computed by applying the ops it WOULD have appended to an in-memory commit layer rather than by replaying the log alone. Without that a fresh clone could not learn whether it agrees with the tracked commitment without first writing — and a gate that must dirty its subject to ask a question is the pattern adr-099's own migrator was built to avoid.
WHAT THIS DOES NOT DECIDE. It does not make the oplog shared. Versioning the evidence — exporting the op sequence so every clone replays one history — is a strictly stronger claim with its own costs (monotonic growth in git, a merge semantics that must be union, a format that is not the redb file) and it is left open. `OpLog::append` is already idempotent by `OpId`, so such an import would need no migration ledger; that is a fact about feasibility, not a decision to build it.
Constraints
- Hard `_refs.ncl` MUST record a `state_root`, and it MUST equal the Merkle root the local oplog computes over its materialised cells. A tracked commitment that differs between two copies of the same graph is a defect, not a difference.
- Hard Neither `ontology_id` nor `state_root` may be removed from the registry entry in favour of the other. They address a history and a graph respectively, and no single value answers both.
- Hard The dry-run path MUST report the state root the reconciliation would produce, computed without writing. It MUST NOT report the root already on disk, and MUST NOT persist anything in order to answer.
- Hard The reconciliation timestamp MUST remain a real instant. No mechanism may derive the HLC from content in order to make `ontology_id` reproducible.
Alternatives considered
- Make the oplog deterministic — drop the wall clock, derive HLC from content — rejected: It buys comparability by destroying bitemporality. `valid_from` vs `tx_time` — when it was true vs when we learned it — is the property ADR-023 introduced the log for, and a log where every copy claims to have learned everything at the same instant records a fiction. The measured divergence is not noise; it is the record being honest.
- Replace `ontology_id` with `state_root` in `_refs.ncl` — rejected: `ontology_id` is what `fetch_ontology` and the cross-project witness path resolve against, and what addresses a specific history for `as_of`. Removing it would trade one unanswerable question for another. Both, or the carrier answers half.
- Version the oplog itself, or an export of it, so every clone replays one shared history — rejected: Strictly stronger and genuinely attractive — `append` is already idempotent by `OpId`, so an import needs no ledger — but it is a different decision with its own costs: the log grows monotonically in git, its merge semantics must be union rather than conflict, and the artefact would have to be a canonical export rather than the redb file, whose bytes depend on the writer. Left open rather than folded in.
- Populate `RegistryEntry::verified_state_root_hex` instead of adding a field to the NCL — rejected: That struct is an in-memory registry, not the versioned surface. Filling it would leave the tracked file — the thing another instance actually reads — still carrying only the per-copy address. It should be filled too, and is named as remaining work rather than claimed as done.
Anti-patterns
- A field whose every writer writes None — A record gains a field for a value that was correctly identified as needed, with a doc-comment explaining what its absence means — and every construction site fills it with the absent case. It reads as resolved to anyone who inspects the type, and only a search for writers reveals that the confirmation never happens. `verified_state_root_hex` stood this way from its introduction until adr-100.
- A machine-specific number in a versioned file — A value that identifies a working copy is committed as though it identified the project. Every clone that reconciles produces a different one, dirties the file, and — if committed — makes the project's claim into that workstation's. The churn reads as ordinary noise because nothing distinguishes it from a real change.
- A check whose failure state is the normal state of a new checkout — A commit gate compares local derived state against authored input, on an artefact that is deliberately not versioned. A fresh clone has no derived state, so the gate fails with a large delta that represents no drift — 1463 asserts over an identical graph, measured. The contributor's first act is to silence it, which is how a gate teaches its own bypass.
Related ADRs
ADR-029 · ADR-023 · ADR-028 · ADR-036 · ADR-041 · ADR-099 · ADR-074 · ADR-071