Crux Daemon · 10. The memory substrate

Everything the daemon remembers about your agents is a fact: an (entity, key, value) triple appended as one line of JSON to <data_dir>/facts.jsonl and replayed into HashMaps at boot. That is the whole storage model for memory. It is not the same store as the binary .ccxseg segment files under shards/, and conflating the two is the most common way to be wrong about this daemon.

This chapter is reference. It documents each mechanism, its flag and default, and what it does not do. Retrieval over these facts is chapter 11; sessions, cases and handoffs are chapter 12. Every flag named here has its full parsing rules in chapter 5, the process that owns these files is chapter 1, and anything found broken rather than merely narrow is in chapter 16.

10.0 In plain English

Every agent you run forgets everything the moment it stops. The fact store is the daemon's answer to that: a plain text file on your disk that agents write short, durable statements into and read back on their next run. Each statement has three parts, a subject, an attribute and a value, written as one line of JSON. ("execplan:docs-set", "gate:M7", "passed") is a fact. The nearest familiar thing is a lab notebook rather than a database: entries are appended in the order they happened, nothing is rewritten in place, and you can open the file in a text editor and read what your agents believed and when they came to believe it.

That boringness is deliberate and it is the feature. What you most want from a memory that outlives a crash is to be able to open it and see what happened, without needing the process that wrote it to still be running or still be healthy. One line of JSON per fact gets you that. Without such a store, every session starts from zero: an agent re-derives a conclusion it reached yesterday, contradicts a decision a sibling session made an hour ago, and you are the only integration layer holding it together.

There is a second store, and confusing the two is the most common way to be wrong about this daemon. Document and event content lives in binary segment files under shards/, written by different code in a different format with much stronger integrity machinery: magic numbers, per-frame hashes, checksummed manifests. Facts never go into a segment and segment events never go into facts.jsonl. Whenever you read a sentence about "the append-only store", it is ambiguous until it names which one, and §10.1 draws the line properly.

You will touch this chapter in four situations. When you decide what your agents should write down at all, because the store rewards deliberate, short statements. When recall is wrong and you want the raw record rather than the ranked answer. When you need to actually delete something, for a retention policy or a data-subject request, which is deletion followed by compaction and is covered in §10.6. And when you are deciding whether a fact should go stale over time, which is the horizon and freshness machinery in §10.10.

Two things people reliably get wrong. The first is assuming it searches like a search engine: it does not, and recall over facts is substring matching, which chapter 11 sets out in full. The second is assuming that "append-only" implies "durable". The default write path does not fsync, so a store_fact call that returned success can still be lost to a power failure. If you need a durability guarantee, take a filesystem snapshot; do not infer one from a 200. §10.4 gives the exact boundary.

10.1 There are two substrates, not one

The daemon has two independent persistence layers that share no code and no format.

Fact storeSegment store
Cratecorecrux-memory (fact_store.rs)corecrux-storage (lib.rs)
On disk<data_dir>/facts.jsonl, newline-delimited JSON<data_dir>/shards/segments/*.ccxseg: binary, LSM-shaped
Framinga newlinemagic numbers, fixed-size headers, per-frame BLAKE3, CRC-checked manifests
Who uses itstore_fact, query_facts, memory_view, sessions, cases, passports, coord intents, work itemsthe gRPC append/replay data plane and the query / query_scan / query_expand lane
Integrity storyJSON parseability, plus torn-tail quarantinemagic + CRC + BLAKE3 frame and payload hashes, verify-store --strict
StatusSHIPPEDSHIPPED

A fact is never written to a .ccxseg. A segment event is never in facts.jsonl. Any sentence about "the append-only store" is ambiguous until it names which one.

      store_fact / query_facts            query / query_scan / query_expand
              │                                        │
              ▼                                        ▼
      ┌───────────────────┐                  ┌────────────────────────┐
      │  FactStore        │                  │  segment readers       │
      │  facts.jsonl      │                  │  shards/*.ccxseg       │
      │  + in-memory maps │                  │  + .ccxi / .ccxv       │
      └───────────────────┘                  └────────────────────────┘
        substring match,                       BM25, optional dense
        decayed confidence                     re-rank, coverage report

The strong integrity story belongs to the segment substrate. Its constants are real and fixed: SEGMENT_MAGIC_CCS3 (lib.rs:44), FRAME_MAGIC_CRX1 (lib.rs:48), a 4096-byte segment header (lib.rs:54), a 256-byte footer (lib.rs:55), manifest framing MANIFEST_MAGIC_CCMF (storage/lib.rs:43) and crash-safe commit markers COMMIT_FRAME_MAGIC_CCMT (storage/lib.rs:58). Segment files are named segments/seg-{segment_seq:020}-{hex16}.ccxseg with in-progress heads as .ccxhead (append.rs:355).

The fact store's durability story is "newline-delimited JSON with torn-tail quarantine". That is a materially weaker guarantee, and §10.4 states exactly how much weaker.

10.2 The Fact record

Defined at fact_store.rs:169. Every field, with no omissions.

FieldTypeLineNotes
fact_idString:170f_ + UUIDv4, dashes stripped (:878)
tenant_hashString:174defaults to "default" (:267)
entityString:175the subject
keyString:176the attribute
valueString:177always a string, a JSON value is stringified by the caller
source_receiptOption<String>:178
confidencef32:179defaults to 1.0 (:400)
stored_atDateTime<Utc>:180transaction time
tokensusize:181estimate_tokens(&value) at store time (:879)
deletedbool:182soft-delete tombstone
versionu32:185monotonic per (entity, key), starts at 1
supersedesOption<String>:188the previous version's fact_id
privatebool:191narrower than the name suggests, see §10.7
horizon_classHorizonClass:198decay bucket
reverified_atOption<DateTime<Utc>>:203decay re-anchor
superseded_byOption<String>:213cross-entity retirement marker
actorOption<String>:222authorship; set only when CORECRUXD_AGENT_PASSPORTS is on
valid_fromOption<DateTime<Utc>>:231valid time start, inclusive
valid_toOption<DateTime<Utc>>:237valid time end, exclusive
access_countu32:248in-memory only, never journaled (:1042)
last_accessed_atOption<DateTime<Utc>>:252in-memory only

The write request type StoreFact (:288) is a strict subset. It cannot set valid_from / valid_to, build_fact hard-codes both to None (:918), and it cannot set superseded_by or version. Every fact is born with an unbounded valid-time interval. Declaring validity is always a second, deliberate call.

10.3 The journal format

One JournalEvent per line, a #[serde(tag = "op")] enum at fact_store.rs:35 with eight variants.

opPayloadLine
store{fact}:37
store_batch{facts: [...]}:39
delete{fact_id, deleted_at}:41
supersede{fact_id, by_fact_id, superseded_at}:44
clear_supersede{fact_id, cleared_at}:51
set_validity{fact_id, valid_from?, valid_to?, set_at}:56
consolidate{canonical, superseded_fact_ids, consolidated_at}:70
consolidate_undo{canonical_fact_id, restored_fact_ids, undone_at}:80

There is no magic number, no length prefix and no per-record checksum. Integrity rests entirely on JSON parseability, and a line that fails to parse is skipped with a warning while the replay continues (:855). That is a deliberate availability choice: a corrupted middle line silently drops the facts it carried and the daemon boots healthy.

10.4 Durability: what "append-only" does and does not buy you

The default write path does not fsync. append_journal (:751) opens the file in append mode, writes one line and returns. append_journal_durable (:765) additionally calls file.sync_all() and fsyncs the parent directory, and only try_store_bulk_durable uses it (:1208).

Everything else, store, try_store, store_bulk, try_store_bulk, delete, try_delete, mark_superseded, clear_superseded, set_validity, consolidate_facts_v1, consolidate_undo_v1, takes the non-fsyncing path. A store_fact that returned success can be lost to a power failure. If you need a durability guarantee, take a filesystem snapshot; do not infer one from a 200.

There is a second, sharper edge. store mutates the in-memory maps before appending, and only logs a warning if the append fails (:1143), so store() can leave a fact live in memory that was never written to disk at all. try_store (:1152) appends first and propagates the error. Prefer the try_ variants in any code you write against the crate.

Torn-tail recovery is real. Before every append, repair_torn_journal_tail (:497) checks whether the file's last byte is a newline. If not it walks back to the last newline, copies the unterminated tail into a quarantine file facts.jsonl.torn.<uuid> at mode 0600 on Unix (:544), truncates the journal, and fsyncs both the file and the directory (:547). Even parseable JSON in that tail is quarantined rather than committed, the reasoning at :493 is that the append never completed its delimiter and so may never have returned success to a caller. A tail larger than MAX_RECOVERY_TAIL_BYTES (64 MiB, :530) is a hard error rather than a quarantine.

Three mutations are not journaled at all.

  • set_horizon (:936), a horizon-class override is lost on restart.
  • reverify (:950), a re-verification anchor is lost on restart, despite reverified_at being a serialised field.
  • record_access (:1052), documented as intentional at :1048.

Only the third is documented as deliberate. The plain statement for an operator: memory_set_horizon and memory_reverify do not survive a daemon restart unless a compaction happens to run in between, because compaction re-serialises live facts from memory (§10.6). set_validity explicitly contrasts itself against them (:1010), validity is journaled because it is "a durable historical claim".

10.5 How a store_fact becomes bytes

The MCP path, in order. Read it once and the rest of the chapter's edge cases follow.

  1. handle_store_fact (facts.rs:113) validates that entity, key and value are present strings.
  2. Daemon-owned reserved prefixes are rejected outright (facts.rs:117, list at fact_privacy.rs:144).
  3. If private: true and an agent identity is present, the entity is rewritten to __agent::{agent_name}::{entity} (scope.rs:19).
  4. FactStore::store / try_store calls fact_privacy::enforce_global first (fact_store.rs:1140), which forces private = true for reserved prefixes regardless of what the caller asked for.
  5. build_fact (:877) allocates the fact_id, computes tokens, resolves the version chain from key_index, resolves horizon_class, and stamps stored_at = Utc::now().
  6. insert_fact_indexes (:1068) then the journal append, in that order for store, the reverse for try_store.
  7. supersede_prior_version (:1176) marks the predecessor as superseded by the new fact, so recall is latest-version-wins by default.
  8. after_fact_stored (:1080) embeds the fact if an embedder is configured, runs near-duplicate detection if enabled, and emits a FactStored event on the bus.

10.6 Indexes, boot cost and compaction

Four in-memory maps hang off FactStore (:456): facts by fact_id; entity_index from entity to fact ids; key_index from (entity, key) to an ordered version chain; and embeddings from fact id to vector.

None of them is persisted. All are rebuilt by replaying the journal at boot (:730, :782). There is no on-disk index over facts, so boot time and steady-state memory are both linear in journal size, and embeddings is uncapped by design (:1093).

Compaction is what bounds the journal, and it is also the erasure primitive. compact_journal (:1787) refuses outright if any deleted fact is covered by an active legal hold. compact_journal_after_legal_hold_override_receipt (:1811) is the escape hatch and requires a legal_hold_overridden receipt that enumerates every blocked fact and hold. compact_journal_unchecked (:1839) then partitions live from deleted, sorts live by (stored_at, fact_id) for reproducibility, writes a fresh journal to a .tmp file, flushes, sync_all()s, renames atomically and fsyncs the directory.

Two consequences matter operationally.

Compaction destroys deleted content. For each deleted fact it writes a value-free delete tombstone, the original store event carrying the value is not copied forward (:1929). Superseded versions survive, because they are distinct live fact_ids, so version history is preserved. mark_retention_eligible (:1951) soft-deletes facts older than a cutoff, skipping private facts, __sync_tombstone__:: records and legal-hold-covered facts, and the operator then compacts to actually remove content. This is the intended GDPR and retention primitive. "Nothing is ever destroyed" is true of consolidation and false of the store as a whole.

Compaction makes ephemeral state durable. Because it re-serialises the in-memory Fact structs, a non-journaled set_horizon or reverify becomes durable if compaction happens to run, and a non-zero access_count gets written to disk (it is skipped only when zero, :247). An explicitly ephemeral ranking signal can therefore become permanent as a side effect of maintenance.

10.7 private = true: what it actually does

private is primarily a sync-push exclusion flag, not a read ACL. The module doc says so (fact_privacy.rs:6) and so does the field comment: "Private facts are never pushed to a remote during sync" (fact_store.rs:189).

Every place the flag is consulted:

Enforcement pointSourceEffect
Sync push filtersync.rs:360excluded from remote push
MCP visibility scopescope.rs:32visible_entity_for_agent returns None
Console fact listconsole.rs:2881filtered out of the console view
aggregate_v1fact_store.rs:1508excluded from deterministic aggregates
exportfact_store.rs:1632excluded
list_pagefact_store.rs:1711excluded
Retention sweepfact_store.rs:1956private facts are never retention-deleted
Consolidation guardfact_store.rs:2096private targets rejected as TargetPrivate
.cruxpack exportcruxpack.rs:316excluded unless include_private

And the place it is not consulted: FactStore::query_inner (:1362), the store's own query path, applies no private filter at all. Neither do get, get_by_entity, all_facts or fact_history, which is exactly why they carry an "internal / admin only" warning (:1269).

The model to hold: private facts are hidden from the MCP recall surfaces and from sync and export by filters applied at the call layer, not by the store. Per-agent ownership comes from the __agent::<owner>:: entity-prefix scheme (scope.rs:19), and visibility is owning-identity-only. Group sharing is explicitly deferred and not implemented (scope.rs:52).

An anonymous caller, no CRUX_AGENT_TOKENS configured, which is the default local posture, sees no private facts at all, because visible_entity_for_agent(fact, None) returns None for any private fact (scope.rs:32).

Born-private prefixes

DEFAULT_PRIVATE_PREFIXES (fact_privacy.rs:91) is a 37-entry list that forces private = true at store time whatever the caller asked for. It covers __agent::, __ops::, __ax__::, __work__::, __passport__::, __coord__::, __incident__::, __legal_hold__::, __candidate_fact__::, decisions:: and github:: among others. Operators extend it with CORECRUXD_ALWAYS_PRIVATE_PREFIXES and subtract with CORECRUXD_SHARE_PREFIXES_OVERRIDE (:179). The policy is a process-global OnceLock, first writer wins, and it cannot be changed at runtime (:60).

A stricter list, DAEMON_OWNED_ENTITY_PREFIXES (:144), __legal_hold__::, __legal_hold_receipt__::, __incident__::, __mint_request__::, is rejected at client write boundaries. The core store deliberately does not enforce it, because the daemon's own governance flows write through it (:138).

A drift-guard test (:370) asserts every born-private prefix is also in CRUXPACK_RESERVED_PREFIXES, so adding a prefix cannot silently make facts exportable.

10.8 Tenancy

tenant_hash is a plain string defaulting to "default" (:267). Daemon and MCP handlers overwrite it from the authenticated context before storage (:289), so a JSON caller cannot set it to another tenant.

Filtering, however, is opt-in per query. FactQuery.tenant_hash is an Option<String>, and None "preserves the historical single-tenant result set" (:409), which means it returns every tenant's facts. The _for_tenant accessors (:1274, :1292, :1609) are the safe variants. Isolation in the fact store depends on callers choosing them.

The segment lane's tenancy is stronger and unrelated: a 64-bit xxh64 hash checked in full, with lo16 used only as a fast precheck (bm25.rs:25), and a deliberate collision-isolation test (fused.rs:317).

10.9 Bi-temporal as_of

Status: SHIPPED, valid-time only.

Two time axes exist. Transaction time is stored_at (:180), when this node learned the fact; always set. Valid time is the half-open interval [valid_from, valid_to) (:231, :237), when the fact was true in the world; both ends optional, both defaulting to open. Fact::valid_at (:279) is instant >= valid_from && instant < valid_to, with an open bound always satisfied.

set_validity (:1016) is the only writer. It journals the event before mutating (:1025), though it only logs a warning if the append fails and then mutates anyway (:1030).

Queries take one of two paths: query_as_of / try_query_as_of at the store level (:1332), or handle_query_facts on the MCP side, which parses as_of as RFC 3339 and rejects an unparseable value rather than silently dropping the filter (facts.rs:453).

What as_of cannot do

This is a genuine bi-temporal valid-time filter, correctly half-open. It is not a time machine. Five limits, all of which a reader will otherwise assume away:

  1. It cannot travel in transaction time. Nothing filters on stored_at <= as_of. A fact learned yesterday about a state that held last year is returned by an as_of query for last year. That is correct bi-temporal semantics, and it means as_of answers "what do we now believe was true then", not "what did we believe then". The doc comment at :1329 claims the latter and overstates the code.
  2. It cannot see through deletion. query_inner filters !deleted before the as_of filter (:1368), as does the MCP path (facts.rs:729). A soft-deleted fact is invisible at every instant, and after compaction its value is gone from disk.
  3. It cannot see through supersession by default. The MCP path excludes superseded_by-marked facts unless include_superseded: true (facts.rs:731). Since every re-store retires the previous version, a default as_of query returns only the current version even when an older version is the one whose interval contains the instant.
  4. It reconstructs nothing for facts that never had validity set. With both bounds None, the default for essentially every fact, since no store path sets them, every fact matches every instant. On a store where nobody calls set_validity, as_of is a no-op.
  5. It cannot recover a lost validity edit. set_validity journals without fsync and warns-then-mutates on append failure.

The honest one-line summary: as_of is a valid-time filter over facts that have explicitly declared a validity interval. It is not a snapshot of the store at a past moment.

10.10 Freshness decay and horizon classes

Status: FLAG CORECRUXD_FEATURE_FRESHNESS, default ON (opt-out), for the freshness tools. The recall-time demotion is applied unconditionally. The salience input is FLAG CORECRUXD_MEMORY_SALIENCE, default OFF.

The classes

HorizonClass (fact_store.rs:97), mirrored in the pure projection crate as decay.rs:63 to avoid a runtime dependency.

ClassDefault thresholdIntended for
volatile24 hoursdeploy state, PIDs, daily-changing metrics
medium35 daysper-tenant counts, preferences, traits
stable365 daysarchitectural counts, naming conventions, layout decisions
nonenever decaysidentity, immutable history, pinned facts

Default is none (:159) so pre-freshness journal entries replay as never-stale. If a caller supplies no class, default_for_entity (:141) infers one from the entity prefix: __ops:: becomes volatile; bench: and __bootstrap__:: become stable; execplan:, incident: and __candidate_fact__:: become medium; everything else becomes none. The default for an ordinary user fact is therefore never decays.

A free-text parser, parse_freshness_horizon (facts.rs:59), handles a freshness_horizon: string instead of a class. It is keyword-based and falls back to none for anything it does not recognise (facts.rs:94).

The decay function

Thresholds come from DecayPolicy (decay.rs:92): constants 24 hours, 35 days and 365 days (decay.rs:100), overridable per process by CORECRUXD_DECAY_VOLATILE_HOURS, CORECRUXD_DECAY_MEDIUM_DAYS and CORECRUXD_DECAY_STABLE_DAYS (decay.rs:117); a non-positive or unparseable value falls back to the default.

The core function is apply_at_salient (decay.rs:180):

if class == none                   -> Fresh
if written_ms <= 0 || now_ms <= 0  -> Unknown
if written_ms > now_ms             -> Unknown        (clock skew)

age_ms       = now_ms - written_ms
threshold_ms = per_class_threshold_ms * salience_factor(access_count)
Freshness    = if age_ms > threshold_ms { Stale } else { Fresh }

The comparison is boundary-inclusive: at exactly 24 hours a volatile fact is still Fresh (test at decay.rs:311). apply_at_chrono_salient (decay.rs:243) prefers reverified_at over stored_at, so a re-verified fact restarts its clock.

The salience multiplier, and why it is inert

salience_factor(n) = clamp(1 + 0.25 * ln(n + 1), 1.0, 4.0)

At decay.rs:168. salience_factor(0) == 1.0 exactly, which is the load-bearing backward-compatibility invariant (decay.rs:161). It is monotonically non-decreasing and bounded, so salience can only extend a fresh window, never shorten one.

CORECRUXD_MEMORY_SALIENCE is default OFF (facts.rs:25). When it is off, record_access is never called (facts.rs:503), so access_count stays 0 for every fact and salience_factor is always exactly 1.0. In the default configuration the salience multiplier is mathematically inert; it multiplies every threshold by one. The stated rationale is that recording an access takes a write lock on an otherwise read-only path (facts.rs:499).

Where decay enters ranking

Through effective_confidence (decay.rs:281):

FreshnessEffect on stored confidence
Freshunchanged
Unknownunchanged, never punish what cannot be proven stale
Stalemultiplied by STALE_DEMOTION_FACTOR = 0.5 (decay.rs:264)

STALE_DEMOTION_FACTOR is deliberately a fixed constant rather than env-tunable, so ordering is deterministic across machines (decay.rs:259).

The call site is query_visible_facts_opts_as_of (facts.rs:757), which sorts by effective confidence descending with stored_at descending as the tiebreak. Stored confidence is never mutated; this is purely a sort key (facts.rs:750). Every query_facts row surfaces effective_confidence alongside the raw confidence, plus freshness and age_hours (facts.rs:553). A caller can set a floor with min_effective_confidence, validated to 0.0..=1.0 (facts.rs:431), and the response reports filtered_below_threshold so "nothing matched" is distinguishable from "everything was below the floor" (facts.rs:775).

Freshness flags

FlagDefaultEffect
CORECRUXD_FEATURE_FRESHNESSon (opt-out)Only an explicit "", 0, false, off or no disables it (freshness.rs:46). Disabling returns CAPABILITY_DENIED from memory_freshness, memory_set_horizon and memory_reverify (freshness.rs:67). It gates those tools only, the ranking demotion still applies.
CORECRUXD_MEMORY_SALIENCEoffAccepts 1, true, yes, on. Off means access_count stays 0 for every fact and the multiplier is always 1.0 (facts.rs:25).
CORECRUXD_DECAY_VOLATILE_HOURS24volatile threshold
CORECRUXD_DECAY_MEDIUM_DAYS35medium threshold
CORECRUXD_DECAY_STABLE_DAYS365stable threshold

10.11 Contradiction detection

Status: FLAG CORECRUXD_FEATURE_CONSOLIDATION, default ON (opt-out). The detector is a fixed word list.

contradiction_candidates_v1 (fact_store.rs:2021) is read-only and emits candidates, not decisions (:2014). It iterates all facts, skipping deleted and superseded rows; skips any fact whose value has no deterministic polarity class; groups by (entity, key); buckets by polarity; and where a group holds at least two distinct polarities emits a candidate with reason = "opposite_polarity_same_entity_key".

The polarity classifier is a hard-coded word list, not a model. polarity_class_v1 (fact_store.rs:2421) trims non-alphanumerics, lowercases, and matches the whole value against 27 literal tokens:

positive (14): true yes y on enabled enable active complete completed
               passed pass approved approve present

negative (13): false no n off disabled disable inactive blocked failed
               fail rejected reject absent

That is the entire detector. It fires only when a fact's whole value is one of those 27 strings. What it does not detect:

  • "deployed to prod" versus "rolled back", no polarity class, no candidate.
  • "3" versus "7", no polarity class, no candidate.
  • Any two free-text values disagreeing under the same (entity, key).
  • The common case at all: because supersede_prior_version retires the previous version on every re-store (:1176), simply updating a value never produces a candidate.

It is a boolean-polarity conflict detector. Used for what it is, catching a deployed: true sitting beside a deployed: false; it works and it is deterministic and free. Read as "the daemon notices when my agents contradict each other", it will disappoint.

The MCP surface is memory_contradictions (consolidation.rs, dispatch at mod.rs:2909), documented read-only with dry_run: true always present in the response (mod.rs:2754) and a token_budget default of 500.

10.12 Consolidation and supersession

Two supersession concepts share a name and are easy to conflate.

ConceptFieldScopeSet by
Version chainsupersedes + version (:185)within one (entity, key)automatically, by build_fact (:882)
Retirement markersuperseded_by (:213)cross-entitymark_superseded (:970), cleared by clear_superseded (:992)

Both are journaled, and the retirement marker is explicitly "reversible soft-state, never hard-deletes the target" (:963).

consolidate_facts_v1 (:2077) is the safe resolve pass. It refuses before mutating anything on any of eight guards (:378):

GuardLineRejects
NoTargets:2085an empty target list
TargetNotFound:2091an unknown fact_id
TargetDeleted:2093a soft-deleted target
TargetPinned:2096anything in caller-supplied protected_fact_ids
TargetPrivate:2099private == true
TargetReceiptLinked:2102source_receipt.is_some()
TargetHighConfidence:2105confidence >= protected_confidence_floor, default 0.99 (:346)
TargetOutsideEntityKey:2111a target outside the requested (entity, key)

It then builds the canonical fact without storing it, computes canonical_hash = "blake3:" + hex(blake3(canonical_value)) (:2119), unions the explicit targets with the canonical's own prior version, and performs one journal append as the transactional commit point (:2152). On append failure nothing is mutated and the error propagates; this is one of the few paths in the store that does not warn-and-continue. In-memory state is applied afterwards, mirroring the replay handler exactly (:2163).

What "reversible" concretely means

consolidate_undo_v1 (:2184) refuses on an unknown canonical, returns status: "already_undone" idempotently if the canonical is already deleted (:2192), restores only sources whose superseded_by points at this canonical (:2201), commits with a single journal append (:2214), then soft-deletes the canonical and clears the retirement marker on each restored source.

So "reversible" means the generated canonical is soft-deleted and the sources' retirement markers are cleared. The sources' values were never touched at any point. Consolidation genuinely never destroys its inputs, fact_history (:2003) and all_facts (:1605) still return them, and memory_view or include_superseded: true surfaces them.

Deletion plus compaction does destroy content, as §10.6 sets out. Both statements are true; neither is the whole picture on its own.

Consolidation flags

FlagDefaultEffect
CORECRUXD_FEATURE_CONSOLIDATIONon (opt-out)Gates memory_contradictions and memory_consolidate; only "", 0, false, off, no disable (consolidation.rs:47)
CORECRUXD_CONSOLIDATION_SCHEDULERoffconfig.rs:1328. config.example.env states it "only detects and surfaces candidates; it never auto-resolves, supersedes, or deletes"
CORECRUXD_CONSOLIDATION_SCHEDULER_INTERVAL_SECS3600clamped to 60..=86400 (config.rs:1329)
CORECRUXD_SEMANTIC_DEDUPoffWith an embedder configured, detect_near_duplicate (fact_store.rs:650) flags high-cosine pairs as review candidates. It never mutates or drops a fact, "dedup is advisory review, not silent deletion" (:1100)
CORECRUXD_SEMANTIC_DEDUP_THRESHOLD0.95cosine threshold (config.rs:485)

10.13 Status summary

CapabilityStatus
Fact store: journal, replay, torn-tail quarantine, compaction, legal-hold guardSHIPPED
fsync on the default write pathNot present, only try_store_bulk_durable fsyncs
set_horizon / reverify durabilityNot present, not journaled; lost on restart
private as a store-level ACLNot present, call-layer filters only
Tenant filtering by defaultNot applied: tenant_hash: None returns every tenant
Bi-temporal as_ofSHIPPED, valid-time only; a no-op where set_validity is never called
Freshness decayFLAG CORECRUXD_FEATURE_FRESHNESS, default on; recall-time demotion unconditional
Salience multiplierFLAG CORECRUXD_MEMORY_SALIENCE, default off, inert
Contradiction detectionFLAG CORECRUXD_FEATURE_CONSOLIDATION, default on; a 27-token polarity matcher
Consolidation schedulerFLAG CORECRUXD_CONSOLIDATION_SCHEDULER, default off
Consolidation atomicity and reversibilitySHIPPED, genuinely both
Semantic near-duplicate detectionFLAG CORECRUXD_SEMANTIC_DEDUP, default off, advisory only

Sources