Skip to main content

lunaris/
structured_ingest.rs

1//! Phase 23 — agent-facing structured ingest.
2//!
3//! Lets an AI agent (or any caller that already knows the entity / relation
4//! structure of a message) bypass the LLM extractor and write the graph
5//! directly while still riding the same INGEST-04 single-`atomic_write`
6//! invariant and the same deterministic `EntityId = blake3(name+type)[..16]`
7//! dedup as the extractor-produced path.
8//!
9//! # Why this exists
10//!
11//! Many agents already produce structured `{entities, relations, facts}` as
12//! a side-effect of their own reasoning. Round-tripping that knowledge as
13//! prose through Lunaris's GBNF-constrained extractor pays two LLM passes
14//! (extract here + verify downstream) and loses fidelity. This entry point
15//! takes the structured payload directly.
16//!
17//! # Determinism = no lookup
18//!
19//! Because [`EntityId`] is the 16-byte truncation of
20//! `blake3(normalize(canonical_name) || "::" || entity_type)`, an agent
21//! that ingests
22//!
23//! ```text
24//! RelationInput { subject_name: "Alice", subject_type: "Person",
25//!                 predicate: "reports_to",
26//!                 object_name: "Bob",   object_type: "Person", ... }
27//! ```
28//!
29//! produces the **same** subject/object EntityIds whether the underlying
30//! `Alice (Person)` node was created earlier by an LLM-extracted ingest,
31//! a prior structured ingest, or this very call. The graph storage layer
32//! dedups by key, so re-asserting an existing entity is a no-op and the
33//! new edge attaches to the existing node — no GET-then-PUT round trip,
34//! no race window.
35//!
36//! # Toggle gating
37//!
38//! Unlike the LLM extractor path, [`StructuredIngest`] **always** writes
39//! the graph regardless of `LUNARIS_GRAPH_ENABLED` / `graph_pipeline()
40//! .is_enabled()`. Rationale: the agent explicitly supplied entities —
41//! they are not best-effort extraction. The pipeline toggle continues to
42//! gate ONLY the LLM-extractor branch.
43//!
44//! # What this writes
45//!
46//! In one `atomic_write` per call:
47//!
48//! - Episode KV row (same shape as text ingest).
49//! - Per-chunk KV + `VectorUpsert` (text chunked + embedded just like the
50//!   text-ingest path; BM25 indexing piggybacks on the `content` metadata
51//!   field).
52//! - Per-entity `GraphNode` + `VectorUpsert{entities}`. The entity vector
53//!   uses the caller's optional [`EntityInput::embedding`] when supplied;
54//!   otherwise the handle's current `Embedder` embeds the entity name.
55//! - Per-relation `GraphEdge` with `source_episode_id` stamped into the
56//!   props.
57//! - Per-fact KV + `VectorUpsert{facts}` (fact text embedded via the
58//!   handle's `Embedder`).
59//!
60//! Provenance carried on edges/facts is **episode-level only** in v0.3:
61//! `source_episode_id`. Per-chunk attribution (`source_chunk_id`) and
62//! chunk-MENTIONS-entity edges land in a follow-up phase.
63
64use chrono::{DateTime, Utc};
65use serde::{Deserialize, Serialize};
66use serde_json::json;
67use ulid::Ulid;
68
69use std::collections::HashMap;
70
71use lunaris_core::keyspace::{chunk_key, episode_key, fact_key as scoped_fact_key, fact_spo_key};
72use lunaris_core::{
73    Chunk, Embedder, Hlc, HlcClock, Lsn, LunarisError, Scope, StorageError, StoragePort, WriteOp,
74    sanitize_graph_ident,
75};
76use lunaris_extract::types::{EntityId, Fact, FactId};
77use lunaris_extract::validator::{NeedsReviewItem, NeedsReviewReason};
78use lunaris_ingest::chunk_markdown;
79
80use crate::episode_builder::EpisodeBuilder;
81use crate::reconcile::{FactDecision, FactTriple, SpoEntry, classify_fact};
82
83// Index names + graph name kept in sync with the LLM-extracted path in
84// `crate::ingest`. Same string constants, kept private to this module so a
85// refactor that moves them to a shared place can flip both call sites
86// together.
87const CHUNK_VECTOR_INDEX: &str = "chunks";
88const ENTITIES_INDEX: &str = "entities";
89const FACTS_INDEX: &str = "facts";
90const GRAPH_NAME: &str = "lunaris_graph";
91
92// Mirrors `lunaris_ingest::pipeline::DEFAULT_TARGET_TOKENS` /
93// `DEFAULT_OVERLAP_TOKENS`. Inlined to avoid widening the
94// `lunaris_ingest::pipeline` public surface; both numbers are stable across
95// the chunker contract.
96const DEFAULT_TARGET_TOKENS: usize = 256;
97const DEFAULT_OVERLAP_TOKENS: usize = 32;
98
99/// Default confidence for agent-supplied items. Agents that omit
100/// confidence are taken at their word — they presumably know what they
101/// asserted.
102fn default_confidence() -> f32 {
103    1.0
104}
105
106/// Agent-supplied entity. See module docs for the EntityId derivation
107/// contract — the `(name, entity_type)` pair is the source of truth for
108/// node identity; supplying a different alias for the same entity does
109/// **not** create a new node.
110#[derive(Clone, Debug, Serialize, Deserialize)]
111pub struct EntityInput {
112    pub name: String,
113    pub entity_type: String,
114    #[serde(default)]
115    pub aliases: Vec<String>,
116    #[serde(default = "default_confidence")]
117    pub confidence: f32,
118    pub valid_from: DateTime<Utc>,
119    #[serde(default)]
120    pub valid_to: Option<DateTime<Utc>>,
121    /// Optional caller-supplied entity embedding. When `Some`, MUST match
122    /// the handle's [`Embedder::dim`] — a mismatch surfaces as a
123    /// `StorageError::Backend` at ingest time so the operator can correct
124    /// the wheel build rather than silently corrupting the vector index.
125    /// When `None`, Lunaris embeds [`Self::name`] via the handle's
126    /// `Embedder`.
127    #[serde(default)]
128    pub embedding: Option<Vec<f32>>,
129}
130
131/// Agent-supplied relation. Both endpoints are addressed by
132/// `(name, entity_type)` — see module docs.
133#[derive(Clone, Debug, Serialize, Deserialize)]
134pub struct RelationInput {
135    pub subject_name: String,
136    pub subject_type: String,
137    pub predicate: String,
138    pub object_name: String,
139    pub object_type: String,
140    #[serde(default = "default_confidence")]
141    pub confidence: f32,
142    pub valid_from: DateTime<Utc>,
143    #[serde(default)]
144    pub valid_to: Option<DateTime<Utc>>,
145}
146
147/// Agent-supplied fact. `fact_text` is the natural-language rendering of
148/// the `(subject, predicate, object)` triple; it is what gets embedded for
149/// fact vector recall.
150#[derive(Clone, Debug, Serialize, Deserialize)]
151pub struct FactInput {
152    pub fact_text: String,
153    pub subject_name: String,
154    pub subject_type: String,
155    pub predicate: String,
156    pub object_name: String,
157    pub object_type: String,
158    #[serde(default = "default_confidence")]
159    pub confidence: f32,
160    pub valid_from: DateTime<Utc>,
161    #[serde(default)]
162    pub valid_to: Option<DateTime<Utc>>,
163}
164
165/// Top-level payload for [`crate::Lunaris::ingest_structured`] /
166/// [`crate::ScopedLunaris::ingest_structured`].
167///
168/// `episode` carries the conversation-turn text (chunked + embedded like
169/// the text-ingest path); the three vectors carry the agent's structured
170/// knowledge. Any subset may be empty — an episode with only entities,
171/// only relations, or no graph payload at all is valid (the latter
172/// degenerates to a vanilla text ingest with the graph pipeline off).
173pub struct StructuredIngest {
174    pub episode: EpisodeBuilder,
175    pub entities: Vec<EntityInput>,
176    pub relations: Vec<RelationInput>,
177    pub facts: Vec<FactInput>,
178}
179
180impl StructuredIngest {
181    /// Construct a structured-ingest payload from an episode builder. All
182    /// three structured lists start empty — chain `.with_entities(...)`
183    /// etc. to populate.
184    #[must_use]
185    pub fn new(episode: EpisodeBuilder) -> Self {
186        Self { episode, entities: Vec::new(), relations: Vec::new(), facts: Vec::new() }
187    }
188
189    #[must_use]
190    pub fn with_entities(mut self, entities: Vec<EntityInput>) -> Self {
191        self.entities = entities;
192        self
193    }
194
195    #[must_use]
196    pub fn with_relations(mut self, relations: Vec<RelationInput>) -> Self {
197        self.relations = relations;
198        self
199    }
200
201    #[must_use]
202    pub fn with_facts(mut self, facts: Vec<FactInput>) -> Self {
203        self.facts = facts;
204        self
205    }
206}
207
208/// Internal implementation. The public surface is
209/// [`crate::Lunaris::ingest_structured`] /
210/// [`crate::ScopedLunaris::ingest_structured`] which inject the storage,
211/// embedder, and clock from the handle.
212///
213/// INGEST-04 invariant preserved: exactly ONE `atomic_write` call covers
214/// all writes (episode KV + per-chunk KV/Vector + per-entity
215/// GraphNode/Vector + per-relation GraphEdge + per-fact KV/Vector).
216///
217/// Exposed as `#[doc(hidden)] pub` (not part of the stable surface) so the
218/// `memory-update-intelligence` integration tests can drive the REAL
219/// production write path against a recording `StoragePort` double — proving
220/// the dedup + cross-episode-publish logic is actually WIRED into ingest, not
221/// merely unit-correct in isolation. Production callers go through the handle.
222#[doc(hidden)]
223pub async fn ingest_structured_inner(
224    storage: &dyn StoragePort,
225    embedder: &dyn Embedder,
226    clock: &HlcClock,
227    payload: StructuredIngest,
228    scope: Scope,
229) -> Result<Lsn, LunarisError> {
230    let episode = payload.episode.into_episode(scope, clock);
231    let embedder_dim = embedder.dim();
232
233    // ── 1. Chunk + embed episode text ───────────────────────────────────
234    let drafts = chunk_markdown(&episode.content, DEFAULT_TARGET_TOKENS, DEFAULT_OVERLAP_TOKENS);
235    let chunk_embeddings: Vec<Vec<f32>> = if drafts.is_empty() {
236        Vec::new()
237    } else {
238        let texts: Vec<&str> = drafts.iter().map(|d| d.text.as_str()).collect();
239        let rows = embedder.embed_batch(&texts).await?;
240        if rows.len() != texts.len() {
241            return Err(LunarisError::Storage(StorageError::Backend(format!(
242                "structured_ingest: chunk embed returned {} rows for {} chunks",
243                rows.len(),
244                texts.len()
245            ))));
246        }
247        rows
248    };
249
250    // ── 2. Resolve entity embeddings ────────────────────────────────────
251    // Caller-supplied entries are dim-validated up front; the rest go in
252    // a single embed_batch call indexed by `to_embed_idx` so the order is
253    // preserved when we splice results back.
254    let mut entity_embeds: Vec<Vec<f32>> = vec![Vec::new(); payload.entities.len()];
255    let mut to_embed_idx: Vec<usize> = Vec::new();
256    let mut to_embed_text: Vec<String> = Vec::new();
257    for (i, e) in payload.entities.iter().enumerate() {
258        if let Some(emb) = &e.embedding {
259            if emb.len() != embedder_dim {
260                return Err(LunarisError::Storage(StorageError::Backend(format!(
261                    "structured_ingest: EntityInput {:?} supplied embedding has dim {} but \
262                     handle expects {}",
263                    e.name,
264                    emb.len(),
265                    embedder_dim
266                ))));
267            }
268            entity_embeds[i] = emb.clone();
269        } else {
270            to_embed_idx.push(i);
271            to_embed_text.push(e.name.clone());
272        }
273    }
274    if !to_embed_text.is_empty() {
275        let texts: Vec<&str> = to_embed_text.iter().map(String::as_str).collect();
276        let rows = embedder.embed_batch(&texts).await?;
277        if rows.len() != to_embed_idx.len() {
278            return Err(LunarisError::Storage(StorageError::Backend(format!(
279                "structured_ingest: entity embed returned {} rows for {} entities",
280                rows.len(),
281                to_embed_idx.len()
282            ))));
283        }
284        for (idx, emb) in to_embed_idx.into_iter().zip(rows.into_iter()) {
285            entity_embeds[idx] = emb;
286        }
287    }
288
289    // ── 3. Embed fact text in a single batch ────────────────────────────
290    let fact_embeds: Vec<Vec<f32>> = if payload.facts.is_empty() {
291        Vec::new()
292    } else {
293        let texts: Vec<&str> = payload.facts.iter().map(|f| f.fact_text.as_str()).collect();
294        let rows = embedder.embed_batch(&texts).await?;
295        if rows.len() != texts.len() {
296            return Err(LunarisError::Storage(StorageError::Backend(format!(
297                "structured_ingest: fact embed returned {} rows for {} facts",
298                rows.len(),
299                texts.len()
300            ))));
301        }
302        rows
303    };
304
305    // ── 4. Assemble Vec<WriteOp> ────────────────────────────────────────
306    let mut ops: Vec<WriteOp> = Vec::with_capacity(
307        1 + 2 * drafts.len()
308            + 2 * payload.entities.len()
309            + payload.relations.len()
310            + 2 * payload.facts.len(),
311    );
312
313    // Episode KV.
314    let episode_value = serde_json::to_vec(&episode).map_err(|e| {
315        LunarisError::Storage(StorageError::Backend(format!(
316            "structured_ingest: episode serialize: {e}"
317        )))
318    })?;
319    ops.push(WriteOp::KvPut { key: episode_key(&episode.scope, episode.id), value: episode_value });
320
321    // Per-chunk KV + Vector (BM25 piggybacks on `content` in metadata).
322    let mut chunks: Vec<Chunk> = Vec::with_capacity(drafts.len());
323    for (draft, emb) in drafts.into_iter().zip(chunk_embeddings.into_iter()) {
324        let mut c = draft.into_chunk_valid_from(
325            episode.scope.clone(),
326            episode.id,
327            clock,
328            episode.bt.valid.0,
329        );
330        c.embedding = Some(emb.clone());
331        let chunk_value = serde_json::to_vec(&c).map_err(|e| {
332            LunarisError::Storage(StorageError::Backend(format!(
333                "structured_ingest: chunk serialize: {e}"
334            )))
335        })?;
336        ops.push(WriteOp::KvPut { key: chunk_key(&episode.scope, c.id), value: chunk_value });
337        ops.push(WriteOp::VectorUpsert {
338            index: CHUNK_VECTOR_INDEX.into(),
339            id: c.id.to_bytes().to_vec(),
340            embedding: emb,
341            metadata: json!({
342                "episode_id": c.episode_id.to_string(),
343                "heading_path": c.heading_path,
344                "offset": c.offset,
345                "text": c.text,
346                "source": &episode.source,
347            }),
348        });
349        chunks.push(c);
350    }
351
352    // Per-entity GraphNode + VectorUpsert. EntityId is deterministic so
353    // re-ingesting an existing logical entity collapses onto the existing
354    // node at the storage-key layer.
355    let episode_id_str = episode.id.to_string();
356    for (e, emb) in payload.entities.iter().zip(entity_embeds.iter()) {
357        let eid = EntityId::from_name_and_type(&e.name, &e.entity_type);
358        let id_bytes = eid.0.to_vec();
359        ops.push(WriteOp::GraphNode {
360            graph: GRAPH_NAME.into(),
361            id: id_bytes.clone(),
362            // T-01-03-01: agent-supplied entity_type is untrusted free-form
363            // text, same Cypher-injection/parse-break risk as extractor
364            // output in crate::ingest. See sanitize_graph_ident doc.
365            label: sanitize_graph_ident(&e.entity_type, "Entity"),
366            props: json!({
367                "id_hex": format!("{eid}"),
368                "name": e.name,
369                "type": e.entity_type,
370                "aliases": e.aliases,
371                "confidence": e.confidence,
372                "valid_from_iso": e.valid_from.to_rfc3339(),
373                "valid_to_iso": e.valid_to.map(|t| t.to_rfc3339()),
374                "source_episode_id": episode_id_str,
375            }),
376            index_kind: "entities".into(),
377        });
378        ops.push(WriteOp::VectorUpsert {
379            index: ENTITIES_INDEX.into(),
380            id: id_bytes,
381            embedding: emb.clone(),
382            metadata: json!({"entity_type": e.entity_type, "name": e.name}),
383        });
384    }
385
386    // Per-relation GraphEdge with episode-level provenance.
387    for r in &payload.relations {
388        let sid = EntityId::from_name_and_type(&r.subject_name, &r.subject_type);
389        let oid = EntityId::from_name_and_type(&r.object_name, &r.object_type);
390        ops.push(WriteOp::GraphEdge {
391            graph: GRAPH_NAME.into(),
392            src: sid.0.to_vec(),
393            dst: oid.0.to_vec(),
394            // T-01-03-01: same rationale as the GraphNode label above.
395            rel: sanitize_graph_ident(&r.predicate, "RELATED_TO"),
396            props: json!({
397                "confidence": r.confidence,
398                "valid_from_iso": r.valid_from.to_rfc3339(),
399                "valid_to_iso": r.valid_to.map(|t| t.to_rfc3339()),
400                "source_episode_id": episode_id_str,
401            }),
402        });
403    }
404
405    // Per-fact KV + VectorUpsert, with memory-update convergence:
406    //   - SYNC dedup: the fact id is the deterministic `FactId` of the
407    //     (subject, predicate, object) triple, so re-asserting an identical
408    //     fact overwrites the same row in place (no duplicate accrues).
409    //   - CROSS-EPISODE contradiction detection: each fact is classified
410    //     against the in-scope `(subject, predicate)` spo-index read once at
411    //     `now`; an overlapping different-object assertion is collected as a
412    //     `NeedsReviewItem` and published to the async verify queue AFTER the
413    //     commit (the verifier closes the loser via `apply_supersede`).
414    // The spo-index updates are folded into the SAME `ops` vec below, so the
415    // single-`atomic_write` (INGEST-04) invariant holds. Reads do not count.
416    let now_hlc = clock.tick();
417    // spo-key → running entries (seeded from storage on first touch this call,
418    // then mutated as additive/supersede facts are appended so multiple facts
419    // sharing a (subject, predicate) in the SAME payload see each other).
420    let mut spo_index: HashMap<Vec<u8>, Vec<SpoEntry>> = HashMap::new();
421    let mut needs_review: Vec<NeedsReviewItem> = Vec::new();
422
423    for (f, emb) in payload.facts.iter().zip(fact_embeds.iter()) {
424        let sid = EntityId::from_name_and_type(&f.subject_name, &f.subject_type);
425        let oid = EntityId::from_name_and_type(&f.object_name, &f.object_type);
426        // Deterministic identity = sync dedup key.
427        let fact_id = Ulid::from_bytes(FactId::from_triple(sid, &f.predicate, oid).0);
428
429        // Seed the spo-index for this (subject, predicate) from storage once.
430        let spo_key = fact_spo_key(&episode.scope, &sid.0, &f.predicate);
431        if !spo_index.contains_key(&spo_key) {
432            let prior = read_spo_index(storage, &episode.scope, &spo_key, now_hlc).await?;
433            spo_index.insert(spo_key.clone(), prior);
434        }
435
436        let new_triple = FactTriple {
437            subject_id: sid,
438            predicate: f.predicate.clone(),
439            object_id: oid,
440            valid_from: f.valid_from,
441            valid_to: f.valid_to,
442        };
443        let prior = &spo_index[&spo_key];
444        match classify_fact(&new_triple, prior) {
445            FactDecision::Noop => {
446                // Exact re-assertion → dedup: the deterministic-id KvPut
447                // overwrites the fact row IN PLACE with the new window, so keep
448                // the matching spo-index entry's window in sync. Otherwise a
449                // later cross-episode check classifies against a STALE interval
450                // and can falsely supersede (BUG-1 / no_false_supersede).
451                if let Some(entry) = spo_index
452                    .get_mut(&spo_key)
453                    .and_then(|v| v.iter_mut().find(|e| e.object_id == oid))
454                {
455                    entry.valid_from = f.valid_from;
456                    entry.valid_to = f.valid_to;
457                }
458            }
459            FactDecision::Append => {
460                spo_index.get_mut(&spo_key).expect("seeded above").push(SpoEntry {
461                    object_id: oid,
462                    fact_id,
463                    valid_from: f.valid_from,
464                    valid_to: f.valid_to,
465                });
466            }
467            FactDecision::Supersede { loser_fact_id } => {
468                // The new fact is still written + indexed (additive); the
469                // verifier closes the loser asynchronously.
470                let existing_object =
471                    prior.iter().find(|p| p.fact_id == loser_fact_id).map_or(oid, |p| p.object_id);
472                needs_review.push(NeedsReviewItem::Fact {
473                    reason: NeedsReviewReason::CrossEpisodeContradiction {
474                        subject: sid,
475                        predicate: f.predicate.clone(),
476                        existing_fact_id: loser_fact_id,
477                        existing_object,
478                        new_fact_id: fact_id,
479                        new_object: oid,
480                    },
481                    raw: Fact {
482                        id: fact_id,
483                        subject_id: sid,
484                        predicate: f.predicate.clone(),
485                        object_id: oid,
486                        fact_text: f.fact_text.clone(),
487                        confidence: f.confidence,
488                        valid_from_iso: f.valid_from.to_rfc3339(),
489                        valid_to_iso: f.valid_to.map(|t| t.to_rfc3339()),
490                    },
491                });
492                spo_index.get_mut(&spo_key).expect("seeded above").push(SpoEntry {
493                    object_id: oid,
494                    fact_id,
495                    valid_from: f.valid_from,
496                    valid_to: f.valid_to,
497                });
498            }
499        }
500
501        let fact_value = serde_json::to_vec(&serde_json::json!({
502            "id": fact_id.to_string(),
503            "subject_id": sid.0,
504            "predicate": f.predicate,
505            "object_id": oid.0,
506            "fact_text": f.fact_text,
507            "confidence": f.confidence,
508            "valid_from_iso": f.valid_from.to_rfc3339(),
509            "valid_to_iso": f.valid_to.map(|t| t.to_rfc3339()),
510            "source_episode_id": episode_id_str,
511        }))
512        .map_err(|e| {
513            LunarisError::Storage(StorageError::Backend(format!(
514                "structured_ingest: fact serialize: {e}"
515            )))
516        })?;
517        ops.push(WriteOp::KvPut {
518            key: scoped_fact_key(&episode.scope, fact_id),
519            value: fact_value,
520        });
521        ops.push(WriteOp::VectorUpsert {
522            index: FACTS_INDEX.into(),
523            id: fact_id.to_bytes().to_vec(),
524            embedding: emb.clone(),
525            metadata: json!({"predicate": f.predicate, "fact_text": f.fact_text}),
526        });
527        // F16: the fact must exist as a GRAPH node too, not only in KV and the
528        // vector index. Without this the agent-supplied path writes a graph of
529        // entities with nothing retrievable in it: `Graph::anchored` returns
530        // node ids and `hydrate_mixed` resolves a candidate as a chunk row or a
531        // fact row — never an entity row — so a traversal that reaches only
532        // entities yields no hit at all. Mirrors the extraction path's fan-out
533        // in `ingest.rs` (Fact node + HAS_FACT + FACT_ABOUT) so both ingest
534        // paths leave the same graph shape behind.
535        let fact_id_bytes = fact_id.to_bytes().to_vec();
536        ops.push(WriteOp::GraphNode {
537            graph: GRAPH_NAME.into(),
538            id: fact_id_bytes.clone(),
539            label: "Fact".into(),
540            props: json!({
541                // `id_hex` is the property the retrieval Cypher selects
542                // (`RETURN m.id_hex`). A Fact node without it comes back NULL
543                // and its candidate is dropped — see the F16 RED commit.
544                "id_hex": fact_id_bytes.iter().map(|b| format!("{b:02x}")).collect::<String>(),
545                "predicate": f.predicate,
546                "confidence": f.confidence,
547                "valid_from_iso": f.valid_from.to_rfc3339(),
548                "valid_to_iso": f.valid_to.map(|t| t.to_rfc3339()),
549            }),
550            index_kind: "facts".into(),
551        });
552        ops.push(WriteOp::GraphEdge {
553            graph: GRAPH_NAME.into(),
554            src: sid.0.to_vec(),
555            dst: fact_id_bytes.clone(),
556            rel: "HAS_FACT".into(),
557            props: json!({}),
558        });
559        ops.push(WriteOp::GraphEdge {
560            graph: GRAPH_NAME.into(),
561            src: fact_id_bytes,
562            dst: oid.0.to_vec(),
563            rel: "FACT_ABOUT".into(),
564            props: json!({}),
565        });
566    }
567
568    // Fold the updated spo-index rows into the SAME atomic_write (one KvPut per
569    // touched (subject, predicate) — INGEST-04 single-write preserved).
570    for (key, entries) in &spo_index {
571        let value = serde_json::to_vec(&spo_entries_to_json(entries)).map_err(|e| {
572            LunarisError::Storage(StorageError::Backend(format!(
573                "structured_ingest: spo-index serialize: {e}"
574            )))
575        })?;
576        ops.push(WriteOp::KvPut { key: key.clone(), value });
577    }
578
579    // ── 5. Single atomic_write (INGEST-04 invariant) ────────────────────
580    let lsn = storage.atomic_write(&episode.scope, &ops).await?;
581
582    // ── 6. Post-commit: publish cross-episode contradictions to the verify
583    //       queue (side channel; the ingest already committed atomically).
584    if !needs_review.is_empty() {
585        crate::ingest::publish_needs_review(storage, &episode.scope, &needs_review).await;
586    }
587
588    Ok(lsn)
589}
590
591/// Read + parse the `(subject, predicate)` spo-index row at `as_of` into the
592/// prior [`SpoEntry`] list consumed by [`classify_fact`]. A missing row (or an
593/// empty/garbled value) yields an empty list — the first fact for a
594/// `(subject, predicate)` is always additive.
595/// `pub(crate)` so the LLM-extraction path (`ingest::ingest_episode_graph_on`)
596/// reuses this exact reader instead of forking the row format — the two
597/// ingest paths MUST agree on the spo-index encoding or a fact written by one
598/// is invisible to the other's contradiction check.
599pub(crate) async fn read_spo_index(
600    storage: &dyn StoragePort,
601    scope: &Scope,
602    key: &[u8],
603    as_of: Hlc,
604) -> Result<Vec<SpoEntry>, LunarisError> {
605    let Some(row) = storage.read_as_of(scope, key, as_of).await.map_err(LunarisError::Storage)?
606    else {
607        return Ok(Vec::new());
608    };
609    let arr: Vec<serde_json::Value> = serde_json::from_slice(&row.value).unwrap_or_default();
610    let mut out = Vec::with_capacity(arr.len());
611    for v in arr {
612        let (Some(obj_hex), Some(fid_str), Some(vf_str)) = (
613            v.get("object_id").and_then(|x| x.as_str()),
614            v.get("fact_id").and_then(|x| x.as_str()),
615            v.get("valid_from").and_then(|x| x.as_str()),
616        ) else {
617            continue;
618        };
619        let (Some(object_id), Some(fact_id), Some(valid_from)) = (
620            EntityId::from_hex(obj_hex),
621            Ulid::from_string(fid_str).ok(),
622            DateTime::parse_from_rfc3339(vf_str).ok().map(|d| d.with_timezone(&Utc)),
623        ) else {
624            continue;
625        };
626        let valid_to = v
627            .get("valid_to")
628            .and_then(|x| x.as_str())
629            .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
630            .map(|d| d.with_timezone(&Utc));
631        out.push(SpoEntry { object_id, fact_id, valid_from, valid_to });
632    }
633    Ok(out)
634}
635
636/// Serialize the spo-index entries to the canonical JSON array shape:
637/// `[{object_id:hex, fact_id:ulid_str, valid_from:iso, valid_to:iso|null}]`.
638pub(crate) fn spo_entries_to_json(entries: &[SpoEntry]) -> Vec<serde_json::Value> {
639    entries
640        .iter()
641        .map(|e| {
642            json!({
643                "object_id": format!("{}", e.object_id),
644                "fact_id": e.fact_id.to_string(),
645                "valid_from": e.valid_from.to_rfc3339(),
646                "valid_to": e.valid_to.map(|t| t.to_rfc3339()),
647            })
648        })
649        .collect()
650}