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 store | Segment store | |
|---|---|---|
| Crate | corecrux-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 |
| Framing | a newline | magic numbers, fixed-size headers, per-frame BLAKE3, CRC-checked manifests |
| Who uses it | store_fact, query_facts, memory_view, sessions, cases, passports, coord intents, work items | the gRPC append/replay data plane and the query / query_scan / query_expand lane |
| Integrity story | JSON parseability, plus torn-tail quarantine | magic + CRC + BLAKE3 frame and payload hashes, verify-store --strict |
| Status | SHIPPED | SHIPPED |
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.
| Field | Type | Line | Notes |
|---|---|---|---|
fact_id | String | :170 | f_ + UUIDv4, dashes stripped (:878) |
tenant_hash | String | :174 | defaults to "default" (:267) |
entity | String | :175 | the subject |
key | String | :176 | the attribute |
value | String | :177 | always a string, a JSON value is stringified by the caller |
source_receipt | Option<String> | :178 | |
confidence | f32 | :179 | defaults to 1.0 (:400) |
stored_at | DateTime<Utc> | :180 | transaction time |
tokens | usize | :181 | estimate_tokens(&value) at store time (:879) |
deleted | bool | :182 | soft-delete tombstone |
version | u32 | :185 | monotonic per (entity, key), starts at 1 |
supersedes | Option<String> | :188 | the previous version's fact_id |
private | bool | :191 | narrower than the name suggests, see §10.7 |
horizon_class | HorizonClass | :198 | decay bucket |
reverified_at | Option<DateTime<Utc>> | :203 | decay re-anchor |
superseded_by | Option<String> | :213 | cross-entity retirement marker |
actor | Option<String> | :222 | authorship; set only when CORECRUXD_AGENT_PASSPORTS is on |
valid_from | Option<DateTime<Utc>> | :231 | valid time start, inclusive |
valid_to | Option<DateTime<Utc>> | :237 | valid time end, exclusive |
access_count | u32 | :248 | in-memory only, never journaled (:1042) |
last_accessed_at | Option<DateTime<Utc>> | :252 | in-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.
op | Payload | Line |
|---|---|---|
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, despitereverified_atbeing 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.
handle_store_fact(facts.rs:113) validates thatentity,keyandvalueare present strings.- Daemon-owned reserved prefixes are rejected outright (facts.rs:117, list at fact_privacy.rs:144).
- If
private: trueand an agent identity is present, the entity is rewritten to__agent::{agent_name}::{entity}(scope.rs:19). FactStore::store/try_storecallsfact_privacy::enforce_globalfirst (fact_store.rs:1140), which forcesprivate = truefor reserved prefixes regardless of what the caller asked for.build_fact(:877) allocates thefact_id, computestokens, resolves the version chain fromkey_index, resolveshorizon_class, and stampsstored_at = Utc::now().insert_fact_indexes(:1068) then the journal append, in that order forstore, the reverse fortry_store.supersede_prior_version(:1176) marks the predecessor as superseded by the new fact, so recall is latest-version-wins by default.after_fact_stored(:1080) embeds the fact if an embedder is configured, runs near-duplicate detection if enabled, and emits aFactStoredevent 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 point | Source | Effect |
|---|---|---|
| Sync push filter | sync.rs:360 | excluded from remote push |
| MCP visibility scope | scope.rs:32 | visible_entity_for_agent returns None |
| Console fact list | console.rs:2881 | filtered out of the console view |
aggregate_v1 | fact_store.rs:1508 | excluded from deterministic aggregates |
export | fact_store.rs:1632 | excluded |
list_page | fact_store.rs:1711 | excluded |
| Retention sweep | fact_store.rs:1956 | private facts are never retention-deleted |
| Consolidation guard | fact_store.rs:2096 | private targets rejected as TargetPrivate |
.cruxpack export | cruxpack.rs:316 | excluded 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:
- 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 anas_ofquery for last year. That is correct bi-temporal semantics, and it meansas_ofanswers "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. - It cannot see through deletion.
query_innerfilters!deletedbefore theas_offilter (: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. - It cannot see through supersession by default. The MCP path excludes
superseded_by-marked facts unlessinclude_superseded: true(facts.rs:731). Since every re-store retires the previous version, a defaultas_ofquery returns only the current version even when an older version is the one whose interval contains the instant. - 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 callsset_validity,as_ofis a no-op. - It cannot recover a lost validity edit.
set_validityjournals 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.
| Class | Default threshold | Intended for |
|---|---|---|
volatile | 24 hours | deploy state, PIDs, daily-changing metrics |
medium | 35 days | per-tenant counts, preferences, traits |
stable | 365 days | architectural counts, naming conventions, layout decisions |
none | never decays | identity, 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):
| Freshness | Effect on stored confidence |
|---|---|
Fresh | unchanged |
Unknown | unchanged, never punish what cannot be proven stale |
Stale | multiplied 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
| Flag | Default | Effect |
|---|---|---|
CORECRUXD_FEATURE_FRESHNESS | on (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_SALIENCE | off | Accepts 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_HOURS | 24 | volatile threshold |
CORECRUXD_DECAY_MEDIUM_DAYS | 35 | medium threshold |
CORECRUXD_DECAY_STABLE_DAYS | 365 | stable 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_versionretires 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.
| Concept | Field | Scope | Set by |
|---|---|---|---|
| Version chain | supersedes + version (:185) | within one (entity, key) | automatically, by build_fact (:882) |
| Retirement marker | superseded_by (:213) | cross-entity | mark_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):
| Guard | Line | Rejects |
|---|---|---|
NoTargets | :2085 | an empty target list |
TargetNotFound | :2091 | an unknown fact_id |
TargetDeleted | :2093 | a soft-deleted target |
TargetPinned | :2096 | anything in caller-supplied protected_fact_ids |
TargetPrivate | :2099 | private == true |
TargetReceiptLinked | :2102 | source_receipt.is_some() |
TargetHighConfidence | :2105 | confidence >= protected_confidence_floor, default 0.99 (:346) |
TargetOutsideEntityKey | :2111 | a 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
| Flag | Default | Effect |
|---|---|---|
CORECRUXD_FEATURE_CONSOLIDATION | on (opt-out) | Gates memory_contradictions and memory_consolidate; only "", 0, false, off, no disable (consolidation.rs:47) |
CORECRUXD_CONSOLIDATION_SCHEDULER | off | config.rs:1328. config.example.env states it "only detects and surfaces candidates; it never auto-resolves, supersedes, or deletes" |
CORECRUXD_CONSOLIDATION_SCHEDULER_INTERVAL_SECS | 3600 | clamped to 60..=86400 (config.rs:1329) |
CORECRUXD_SEMANTIC_DEDUP | off | With 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_THRESHOLD | 0.95 | cosine threshold (config.rs:485) |
10.13 Status summary
| Capability | Status |
|---|---|
| Fact store: journal, replay, torn-tail quarantine, compaction, legal-hold guard | SHIPPED |
fsync on the default write path | Not present, only try_store_bulk_durable fsyncs |
set_horizon / reverify durability | Not present, not journaled; lost on restart |
private as a store-level ACL | Not present, call-layer filters only |
| Tenant filtering by default | Not applied: tenant_hash: None returns every tenant |
Bi-temporal as_of | SHIPPED, valid-time only; a no-op where set_validity is never called |
| Freshness decay | FLAG CORECRUXD_FEATURE_FRESHNESS, default on; recall-time demotion unconditional |
| Salience multiplier | FLAG CORECRUXD_MEMORY_SALIENCE, default off, inert |
| Contradiction detection | FLAG CORECRUXD_FEATURE_CONSOLIDATION, default on; a 27-token polarity matcher |
| Consolidation scheduler | FLAG CORECRUXD_CONSOLIDATION_SCHEDULER, default off |
| Consolidation atomicity and reversibility | SHIPPED, genuinely both |
| Semantic near-duplicate detection | FLAG CORECRUXD_SEMANTIC_DEDUP, default off, advisory only |
Sources
- crates/corecrux-memory/src/fact_store.rs:169, the
Factrecord - crates/corecrux-memory/src/fact_store.rs:35,
JournalEvent - crates/corecrux-memory/src/fact_store.rs:751,
append_journal, no fsync - crates/corecrux-memory/src/fact_store.rs:497,
repair_torn_journal_tail - crates/corecrux-memory/src/fact_store.rs:1839,
compact_journal_unchecked - crates/corecrux-memory/src/fact_store.rs:2421,
polarity_class_v1, the 27-token list - crates/corecrux-memory/src/fact_store.rs:2077,
consolidate_facts_v1 - crates/corecrux-memory/src/fact_privacy.rs:91,
DEFAULT_PRIVATE_PREFIXES - crates/corecrux-projections/src/decay.rs:180,
apply_at_salient - crates/crux-mcp/src/scope.rs:19, the
__agent::private-entity scheme - crates/crux-mcp/src/tools/facts.rs:113,
handle_store_fact - crates/corecrux-segment/src/lib.rs:44, segment magic numbers

