Kannaka Library
Kannaka Library / kannaka (kannaka-memory) / ADR-0051 Adversarial Design Review — Reconciled Verdict
kannaka-labs/kannaka-memory docs/adr/ADR-0051-review-verdict.md · 2026-09-12 · source ↗ · edit ↗

ADR-0051 Adversarial Design Review — Reconciled Verdict

Overall verdict: GO_WITH_CHANGES

The staged shape — Stage 0 explicit declaration → Stage 1 propose-only → Stage 2 flag-gated auto-apply — survived every lens and was independently endorsed by all five reviewers. So did the two hardest calls in the ADR: refusing to reuse phase/detect_contradictions, and hard-depending on ADR-0049 rather than running a detector on compound wavefronts. Nothing refutes the plan.

What is refuted is narrower and repairable: three specific factual claims the ADR makes about the code it will build on. Two of them are load-bearing for its own risk argument.

ADR claimStatus
:125-126 "set_temporal can clear the stamp. That is the safety margin that makes Stage 2 thinkable at all"FALSE. No code path can write None.
:65-67 "genuinely usable today for any caller that already knows the relationship (the swarm, the nostr membrane…)"FALSE. Swarm ids are node-local; no supersede subject exists.
:112-114 merge produces "a memory that asserts both values weakly"FALSE. Merge is verbatim retention of one member + hard deletion of the other.

Stage 1's specification is also unbuildable as written against ADR-0049 as written — but the ADR already gates Stage 1 on ADR-0049 being built, so that is a spec amendment on an unstarted stage, not a refutation.

Deduplication note: 47 raw findings reduce to 19 distinct defects. set_temporal cannot clear appeared 5×; dream-merge-deletes-the-current-fact 4×; the swarm-brief hard filter 3×; gate vacuity 4×; the observed_at-is-None inertness 3×.


BLOCKERS — must reshape the design before any build

B1. The recoverability premise does not exist. Ship the un-stamp before the first stamp.

(merges 5 findings; all CONFIRMED, verified verbatim)

src/hrm_store.rs:1885-1888:

if eff.is_some() { meta.effective_at = eff; }
if obs.is_some() { meta.observed_at = obs; }
if exp.is_some() { meta.expires_at = exp; }

None means "leave untouched" — contractually, per the doc comment at hrm_store.rs:1870-1872. The same guard repeats at all four write surfaces (:1893, :1898, :1903, :1907-1911). The sole call site src/bin/kannaka.rs:1557 is itself wrapped in if effective_at.is_some() || observed_at.is_some() || expires_at.is_some() (:1556), and parse_ts (kannaka.rs:1446-1454) process::exit(2)s on anything not RFC3339, so no clear sentinel is even expressible. REMEMBER_USAGE (kannaka.rs:1427) offers no clear flag, and there is no kannaka temporal verb at all — set_temporal is unreachable for any pre-existing id.

The only workaround is stamping a far-future date, which is a different assertion, not a clear.

Required before Stage 0 merges: tri-state signature set_temporal(&id, Option<Option<DateTime<Utc>>> ×3) (outer None = leave, Some(None) = clear) or a sibling clear_temporal, keeping the current signature as a thin wrapper so no caller changes; plus kannaka temporal <id> [--expires RFC3339|--clear-expires] [--observed …] [--effective …] so any existing id is reachable. Test: stamp → save → reload → clear → save → reload → expires_at == None, extending the existing round-trip at src/medium/chiral_persistence.rs:839-867. Until that test is green, delete ADR-0051:124-127's recoverability sentence.

B2. The Stage-0 write has no commit point and no mutual exclusion.

(merges the torn-write and daemon-clobber findings; both CONFIRMED)

Two halves of one defect — when the stamp is committed.

Ordering. openclaw.rs:406-409 does absorb(...) then self.engine.store.flush().ok() — the new memory is on disk before control returns. Then kannaka.rs:1552-1557 calls set_modality/set_temporal, which only mark_dirty() (hrm_store.rs:1917-1920). :1560 prints the id; :1563-1701 does blocking NATS work; hrm_store.rs:2342-2350 impl Drop is the only later write. SIGKILL, Ctrl-C during a hung NATS connect, or any process::exit in that window leaves the new fact live, the old fact un-expired, exit 0, id already printed.

Exclusion. remember is the only writer Stage 0 proposes and the one writer that takes no lock: lock sites are kannaka.rs:1381 (dream probe), :2580, :2711, :2956, :4051 (swarm join) — the remember arm :1426-1560 is absent. swarm join holds the medium in RAM, never re-reads the .hrm, and flushes every 30s heartbeat (:4001swarm_publish_heartbeatsys.engine.store.flush() at :762, comment "Periodic flush"), rewriting the whole file from its stale snapshot. Two aggravators verified: acquire_write_lock_blocking returns None after timeout and logs "proceeding" (:172), bound unchecked to _join_write_lock at :4044; and the flock is inside #[cfg(unix)] (:146-153), so on Nick's Windows seed box there is no lock at all. This is the exact class documented in-repo at kannaka.rs:1217-1220 and hrm_store.rs:324-329.

ADR-0051:118 "Single-writer discipline is binding" is refuted for CLI writers.

Required: resolve and stamp the --supersedes target before the print and before any network I/O, apply the new memory and both stamps inside one dirty window so a single save_medium rename (chiral_persistence.rs:319, already atomic) is the commit for the whole supersession; take try_acquire_write_lock() in the remember arm and hard-fail non-zero when the writer daemon holds it. Then either make swarm join stat the .hrm and reload when mtime exceeds its own last write, or route --supersedes as a request the daemon consumes. Test: mock daemon holding a stale snapshot, run a stamp, force a daemon flush, assert expires_at survived.

B3. The shipping surface has zero pre-registered gates, and every failure mode on it is silent.

(CONFIRMED; the most practically important finding in the set)

ADR-0051:67 says Stage 0 "is the whole of what ships without further review." Every gate at :90-101 measures the Stage 1 detector, which the ADR itself says cannot exist until ADR-0049 does.

Three silent-success paths on that ungated surface:

  • set_temporal returns bool (hrm_store.rs:1879, :1916) and kannaka.rs:1557 discards it — a bare statement, no binding. Given B7 (peer uuids are not local ids) and merge-eaten ids, a wrong --supersedes target is the common case, and the command prints a fresh uuid and exits 0 having stamped nothing.
  • The whole block is inside if let Some(hrm) = ...downcast_mut::<HrmStore>() (:1548-1553) — a non-HRM backend drops the flags with no message.
  • warn_if_readonly (:684-691, called at :1432) prints to stderr and the command still exits 0 — precisely the ADR-0047 reinforce_link shape ADR-0051:118-122 cites against everyone else.
  • set_temporal sets found = true on a cache-only match (:1907-1912), but sync_cache_to_medium writes back energy only — its own comment at hrm_store.rs:590 says "ONLY energy is synced here." A cache-only stamp reports success and is discarded at the next save.

Required: pre-register Stage-0 gates as ordinary cargo test cases, not research-harness output. Change set_temporal's return to an enum distinguishing AuthoritativeWrite / CacheOnly / NotFound; --supersedes exits 2 on anything but AuthoritativeWrite; unknown id prints nothing to stdout; self-supersession rejected; --supersedes under KANNAKA_READONLY=1 exits non-zero; stamp survives .hrm write+reload.

B4. G1 precision is scored over unordered pairs — a detector that always expires the wrong member scores full green.

(CONFIRMED, blocker, no counter-ruling)

ADR-0051:92-93 defines precision as "of the pairs the detector calls supersession, what fraction truly are." Pair membership is direction-free: {v_old, v_new} is a true pair regardless of which member Stage 2 stamps. An inverted detector — or, given B6, one tie-breaking arbitrarily on equal timestamps — posts G1 = 1.0, G2 = 1.0, G3 zero false calls, G4 zero candidates, and Stage 2 then writes expires_at onto every current fact in the corpus. Because swarm brief hard-filters expired items (kannaka.rs:3630-3639), the agent's brief would answer with the superseded value and drop the true one.

Required: score ordered candidates (expired_id, replacement_id); a true positive requires both pair and direction. Add a direction-accuracy gate at exactly 1.0 — an inverted call is strictly worse than no call. State the tie rule now: equal observed_at ⇒ emit nothing, never guess.

B5. A dead detector posts the best score the harness can print and passes 3 of 4 gates.

(merges 4 gate-integrity findings; CONFIRMED)

ADR-0051:89-101 enumerates only G1-G4; liveness appears solely as the parenthetical "hard liveness gate not folded into fitness" — no metric, no threshold, no placement. Instantiate a detector at threshold 1.0: G1 = 0 FP / 0 calls = vacuous 1.0; G3 passes (no false calls); G4 passes "zero, not few" exactly. Only G2 fails, and :94-96 explicitly deweights it. If L9 copies L8's fitness verbatim — research.rs:5005 fitness = 0.50*(1-p1) + 0.25*(1-p2) + 0.25*(1-p3), every term one-minus-a-goodness — the inert detector posts the global minimum, i.e. the best printable score. The gradient points at threshold → 1.0.

Two aggravators verified: L8's liveness_observed (research.rs:4936-4941) is an OR across all runs, satisfied by one candidate in ten corpora; and L8's let live = liveness_observed || t_exp == 0.0; (:5001) auto-passes the disabled arm, which ported as || threshold >= 1.0 makes the inert arm print PASS.

L8 also defines a "0.5 = no evidence" zero-denominator convention (research.rs:4969, :4975, :4981) that ADR-0051 does not carry over.

Required, all pre-registered: promote liveness to an enumerated G5, hard PASS/FAIL outside fitness, defined per seed (every one of the ≥10 seeds must yield ≥1 true-positive candidate — not an OR). Adopt the 0.5-no-evidence convention so 0/0 precision fails a ≥0.95 gate. Make G2 a hard floor (state the number now). Add two arms the harness asserts must come out NOT_SUPPORTED and fails the run if they pass: (a) threshold = 1.0; (b) a timestamp-shuffled control — identical corpus, observed_at randomly permuted — where precision must collapse to chance. Arm (b) is the one that matters: if precision does not collapse, the detector is not reading time at all and G1 is coming entirely from text overlap.

B6. Stage 1's rule is inert on the live corpus, and the mandated fixture hides it.

(merges 3 findings; CONFIRMED — this is the ADR-0047 shape, third occurrence)

ADR-0051:74 requires "the newer facet's observed_at is later." ADR-0050:105-107 records observed_at is None everywhere on the live HRM; the sole writer is hrm_store.rs:1873 with one call site kannaka.rs:1557; and absorb_gate.rs's StagedMemory/admit/commit_promotion path carries no temporal field at all, so nothing ingested over the swarm ever acquires one. None vs None — neither is later — so the detector emits zero candidates in production while ADR-0051:103-106 mandates a fully-stamped fixture that measures a state production never reaches.

The ranking side already solved this: hemisphere.rs:119 is meta.observed_at.unwrap_or(meta.created_at). The rule text omits the fallback.

Required: (1) write the created_at fallback into the ADR's rule text, not into the implementation later. (2) Add a mandatory unstamped arm to L9 — identical corpus, every observed_at = None, ordering from created_at only, same G1/G2 thresholds. That arm is the live-corpus condition; without it a green L9 says nothing about the real HRM.

⚠️ Interaction: the created_at fallback is only safe if B8 lands first — backfilled facets all get the same created_at.

B7. Stage 1's key does not exist, and ADR-0049 excludes the canonical supersession shape.

(merges 4 findings; CONFIRMED — Stage 1 is unbuildable as specified)

Three independent structural problems at the ADR-0049 seam:

(a) No (subject, attribute, value) structure exists. ADR-0051:73-74 keys the entire Stage-1 signal on "subject+attribute resonate … but value differs." ADR-0049:53-70 defines decomposition as a "fixed clause/sentence split" producing text; the only new serialized fields it commits to are parent_id/is_facet/decomposed (ADR-0049:44, :124-126). Grep across src/ for any S/A/V extractor returns only config.rs:1462 platform_triple() and an unrelated homomorphic-addition test. Similarity is whole-vector cosine (hemisphere.rs:412, hrm_store.rs:173) over a whitespace bag-of-words encoder (encoding.rs:75-89) — no API scores a sub-span. An implementer has nothing to key on and will silently fall back to whole-facet cosine.

(b) The canonical shape is never faceted. ADR-0049:64-70 decomposes only "compound, lived-origin content (≥2 independent clauses …)". The ADR's own example — "…channel is twelve" vs "…channel is twentyseven" — and the entire L8 corpus (research.rs:4850, fact_text(w, VALUES[v])) are single-clause. They never become facets, never become resolve-only parents, and inherit none of ADR-0049's exemptions. The detector's unit of analysis never materializes for the dominant supersession shape, while G4 passes trivially for the same reason.

(c) The stamp and the reader are on opposite records. ADR-0049:29-35 makes parents "excluded from the resonance scan" with facets the active wavefronts, and :78-82 has recall rewrite id→parent.id. So: temporal_weight is evaluated on self.metadata[i] — the scanned row, i.e. the facet (hemisphere.rs:402) — meaning a parent stamp is invisible to ranking; while kannaka.rs:3633-3637 does store.get(&r.id) on the already-resolved id, i.e. the parent — meaning a facet stamp is invisible to the brief. set_temporal keys on exactly one id with no facet walk (hrm_store.rs:1889-1912). Every stamp is visible to exactly one of the two consumers, and it is the wrong one in each direction. Stage 0 goes inert the day ADR-0049 ships.

Required, as ADR amendments before Stage 1 is designed: declare the constellation, not the wavefront, the unit of temporal truthset_temporal resolves a facet up to its parent and fans the write to the parent and every child with that parent_id, through one shared helper so no caller can stamp half a constellation; resolve_facets carries the most-restrictive temporal spec of parent+facet onto the resolved result. Re-key Stage 1 to work on any wavefront pair (facet or whole atomic memory), using facets only as an additional source of comparable units for compound content. Either amend ADR-0049 to emit persisted normalized S/A/V slots (three more trailing fields, deterministic extractor, own fallback struct) or restate Stage 1 as a lexical operation over normalized facet strings with resonance demoted to an O(n²) pre-filter. Amend ADR-0049's quality gate to explicitly retain short attribute-value assertions rather than drop them as numeric/low-word-count, as a named test case. Require the L9 fixture to be a majority of single-clause families.


MAJORS — change the design, do not stop it

M1. Derive the stamp; do not read the clock. ADR-0051:62-64's expires_at = now is wrong three ways at once, and one change fixes all three. set_temporal overwrites unconditionally (hrm_store.rs:1888) with no journal or superseded_by anywhere in WavefrontMeta (types.rs:465-518), so re-running a declaration moves the expiry forward and a resumed batch re-derives from scratch. Backfilling three known versions in one session stamps v1 and v2 milliseconds apart, destroying the fact that v1 died years before v2. And a Stage-2 writer calling Utc::now() inside dream would persist a wall clock into WavefrontMeta (types.rs:500-508), breaking the #521 byte-identical-dream contract (hrm_store.rs:3401-3419) — note the existing Utc::now() at :1371 is ranking-only and never reaches disk. The L8 fixture already has the right semantics: research.rs:4854-4859 sets version v's expires to version v+1's observed instant, with the comment "version v expires exactly when version v+1 was observed — that is what supersession IS." Stamp expires_at = replacement.observed_at.unwrap_or(replacement.created_at); skip any target already expired at ≤ that value; support remember "…" --observed <iso> --supersedes <id>; batch a pass into one save_medium (the pattern apply_consolidation already uses at hrm_store.rs:1570-1576).

M2. Dream merge can hard-delete the current fact and keep the expired one — and the ADR describes the wrong failure mode. apply_consolidation's Snap carries only id/energy/phase/tier/timestamp/vector (hrm_store.rs:1316-1323) — no temporal field is read anywhere in the pass. Carrier is argmax eff_strength = energy * exp(-0.001*age_days) (:1371-1376), the carrier mutation loop writes only energy and tier (:1461-1471), and every non-carrier is hard-removed via remove_wavefront (:1474-1495). usable[] excludes only Tier::Pinned (:109) — nothing temporal is merge-exempt. So an old high-energy expired fact can win the carrier vote, keep its expires_at, and the current replacement is deleted outright. The ADR's prose at :112-114 ("blurred into one wavefront … asserts both values weakly") is factually wrong — ADR-0036's vec_rep = normalize(Σ vec_i) was never implemented; a merge is verbatim retention plus deletion. A blur would at least read as low confidence; a deletion does not. Rewrite that paragraph. Severity is major, not blocker, because the skeptics found the preconditions are stiffer than four lenses assumed: ConsolidateOpts::default().mode is DryRun (types.rs:159, :181), and openclaw.rs:902-906 force-downgrades Apply→DryRun under belief unless KANNAKA_MERGE_UNDER_BELIEF=1ADR-0036:241 confirms production is in exactly that forced-dryrun state. The binding gate is also cosine ≥ 0.92 (types.rs:158), never measured for the pair; under the BoW encoder a 5-token pair sharing 4 tokens scores ~0.80, below threshold, so short facets are the least mergeable shape. Fix anyway: add the temporal triple to Snap, split any group whose members disagree on expires_at.is_some(), and make carrier selection lexicographic on (is_current, eff_strength). Test with dupA/dupB (hrm_store.rs:3371-3374), one member stamped expired, asserting zero admitted groups — plus the both-unstamped inverse as the revert-and-confirm-fail control.

M3. swarm brief hard-drops expired memories, unconditionally, on the default config. kannaka.rs:3630-3639 filters known on the boolean temporal::is_current (temporal.rs:72) before building ConsensusItem — no env gate anywhere in the path, and it is the only production consumer of is_current. Meanwhile KANNAKA_RECALL_TEMPORAL_EXP defaults to 0.0 and hemisphere.rs:397-404 skips the temporal factor entirely at that default. Net: on a stock install, --supersedes X produces zero ranking demotion and total exclusion of X from the brief and its confidence — the exact inverse of ADR-0050:56-58 and ADR-0051:124-126. This also recalibrates B4/G1: the ADR sets Stage 2's acceptable precision against "demoted," but the real consequence on the surface an operator reads is "invisible," and the two imply very different thresholds. Convert the filter to a demotion (keep the item, multiply confidence by the floor, tag "current": false in --json), or state the exclusion explicitly in Consequences and raise G1 accordingly. Note this predates ADR-0051 (Wave 3 Task 3.2b) and ADR-0050:30 documents it — a known-but-uncorrected inconsistency.

M4. Delete the swarm/nostr claim at ADR-0051:65-67. Three independent legs, all verified. handlers/swarm.rs:1025 absorbs peer content via sys.remember_with_category(...), minting a fresh local uuid and fresh created_at and discarding the peer's id — while absorb_gate.rs:188-193 defines the actual cross-node join key as blake3(normalize(content)). So --supersedes <uuid> names something no peer holds. There is no wire path for a post-hoc metadata update: grep for memory.update|memory.expire|memory.supersede across src/ returns nothing; KANNAKA.memory.new (nats.rs:1887) is emitted once at creation. And swarm sync is Kuramoto phase reconciliation only (kannaka.rs:4850-4930) — there is no store-level reconciliation anywhere, so once two nodes disagree they never converge. Scope Stage 0 as explicitly node-local; promote ADR-0051:133-134's "who may expire a swarm memory" footnote to a blocking prerequisite of any swarm claim. If replication is later wanted, key it on content_hash with its own signed subject routed through admit().

M5. HrmStore::insert never writes the chiral hemisphere — a live data-loss bug, independent of this ADR. hrm_store.rs:2062-2080 touches only self.medium and self.memory_cache; self.chiral is never touched. save_medium serializes self.chiral when present (:635-641) and rebuild_cache reconstructs from chiral.right.metadata (:474-507), so an inserted memory is absent from the .hrm and gone at the next load. The sibling method carries the exact warning and the exact fix — insert_raw_wavefront routes through chiral.store_vector because "direct medium.add_wavefront would only update the flat medium, and chiral save would ignore it — losing the wavefront on next process restart" (hrm_store.rs:836-842). Live callers: kannaka.rs:4652 (wire sync) and ops.rs:551 (import). Second half: insert also copies only energy/frequency/phase/timestamps/created_at/hallucinated (:2069-2076) — the temporal triple is silently discarded, so a stamp supplied at insert lives only in the cache until the next rebuild_cache. File this as its own issue and fix it before citing any import path as a Stage-0 consumer.

M6. exportimport destroys every temporal stamp. ops.rs:393-415 hand-builds the export object with id/content/amplitude/frequency/phase/decay_rate/created_at/layer_depth/hallucinated/parents/vector/xi_signature/geometry/connections — no effective_at, observed_at, expires_at, tier, or modality. ops.rs:531-549 reconstructs field-by-field and sets none of them, then calls insert at :551, which drops them anyway (M5). This is the documented recovery path for HRM corruption — exactly when the stamps matter most. Add the fields as optional RFC3339 strings both directions, and call set_temporal per imported id after insert. Round-trip test: stamp → export → fresh store → import → stamp present.

M7. Wire temporal fields would be unsigned and unsanitized. canonical_mem binds agent_id ‖ memory_id ‖ nonce ‖ ts ‖ blake3(content) ‖ subject ‖ amp_q16 ‖ tier — nothing temporal (provenance.rs:198-219, golden length 116 asserted at :884-886). CleanFields has exactly four fields (absorb_gate.rs:129-138) and sanitize (:151-169) touches nothing else. The receive path deserializes a whole HyperMemory from wire JSON (kannaka.rs:4608-4610) whose temporal fields are #[serde(default)] (memory.rs:124-136). Never land the insert-copies-temporal half of M5 without the sanitization half — on the day someone "fixes" insert, a peer gains ungated control of expires_at on our local store and can floor any of our memories. Extend CleanFields to clamp created_at/observed_at to min(wire, receive_time), drop a wire expires_at unless signature-covered, reject expires_at <= effective_at; add the triple to canonical_mem behind a new domain tag (not appended to the existing one — old sigs must fail, not silently verify a zeroed triple). (One lens claimed the created_at hole is exploitable today; refuted — hrm_store.rs:2074's unclamped copy lands only in the flat mirror, which resonate_with_weights never scans on a chiral store and save_medium never serializes.)

M8. The eviction paths are blind to expires_at. lowest_value_overflow_ids (openclaw.rs:766-777, invoked from dream at :1003-1017 under KANNAKA_MAX_MEMORIES) filters only m.tier != Tier::Pinned and sorts by effective_strength = amplitude/age/retrieval only (memory.rs:188-192). triage_select (openclaw.rs:690-707) sorts amplitude DESC, retains the strongest, and forgets lower-amplitude members at cosine ≥ 0.95 — so the retained member is the old accessed fact, not the current one. Give both the exemption Pinned has: m.expires_at.is_none() in the overflow filter; skip the redundancy check when exactly one member is expired (that is a supersession record, not a duplicate); change retention preference to (is_current, amplitude). Add a bounded KANNAKA_EXPIRED_RETENTION_DAYS escape hatch so expired memories are reclaimable by explicit policy. (The claimed starvation feedback loop is weaker than argued — a stamp does not touch amplitude or created_at, so effective_strength is unchanged; only retrieval_count is affected, and the non-zero floor exists to prevent that drop-out. The retention gap is real; the causal story in the finding is not.)

M9. Swarm re-absorption can resurrect the superseded value as the freshest fact. handlers/swarm.rs:958-970 gates re-import on res.first().strength >= threshold (default 0.4 at :860); openclaw.rs:447-460 sets strength straight from resonate_query, which multiplies in tweight when temporal_exp > 0 (hemisphere.rs:399-417). So stamping a local memory lowers its own dedup score and can widen the admission window; swarm.rs:1025 then re-absorbs the peer's identical stale text as a fresh memory with created_at = now, which hemisphere.rs:119 reads as maximally fresh — out-ranking the truth. Dormant today (temporal_exp defaults 0.0) and requires a peer holding the stale text plus swarm absorb being run. Fix (1) is cheap and should be a precondition of turning ADR-0050 on: compute the dedup with temporal_exp = 0.0, so demotion can never widen admission. Also add an explicit locally-superseded guard keyed on content hash.

M10. G3's control classes omit the classes that will actually produce false positives. ADR-0051:97-99 names only elaborations, restatements, partial overlap. Missing, each needing its own separately-scored sub-control at zero candidates (pooling lets 30 easy controls mask 3 hard ones):

  • Multi-valued attributes. Nothing in src/ carries attribute cardinality or arity. "the hive has a brood frame" / "…a honey frame" satisfies the rule literally and completely. The L8 vocabulary already contains one: research.rs:4713 is ["apiary","hive","frames"].
  • Scope-disjoint facts. "north harbor is open" / "south harbor is closed" — and ADR-0049:69 increases the collision by prepending the shared parent subject to every facet.
  • Numeric/unit/date format variants. encoding.rs:75-89 is token-level BoW, so "900" and "nine hundred" are unrelated tokens and a pure format change reads as a value change.
  • Disjoint effective_at intervals. hemisphere.rs:114-118 already reads effective_at on the ranking side, yet the Stage-1 rule ignores it entirely. Two facts with disjoint effective intervals are a timeline, not a supersession — the rule must say so before the fixture is written.
  • Reversed elaboration. The rule has no mutual-exclusivity requirement on the value spans, so terse-stored-later vs elaborated-stored-earlier fires and expires the more informative facet. Require the normalized spans to be mutually exclusive — neither substring nor superset.
  • Negation. "the relay is up" → "the relay is not up" shares the value token, so a value-difference test never fires, and the phase detector is unavailable. Add polarity as a separate candidate reason with independently measured precision.

Gate Stage 2 on cardinality: maintain a per-(subject, attribute) observed-value-set and refuse to auto-apply for any key ever seen with ≥2 concurrently-current values; default for an unseen key is "not known to be single-valued" (propose only).

M11. Gate on MIN over seeds, not the mean — and "≥10 corpus seeds" is 10 rotations of 16 templates. L8 accumulates sums (research.rs:4949-4954), divides by n (:4957-4963), and the verdict at :5008 tests only means; experiments/results-L8.tsv is 19 lines, one row per arm, no per-seed rows, no variance column. ADR-0051:92-93 calls G1 "the gate that matters" because a false positive expires a true memory — an absolute, asymmetric harm, for which a mean is the wrong statistic. With ~240 ordered true pairs, mean precision 0.97 is ~7 wrongly-expired true memories, and one catastrophic seed at 0.60 hides behind nine at 1.0. Separately, research.rs:4837-4838 is (i + run) % FAMILY.len() with n_families = 12 (:4672) against FAMILY.len() = 16 (:4700) — consecutive seeds share 11 of 12 families and all runs draw one 16-template table. Gate G1 on MIN over seeds plus an absolute FP-count-of-zero gate; emit one TSV row per seed; grow the tables or state plainly that the seeds are rotations.

M12. The kept regression test does not assert the ADR's stronger claim and measures a phase function production never invokes. chiral.rs:1598-1634: unrelated is computed at :1615-1618, printed at :1620-1624, and never asserted — the only assertion is supersession < FRAC_PI_2 (:1628-1633). The non-monotonicity ADR-0051:47-50 calls "the stronger one" is narrated, not guarded; a future encoder change makes phase monotonic and the test stays green. Worse for fidelity: the test calls content_born_phase on the raw encoded vector (:1603-1604), while live ingest calls content_born_phase_centered(&adapted, &mean) gated on belief_phase_enabled() (hemisphere.rs:263-268), which defaults FALSE (chiral.rs:35-39) — so on today's HRM every stored phase is literally 0.0 and detect_contradictions' gap is 0 for every pair. And dream's rephase pass recomputes with the uncentered function on centered vectors (chiral.rs:1177-1185), so a memory's phase changes after a dream. Add assert!(supersession >= unrelated, "phase became monotonic w.r.t. supersession; re-open the detect_contradictions reuse question"), plus a case going through ChiralMedium::store with KANNAKA_BELIEF_PHASE=1 reading cm.right.phase[idx], plus a third re-reading after cm.dream(...).


CONFIRMS worth preserving

Design decisions that survived every lens — do not let a later edit undo these:

  1. The staging itself. Stage 0 = explicit declaration, zero inference, independently shippable; Stage 1 = propose-only; Stage 2 = off-by-default flag. Given that merge, triage, and the size cap are all blind to expires_at (M2, M8), an inference-driven writer would be creating irreversible deletions from a guess.
  2. Refusing to key detection on phase. Non-negotiable, and the reason is stronger than the ADR states. content_born_phase is content-smooth by construction (chiral.rs:78-100) with a committed test asserting near-duplicates land at near-identical phase, circ(pb,pn) < 0.15 (chiral.rs:2067-2097), while detect_contradictions only fires on gap > opposed_gap (sensemaking.rs:145-147). Keep supersession_pairs_are_not_phase_opposed as a permanent regression pin. ⚠️ One correction to the reasoning several reviewers endorsed: the popular argument that "cos(0.6341) = 0.805 clears merge_phase_cos = 0.7071, so phase actively marks supersession pairs mergeable" is right in conclusion but wrong in derivation — with belief phase off every stored phase is 0.0, so the phase gate passes trivially for every pair (ADR-0036:227 says exactly this). The conclusion is stronger than the derivation; state it correctly. Also: ADR-0051 does not "retire" detect_contradictions — it rejects reuse for this job; the detector remains live for immune::classify_batch.
  3. Detecting on facets rather than compound wavefronts, and hard-depending on ADR-0049. The encoder arc's evidence (rank-1 sim 0.763 atomic vs not-in-top-6 compound) holds. Do not let a later edit "unblock" Stage 1 by running it on compound wavefronts — that re-runs the smearing failure ADR-0049 proved. B7 is a scope defect in the facet definition, not a defect in the facet idea.
  4. Writing through the existing WavefrontMeta fields rather than a new trailing field or a sidecar. temporal_weight reads WavefrontMeta directly (hemisphere.rs:101-125, :402), so a sidecar would have to be merged into the hemisphere before every recall. Zero format risk, zero new fallback struct. The pressure to add superseded_by is where format risk re-enters — prefer a sidecar JSON alongside .links.json/.reactivation.json, the precedent documented at hrm_store.rs:706-712.
  5. Routing every write through HrmStore::set_temporal, tagging all four surfaces. Load-bearing, not belt-and-braces: ChiralMedium::recall_vector scores left matches with the LEFT hemisphere's own metadata (chiral.rs:527, :529, :542; hemisphere.rs:333-340, :402) and only then translates left→right (chiral.rs:564-574). Never simplify to the right hemisphere alone — an unstamped left twin would return an expired memory at full temporal weight under the canonical id.
  6. The non-zero floor, and specifically w.clamp(floor, 1.0) at hemisphere.rs:131 with its comment "a merely OLD but still-true fact must never rank below an explicitly EXPIRED one." recall_temporal_floor clamps to [0.05, 1.0] and can never reach 0 (hemisphere.rs:84-91). L8 pinned this empirically (floor 0.05 → P3 0.9399 fails; plateau 0.15-0.50 → P3 1.0000). Given B1, this is now the only mitigation for a wrong call. Never optimize it to zero. Same for the holistic prune threshold being explicitly 0.0 with the invariant stated rather than accidental (chiral.rs:780-791).
  7. Emit-don't-apply under single-writer discipline. save_medium early-returns and clears the dirty flag under KANNAKA_READONLY=1 (hrm_store.rs:619-627), and swarm serve/attention serve force readonly on themselves rather than trusting the operator (handlers/swarm.rs:44-52, handlers/attention.rs:37-44). A detector stamping there would compute and silently discard. Do not relax to "any daemon may stamp." (B2 is the converse gap: the discipline does not currently cover CLI writers.)
  8. Off-by-default with a byte-identical default. resonate_with_weights short-circuits temporal_on = temporal_exp > 0.0 and skips the multiply and the powf entirely (hemisphere.rs:397-404, :413-414). Port L8's P4a check verbatim: unset-vs-explicit-zero compared with a.1.to_bits() != b.1.to_bits() on the full result list (research.rs:4913-4933) — bit-exact, not epsilon-tolerant.
  9. P4 as a hard PASS/FAIL gate kept OUT of the fitness sum (research.rs:4998-5003, verdict at :5008, with the comment at :4998-5000 stating why). The single most important structural choice L8 made. The fix for every vacuity finding is to ADD gates outside fitness, never to reweight fitness.
  10. Weighting G1 precision above G2 recall — and the review makes the asymmetry sharper than the ADR assumed. A false positive is currently unrecoverable (B1), invisible on the default ranking config (M3), fatal on the brief path (M3), and can be made permanent by deletion (M2, M8). Keep precision dominant; do not later rebalance toward recall.
  11. The .hrm format work already in place. Stamps survive save/reload on a pre-temporal file: old files fail the new-struct decode and land on WavefrontMetaPreTemporal/PreTier/Legacy with all bounds None (chiral_persistence.rs:192-208, :96-135), locked by three tests (:789-835, :719-767, :839-867). Writes are crash-atomic — per-pid/per-nanos tmp, trailing blake3, sync_all(), rename (:259-319), verified on load (:215-243, :365); the per-pid naming and 5-minute orphan sweep (:249-264, :321-350) are scar tissue from two real incidents. decode_wavefront_metadata is deliberately single-sourced for both read paths (:186-208). The load path rejects structural desync as CorruptHrm rather than an OOB panic (:615-627, :410-418). Do not weaken any of these; extend them if a field is added. The only atomicity gap ADR-0051 introduces is at the CALL level (B2), not the file level.
  12. absorb_gate::admit as the single seam for any wire-carried temporal data — it runs unconditional sanitization even when dormant, and the deferred PendingPromotion/commit_promotion ordering (absorb_gate.rs:302-323, :364-376) correctly ties the ledger to a successful medium insert. Extend CleanFields here rather than adding a parallel wire path.
  13. "Stamp the whole fixture" (ADR-0051:103-106) — correct and must not be dropped, but must be paired with the unstamped arm from B6 or it hides the inertness rather than exposing it.
  14. Keeping failed-fixture rows in the results artifact (v1-unaged vs v2-aged distractors, both retained in results-L8.tsv). The delta is why the L8 lesson is legible. results-L9.tsv must do the same.

Revised build order

Phase 0 — ADR text corrections (no code; do these first, they change what gets built).

  1. Delete/replace :124-127 — the recoverability claim is false until B1 lands.
  2. Delete the swarm/nostr clause at :65-67; scope Stage 0 node-local; promote :133-134's swarm-expiry question to a blocking prerequisite (M4).
  3. Rewrite :110-116 — merge deletes one version verbatim, it does not blur; and this is a Stage-0 precondition once temporal exclusion is in scope, not a Stage-2 one (M2).
  4. Change :62-64 from expires_at = now to the derived stamp (M1).
  5. Amend :73-75 — add the created_at fallback (B6), add the mutual-exclusivity and disjoint-effective_at requirements (M10), state the equal-timestamp ⇒ emit-nothing rule (B4).
  6. Fix the stale "MUST stay the LAST serialized fields" comment at types.rs:494-499provenance (:509-517) is the actual tail. Replace both with one ordered append-only invariant list; any new field goes after provenance with its own fallback as the first arm of decode_wavefront_metadata and a lock test modelled on :719-767. (Minor, but it is a trap sitting on the exact struct two ADRs are about to edit, and ADR-0049:43-48 warns getting it wrong misdecodes ~3800 records behind a valid checksum with no load error.)

Phase 1 — enabling PR, must merge before the first stamp exists. B1 (tri-state clear + kannaka temporal verb + clear→reload test) and B2 (stamp-before-print, single-save commit, write lock in the remember arm, daemon mtime reload) and B3 (AuthoritativeWrite/CacheOnly/NotFound enum, readonly hard-fail, self-supersession rejection). These three are one coherent change to the write path; splitting them ships a half-safe writer.

Phase 2 — Stage 0 PR. remember --supersedes <id> with the derived stamp (M1), plus the Stage-0 cargo test gate suite from B3. Nothing else in this PR.

Phase 2b — parallel, independent of the ADR. M5 (insert must route through chiral.store_vector and carry the temporal triple) and M6 (export/import round-trip). File M5 as its own bug — it is silent data loss on the wire-sync and import paths today, unrelated to supersession.

Phase 3 — before KANNAKA_RECALL_TEMPORAL_EXP is turned on anywhere. M3 (brief: demote, do not filter), M9 fix (1) (dedup computed at temporal_exp = 0.0), M8 (eviction exemptions). Turning ADR-0050 on is the precondition for M9's resurrection loop and for M3's harm becoming asymmetric.

Phase 4 — before Stage 2 is thinkable, and cheap enough to do at Stage 1. M2's temporal merge exclusion, plus the G5 survival gate: stamp the fixture, run a full KannakaMemorySystem::dream with both KANNAKA_CONSOLIDATE=on and KANNAKA_MERGE_UNDER_BELIEF=1 and KANNAKA_MAX_MEMORIES set to force the size cap, and assert every superseded id is still present and still resolves to its original content, at 1.0, hard PASS/FAIL outside fitness. Assert inside the arm that the merge actually applied (wavefront count decreased or an absorb recorded) — otherwise the dry-run downgrade makes dream a no-op and the arm passes for nothing. This is the arm that settles M2/M8 empirically instead of by argument.

Phase 5 — ADR-0049 amendments, before Stage 1 is designed. All of B7: constellation-as-unit with set_temporal fan-out through one shared helper; resolve_facets carrying the most-restrictive temporal spec; either persisted S/A/V slots or a lexical rule with resonance as a pre-filter; retain short attribute-value assertions as a named test case; facets inherit parent.created_at/observed_at/effective_at verbatim (never the backfill instant) with facet.created_at == parent.created_at in the committed round-trip fixture. Two tests: stamp the parent ⇒ every facet returns the floor; stamp a facet ⇒ the resolved parent fails is_current in the brief.

Phase 6 — L9 pre-registration, frozen before any detector code. B4 (ordered candidates, direction gate at 1.0), B5 (per-seed G5 liveness, 0.5-no-evidence convention, G2 as a hard floor, threshold-1.0 arm and timestamp-shuffled arm both asserted NOT_SUPPORTED), B6 (mandatory unstamped arm), M10 (six separately-scored G3 sub-controls; adversarial G4 corpus with shared subjects, shared attributes, shared value tokens, function-word-only differences, run as a threshold sweep gated on the G2/G4 bands overlapping by a non-degenerate margin), M11 (MIN over seeds, per-seed TSV rows, zero-FP absolute gate). Also: stamp the fixture by returned Uuid, never by content match (research.rs:4861-4866, :4879-4883, :4900-4904 currently key on m.content == text; chiral.rs:329-336 hands back the id, and hemisphere.rs:250-275 never dedups identical content), with a pre-gate fixture self-check that aborts the run if versions cannot be distinguished. Define the metric on the transitive reduction — true positive is (older, nearest-newer) only, and Stage 2 writes expires_at = min(observed_at over all newer candidates) so the value is order-independent; add a 4-version family so the case is exercised at all.

Phase 7 — Stage 1 build, then Stage 2 gated on Phase 6's numbers.

Also fold in wherever convenient: M7 (wire sanitization — but only together with any change that lets insert carry temporal fields, never after), M12 (assert the monotonicity claim; add centered-phase and post-dream probe cases), and the sync_medium_from_chiral metadata fabrication (hrm_store.rs:867-887 mints a fresh WavefrontMeta::new and patches back only id/frequency/phase, so the flat mirror permanently reports every memory unstamped and created at process start — clone the whole meta from chiral.right.metadata[i], and mandate in the ADR that the detector and L9 harness read chiral.right.metadata or memory_cache, never the flat mirror).