Skip to main content

kmp_application/memory/
service.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::sync::Arc;
3
4use kmp_domain::{
5    AuthorNodeCard, BundleNode, BundleRelationship, ContextEventStore, DimensionScopeMode,
6    DimensionSelection, DimensionSelectionMode, EntryLabels, GraphNeighborhoodReader, KmpBundle,
7    KmpMode, LabelSelector, MemoryAboutIndexReader, MemoryDimensionIdentity, MemoryRelationType,
8    NodeCard, NodeCardRejection, NodeCardStore, NodeDetailReader, NodeRelationshipReader,
9    ProjectionWriter, ResolutionTier, SnapshotStore, TemporalCoordinate, TemporalMemoryTraversal,
10    TemporalTraversalRequest, labels_by_entry,
11};
12
13use crate::ApplicationError;
14use crate::commands::CommandApplicationService;
15use crate::memory::{
16    AskMemoryQuery, ExistingMemoryRefs, InspectMemoryQuery, InspectMemoryResult, InspectedEvidence,
17    MemoryIngestCommand, MemoryIngestOutcome, MemoryRelabelCommand, MemoryRelabelOutcome,
18    RelateMemoryQuery, TemporalMemoryQuery, TemporalMemoryResult, TraceMemoryQuery, VisualLabel,
19    VisualProjectionQuery, VisualProjectionResult, WakeMemoryQuery, build_visual_projection,
20    crosses_abouts, relabel_logical_digest, replayed_relabel_outcome, translate_memory_ingest,
21    translate_memory_relabel, validate_ref_token, validate_supplied_entry_ref,
22    validate_supplied_member_ref,
23};
24use crate::queries::{
25    ContextRenderOptions, EndpointHint, GetContextPathQuery, GetContextPathResult, GetContextQuery,
26    GetContextResult, GetNodeDetailQuery, GetNodeRelationshipsQuery,
27    MAX_NATIVE_GRAPH_TRAVERSAL_DEPTH, QueryApplicationService, render_graph_bundle_with_options,
28};
29
30const MEMORY_EXISTING_REFS_LOOKUP_DEPTH: u32 = 1;
31
32#[path = "recall_read.rs"]
33mod recall_read;
34#[path = "temporal_catalogue.rs"]
35mod temporal_catalogue;
36#[path = "temporal_index_cache.rs"]
37mod temporal_index_cache;
38#[path = "temporal_index_identity.rs"]
39mod temporal_index_identity;
40#[path = "temporal_index_read.rs"]
41mod temporal_index_read;
42#[path = "temporal_support_read.rs"]
43mod temporal_support_read;
44
45pub struct KernelMemoryApplicationService<G, D, S, E, W> {
46    query_application: Arc<QueryApplicationService<G, D, S>>,
47    command_application: Arc<CommandApplicationService<E, W>>,
48    /// The derived card view, when this kernel has one. Mounted the way read
49    /// snapshots are, as one object-safe port, so a backend that stores no
50    /// cards composes without carrying a generic for a table it never reads.
51    node_cards: Option<Arc<dyn NodeCardStore>>,
52    temporal_index_cache: Arc<std::sync::Mutex<temporal_index_cache::TemporalIndexCache>>,
53}
54
55impl<G, D, S, E, W> KernelMemoryApplicationService<G, D, S, E, W> {
56    pub fn new(
57        query_application: Arc<QueryApplicationService<G, D, S>>,
58        command_application: Arc<CommandApplicationService<E, W>>,
59    ) -> Self {
60        Self {
61            query_application,
62            command_application,
63            node_cards: None,
64            temporal_index_cache: Arc::new(std::sync::Mutex::new(Default::default())),
65        }
66    }
67
68    /// Mounts the store that holds reader-authored cards. Without it,
69    /// condensing is refused by name rather than answered with silence.
70    pub fn with_node_cards(mut self, node_cards: Arc<dyn NodeCardStore>) -> Self {
71        self.node_cards = Some(node_cards);
72        self
73    }
74
75    /// Authors or refreshes one node's compact card.
76    ///
77    /// The outer result is this service failing; the inner one is the card
78    /// policy refusing a write the store could have performed. A caller that
79    /// collapses them turns "your card is out of date" into "memory is down".
80    pub async fn condense(
81        &self,
82        command: AuthorNodeCard,
83    ) -> Result<Result<NodeCard, NodeCardRejection>, ApplicationError> {
84        let Some(cards) = &self.node_cards else {
85            return Err(ApplicationError::Validation(
86                "this kernel serves no reader-authored cards; kmp_condense needs a store with \
87                 the card view mounted"
88                    .into(),
89            ));
90        };
91        Ok(cards.author_node_card(command).await?)
92    }
93}
94
95impl<G, D, S, E, W> KernelMemoryApplicationService<G, D, S, E, W>
96where
97    G: GraphNeighborhoodReader + MemoryAboutIndexReader + NodeRelationshipReader + Send + Sync,
98    D: NodeDetailReader + Send + Sync,
99    S: SnapshotStore + Send + Sync,
100    E: ContextEventStore + Send + Sync,
101    W: ProjectionWriter + Send + Sync,
102{
103    pub async fn ingest(
104        &self,
105        command: MemoryIngestCommand,
106    ) -> Result<MemoryIngestOutcome, ApplicationError> {
107        let reviewing = super::write_neighborhood::requires_review(&command);
108        // An attachment has no entries of its own: its endpoint admission
109        // depends on stored refs, including for fallback links that need no
110        // semantic review. Bind that read to the atomic commit as well.
111        let consistent_read = reviewing || command.memory.entries.is_empty();
112        let read_guard = if consistent_read {
113            Some(self.command_application.projection_read().await)
114        } else {
115            None
116        };
117        // SQLite publishes events and projections together. Sampling each
118        // about before and after reading rejects a mixed-version neighborhood
119        // even when another process commits between the individual queries.
120        let mut revisions = BTreeMap::new();
121        if consistent_read {
122            revisions.insert(
123                command.about.clone(),
124                self.command_application
125                    .memory_revision(&command.about)
126                    .await?,
127            );
128        }
129        let mut bundles = self
130            .existing_memory_bundle(&command.about)
131            .await?
132            .into_iter()
133            .collect::<Vec<_>>();
134        let mut existing = bundles
135            .first()
136            .map(existing_refs_from_bundle)
137            .unwrap_or_default();
138        // A relation that declares an equivalence across abouts must land
139        // on a ref that exists somewhere; the translation refuses it unless
140        // this read found it, and reads nothing for any other relation.
141        for relation in &command.memory.relations {
142            let Ok(relation_type) = MemoryRelationType::new(&relation.rel) else {
143                continue;
144            };
145            let target_ref = relation.target_ref.trim().to_string();
146            if !crosses_abouts(&command.about, relation, &relation_type, &target_ref) {
147                continue;
148            }
149            match self
150                .query_application
151                .get_node_detail(GetNodeDetailQuery {
152                    node_id: target_ref.clone(),
153                })
154                .await
155            {
156                Ok(detail) => {
157                    if consistent_read {
158                        let owner =
159                            detail.node.properties.get("memory_about").ok_or_else(|| {
160                                ApplicationError::Validation(
161                                    "foreign endpoint lacks memory ownership".into(),
162                                )
163                            })?;
164                        if !bundles
165                            .iter()
166                            .any(|bundle| bundle.root_node_id().as_str() == owner)
167                        {
168                            revisions.insert(
169                                owner.clone(),
170                                self.command_application.memory_revision(owner).await?,
171                            );
172                            if let Some(bundle) = self.existing_memory_bundle(owner).await? {
173                                bundles.push(bundle);
174                            }
175                        }
176                    }
177                    existing.foreign.insert(target_ref);
178                }
179                Err(ApplicationError::NotFound(_)) => {}
180                Err(error) => return Err(error),
181            }
182        }
183        let (update_context, mut outcome) = translate_memory_ingest(&command, &existing)?;
184        if reviewing
185            && self
186                .command_application
187                .accepted_outcome(&command.idempotency_key)
188                .await?
189                .is_none()
190        {
191            let neighborhood = super::write_neighborhood::build_neighborhood(&command, &bundles);
192            for about in &neighborhood.abouts {
193                if revisions.get(about).copied()
194                    != Some(self.command_application.memory_revision(about).await?)
195                {
196                    return Err(ApplicationError::RetryableConflict(
197                        "write neighborhood changed during read; retry the same logical write to refresh it".into(),
198                    ));
199                }
200            }
201            if command.neighborhood_review.as_deref() != Some(neighborhood.token.as_str()) {
202                outcome.neighborhood = Some(neighborhood);
203                outcome.receipt_ref = None;
204                outcome.clocks = None;
205                outcome.accepted = super::MemoryAcceptedCounts {
206                    entries: 0,
207                    relations: 0,
208                    evidence: 0,
209                };
210                outcome.created_dimensions.clear();
211                return Ok(outcome);
212            }
213        }
214        drop(read_guard);
215        if command.dry_run {
216            outcome.receipt_ref = None;
217            // No ingestion clock was committed by a preview.
218            outcome.clocks = None;
219            outcome
220                .warnings
221                .push("dry_run=true; validated memory without writing to the kernel".to_string());
222            return Ok(outcome);
223        }
224
225        let accepted = self
226            .command_application
227            .update_context_after_read(update_context, &revisions)
228            .await?;
229        outcome.read_after_write_ready = true;
230        outcome.replayed = accepted.replayed;
231        if accepted.replayed {
232            // The new translation has a new ingestion time. Only the accepted
233            // receipt can describe the clocks of the original logical write.
234            outcome.clocks = accepted.replayed_receipt.and_then(|receipt| {
235                serde_json::from_str::<serde_json::Value>(&receipt.payload_json)
236                    .ok()
237                    .and_then(|body| serde_json::from_value(body["clocks"].clone()).ok())
238            });
239        }
240        outcome.warnings.extend(accepted.warnings);
241        Ok(outcome)
242    }
243
244    /// Changes the labels one entry stands in without rewriting its text.
245    /// Read like a write: the about's catalogue and the entry's coordinates
246    /// come from the store, the translation refuses what only the caller
247    /// can fix, and one `memory_relabel` change goes to the log.
248    pub async fn relabel(
249        &self,
250        command: MemoryRelabelCommand,
251    ) -> Result<MemoryRelabelOutcome, ApplicationError> {
252        let existing = self.existing_memory_refs(&command.about).await?;
253        let current = self
254            .entry_coordinates(&command.about, &command.ref_id, &existing)
255            .await?;
256        // A relabel translated after its own first apply refuses the labels
257        // that apply put there, so an accepted key is answered before the
258        // translation, the way ingest's replay is answered after it.
259        if !command.idempotency_key.trim().is_empty()
260            && let Some(accepted) = self
261                .command_application
262                .accepted_outcome(&command.idempotency_key)
263                .await?
264        {
265            if accepted.logical_digest.as_deref() != Some(relabel_logical_digest(&command).as_str())
266            {
267                return Err(ApplicationError::Ports(kmp_domain::PortError::Conflict(
268                    format!(
269                        "idempotency key '{}' was already accepted with different content",
270                        command.idempotency_key
271                    ),
272                )));
273            }
274            return replayed_relabel_outcome(&command, &current);
275        }
276        let (update_context, mut outcome) =
277            translate_memory_relabel(&command, &existing, &current)?;
278        if command.dry_run {
279            outcome.warnings.push(
280                "dry_run=true; validated the relabel against the store without writing to the kernel"
281                    .to_string(),
282            );
283            return Ok(outcome);
284        }
285
286        let accepted = self
287            .command_application
288            .update_context(update_context)
289            .await?;
290        outcome.read_after_write_ready = true;
291        outcome.warnings.extend(accepted.warnings);
292        Ok(outcome)
293    }
294
295    /// The coordinates an entry stands in now, read off its `contains_entry`
296    /// edges — the same reading `kmp_inspect` returns as the raw record.
297    async fn entry_coordinates(
298        &self,
299        about: &str,
300        ref_id: &str,
301        existing: &ExistingMemoryRefs,
302    ) -> Result<Vec<TemporalCoordinate>, ApplicationError> {
303        validate_supplied_entry_ref(about, "ref", ref_id).map_err(ApplicationError::Validation)?;
304        if !existing.refs.contains(ref_id) {
305            return Err(ApplicationError::NotFound(format!(
306                "`{ref_id}` is not a memory of `{about}`"
307            )));
308        }
309        let links = self
310            .query_application
311            .get_node_relationships(GetNodeRelationshipsQuery {
312                node_id: ref_id.to_string(),
313            })
314            .await?;
315        inspect_raw_coordinates(ref_id, Some(&links))
316    }
317
318    /// The abouts the memory currently indexes, for consumers that render an
319    /// index of what can be recalled — a viewer's sidebar, a CLI listing.
320    pub async fn list_abouts(&self) -> Result<Vec<String>, ApplicationError> {
321        self.query_application.list_memory_abouts().await
322    }
323
324    async fn read_snapshot(&self) -> Result<Option<Self>, ApplicationError> {
325        Ok(self.query_application.read_snapshot().await?.map(|query| {
326            let mut snapshot = Self::new(Arc::new(query), Arc::clone(&self.command_application));
327            snapshot.temporal_index_cache = Arc::clone(&self.temporal_index_cache);
328            snapshot
329        }))
330    }
331
332    pub async fn wake(&self, query: WakeMemoryQuery) -> Result<GetContextResult, ApplicationError> {
333        let snapshot = self.read_snapshot().await?;
334        snapshot.as_ref().unwrap_or(self).wake_snapshot(query).await
335    }
336
337    async fn wake_snapshot(
338        &self,
339        query: WakeMemoryQuery,
340    ) -> Result<GetContextResult, ApplicationError> {
341        let render_options = memory_render_options(
342            query.token_budget,
343            query.max_tier,
344            KmpMode::ResumeFocused,
345            EndpointHint::Neighborhood,
346        );
347        let dimensions = query.dimensions.resolve_current_about(&query.about);
348        self.selected_recall_context(
349            &query.about,
350            &query.role,
351            query.depth,
352            &dimensions,
353            &render_options,
354        )
355        .await
356    }
357
358    pub async fn ask(&self, query: AskMemoryQuery) -> Result<GetContextResult, ApplicationError> {
359        let snapshot = self.read_snapshot().await?;
360        snapshot.as_ref().unwrap_or(self).ask_snapshot(query).await
361    }
362
363    async fn ask_snapshot(
364        &self,
365        query: AskMemoryQuery,
366    ) -> Result<GetContextResult, ApplicationError> {
367        let render_options = memory_render_options(
368            query.token_budget,
369            query.max_tier,
370            KmpMode::ReasonPreserving,
371            EndpointHint::Neighborhood,
372        );
373        let dimensions = query.dimensions.resolve_current_about(&query.about);
374        self.selected_recall_context(
375            &query.about,
376            "answerer",
377            query.depth,
378            &dimensions,
379            &render_options,
380        )
381        .await
382    }
383
384    pub async fn temporal(
385        &self,
386        query: TemporalMemoryQuery,
387    ) -> Result<TemporalMemoryResult, ApplicationError> {
388        let snapshot = self.read_snapshot().await?;
389        snapshot
390            .as_ref()
391            .unwrap_or(self)
392            .temporal_snapshot(query)
393            .await
394    }
395
396    async fn temporal_snapshot(
397        &self,
398        query: TemporalMemoryQuery,
399    ) -> Result<TemporalMemoryResult, ApplicationError> {
400        let (index, dimensions) = self.temporal_index_read(&query).await?;
401        let request = temporal_request(&query, &dimensions)?;
402        let traversal = index.traverse(&request)?;
403        let source_bundle = filter_bundle_by_memory_dimensions_with_labels(
404            index.bundle(),
405            &dimensions,
406            &traversal.proof_labels(index.bundle())?,
407        )?;
408        // Source-node metadata (including receipt clocks) can affect a
409        // relation even when its body was not requested. Admit all selected
410        // proof nodes before projecting paths, lifecycle and evidence.
411        let node_refs = kmp_domain::TemporalProofPlan::select(
412            &source_bundle,
413            &traversal,
414            query.include.dependencies,
415        )?
416        .body_refs()
417        .clone();
418        let source_bundle = self
419            .query_application
420            .materialize_selected_nodes(source_bundle, &node_refs)
421            .await?;
422        let source_bundle = self
423            .materialize_temporal_supports(source_bundle, &node_refs)
424            .await?;
425        let mut result = TemporalMemoryResult {
426            traversal,
427            source_bundle,
428            include: query.include,
429        };
430        let selected = if result.include.evidence || result.include.dependencies {
431            kmp_domain::TemporalProofPlan::select(
432                &result.source_bundle,
433                &result.traversal,
434                result.include.dependencies,
435            )?
436            .body_refs()
437            .clone()
438        } else if result.include.raw_refs {
439            result
440                .traversal
441                .entries()
442                .iter()
443                .map(|entry| entry.ref_id().to_string())
444                .collect()
445        } else {
446            BTreeSet::new()
447        };
448        result.source_bundle = self
449            .query_application
450            .materialize_selected_details(result.source_bundle, &selected)
451            .await?;
452        Ok(result)
453    }
454
455    /// The context a temporal read traverses, as the graph returned it:
456    /// the read's dimension filter has not narrowed it yet, so the whole
457    /// catalogue of the about is still in the bundle.
458    async fn temporal_read(
459        &self,
460        query: &TemporalMemoryQuery,
461        include_details: bool,
462    ) -> Result<TemporalRead, ApplicationError> {
463        let dimensions = query.dimensions.resolve_current_about(&query.about);
464        let roots = self.memory_context_roots(&query.about, &dimensions).await?;
465        let scopes = requested_dimension_scopes(&query.about, &dimensions, &roots);
466        let mut bundles = Vec::with_capacity(roots.len());
467        for root in roots {
468            let request = kmp_domain::NeighborhoodRequest::new(
469                root,
470                crate::queries::clamp_native_graph_traversal_depth(query.depth),
471            )
472            .with_scopes(scopes.clone());
473            bundles.push(if include_details {
474                self.query_application
475                    .read_context_bundle(&request, "temporal-reader")
476                    .await?
477            } else {
478                self.query_application
479                    .read_context_catalogue(&request, "temporal-reader")
480                    .await?
481            });
482        }
483        Ok(TemporalRead {
484            bundle: super::merge_memory_bundles::merge(bundles)?,
485            dimensions,
486        })
487    }
488
489    /// A range- and level-of-detail-aware read model for renderers.
490    ///
491    /// This stays on the application facade: storage never gains a viewer
492    /// query and renderers never reach into an engine. The temporal move
493    /// narrows by the selected clock first; bins, clusters and moment entries
494    /// are then projected on a channel whose result is not a model prompt.
495    pub async fn visual_projection(
496        &self,
497        query: VisualProjectionQuery,
498    ) -> Result<VisualProjectionResult, ApplicationError> {
499        let snapshot = self.read_snapshot().await?;
500        snapshot
501            .as_ref()
502            .unwrap_or(self)
503            .visual_projection_snapshot(query)
504            .await
505    }
506
507    async fn visual_projection_snapshot(
508        &self,
509        query: VisualProjectionQuery,
510    ) -> Result<VisualProjectionResult, ApplicationError> {
511        let temporal_query = query.temporal_query()?;
512        let read = self.temporal_read(&temporal_query, true).await?;
513        // The catalogue is read before the filter: a renderer draws the
514        // about's labels, and says which are empty in this range. Under
515        // `scope_ids` the graph read itself is narrowed to those scopes, so
516        // the catalogue is what that read reached.
517        let catalogue = VisualLabel::catalogue(&read.bundle);
518        let declarations = if query.level_of_detail == super::VisualLevelOfDetail::Moment {
519            super::visual_projection::declared_equivalences(&read.bundle)
520        } else {
521            Vec::new()
522        };
523        let temporal = temporal_result(temporal_query, read)?;
524        let mut projection = build_visual_projection(&query, temporal, catalogue)?;
525        super::visual_projection::include_owned_declarations(&mut projection, declarations);
526        Ok(projection)
527    }
528
529    /// The neighbourhood `relate` reads: the same load as `ask` over the
530    /// abouts the selection names or resolves. Where the facts fall in time,
531    /// and what they have to do with each other, is read from the bundle
532    /// afterwards; the store is asked for nothing it does not hold.
533    pub async fn relate(
534        &self,
535        query: RelateMemoryQuery,
536    ) -> Result<GetContextResult, ApplicationError> {
537        let snapshot = self.read_snapshot().await?;
538        snapshot
539            .as_ref()
540            .unwrap_or(self)
541            .relate_snapshot(query)
542            .await
543    }
544
545    async fn relate_snapshot(
546        &self,
547        query: RelateMemoryQuery,
548    ) -> Result<GetContextResult, ApplicationError> {
549        let render_options = memory_render_options(
550            query.token_budget,
551            query.max_tier,
552            KmpMode::ReasonPreserving,
553            EndpointHint::Neighborhood,
554        );
555        let dimensions = query.dimensions.resolve_current_about(&query.about);
556        let result = self
557            .memory_context(
558                &query.about,
559                "relater",
560                query.depth,
561                &dimensions,
562                &render_options,
563            )
564            .await?;
565        apply_dimension_selection(result, &dimensions, &render_options)
566    }
567
568    /// Library-level seed discovery. The MCP transport does not expose this
569    /// internal role policy until its progressive agent surface is designed.
570    pub async fn evidence_paths(
571        &self,
572        request: kmp_domain::EvidencePathRequest,
573    ) -> Result<kmp_domain::EvidencePathResult, ApplicationError> {
574        request
575            .validate()
576            .map_err(|e| ApplicationError::Validation(e.to_string()))?;
577        validate_supplied_entry_ref(&request.about, "evidence seed", &request.from)
578            .map_err(ApplicationError::Validation)?;
579        if let Some(kmp_domain::TemporalCursor::Ref(reference)) = request.temporal.cursor() {
580            validate_supplied_entry_ref(&request.about, "as_of.ref", reference)
581                .map_err(ApplicationError::Validation)?;
582        }
583        self.query_application.evidence_paths(&request).await
584    }
585
586    pub async fn trace_search(
587        &self,
588        request: kmp_domain::TraceSearchRequest,
589    ) -> Result<kmp_domain::TraceSearchResult, ApplicationError> {
590        request
591            .validate()
592            .map_err(|e| ApplicationError::Validation(e.to_string()))?;
593        for reference in std::iter::once(&request.from).chain(request.targets.iter()) {
594            validate_supplied_entry_ref(&request.about, "trace search ref", reference)
595                .map_err(ApplicationError::Validation)?;
596        }
597        if let Some(kmp_domain::TemporalCursor::Ref(reference)) = request.temporal.cursor() {
598            validate_supplied_entry_ref(&request.about, "as_of.ref", reference)
599                .map_err(ApplicationError::Validation)?;
600        }
601        self.query_application.trace_search(&request).await
602    }
603
604    pub async fn trace(
605        &self,
606        query: TraceMemoryQuery,
607    ) -> Result<GetContextPathResult, ApplicationError> {
608        let snapshot = self.read_snapshot().await?;
609        snapshot
610            .as_ref()
611            .unwrap_or(self)
612            .trace_snapshot(query)
613            .await
614    }
615
616    async fn trace_snapshot(
617        &self,
618        query: TraceMemoryQuery,
619    ) -> Result<GetContextPathResult, ApplicationError> {
620        self.validate_read_members(
621            &query.about,
622            &[("from", query.from.as_str()), ("to", query.to.as_str())],
623        )
624        .await?;
625        self.query_application
626            .get_context_path(GetContextPathQuery {
627                root_node_id: query.from,
628                target_node_id: query.to,
629                role: query.role,
630                subtree_depth: Some(0),
631                render_options: ContextRenderOptions {
632                    focus_node_id: None,
633                    token_budget: (query.token_budget > 0).then_some(query.token_budget),
634                    max_tier: Some(ResolutionTier::L2EvidencePack),
635                    rehydration_mode: KmpMode::ReasonPreserving,
636                    endpoint_hint: EndpointHint::FocusedPath,
637                },
638            })
639            .await
640    }
641
642    pub async fn inspect(
643        &self,
644        query: InspectMemoryQuery,
645    ) -> Result<InspectMemoryResult, ApplicationError> {
646        let snapshot = self.read_snapshot().await?;
647        snapshot
648            .as_ref()
649            .unwrap_or(self)
650            .inspect_snapshot(query)
651            .await
652    }
653
654    async fn inspect_snapshot(
655        &self,
656        query: InspectMemoryQuery,
657    ) -> Result<InspectMemoryResult, ApplicationError> {
658        if query.ref_id.starts_with("receipt:") {
659            if query.expect_revision.is_some() {
660                return Err(ApplicationError::Validation(
661                    "a receipt is immutable command detail and carries no body revision; \
662                     drop expect to inspect one"
663                        .into(),
664                ));
665            }
666            let reference = kmp_domain::MemoryReceiptRef::parse(&query.ref_id)
667                .ok_or_else(|| ApplicationError::Validation("invalid receipt ref".into()))?;
668            if reference.about() != query.about {
669                return Err(ApplicationError::Validation(
670                    "receipt ref belongs to another about".into(),
671                ));
672            }
673            let accepted = self
674                .command_application
675                .accepted_outcome(reference.idempotency_key())
676                .await?
677                .ok_or_else(|| {
678                    ApplicationError::NotFound(format!("receipt not found: {}", query.ref_id))
679                })?;
680            return super::receipt::inspect_receipt(query, accepted);
681        }
682        self.validate_read_members(&query.about, &[("ref", query.ref_id.as_str())])
683            .await?;
684        let include_incoming = query.include_incoming;
685        let include_outgoing = query.include_outgoing;
686        let include_details = query.include_details;
687        let detail = self
688            .query_application
689            .get_node_detail(GetNodeDetailQuery {
690                node_id: query.ref_id.clone(),
691            })
692            .await?;
693        // Canonical expansion. The store keeps one body version per node, so
694        // the only promise it can keep is "exactly the revision you declared,
695        // or a conflict naming the one that is here". It never reconstructs
696        // an older body, and it returns no text when it cannot keep it.
697        if let Some(expected) = query.expect_revision {
698            let actual = detail.detail.as_ref().map(|body| body.revision);
699            if actual != Some(expected) {
700                return Err(ApplicationError::Ports(kmp_domain::PortError::Conflict(
701                    match actual {
702                        Some(actual) => format!(
703                            "`{}` is at body revision {actual}, not the declared {expected}; \
704                             this store keeps one body version per entry, so the declared one \
705                             cannot be shown. Inspect without expect to read revision {actual}",
706                            query.ref_id
707                        ),
708                        None => format!(
709                            "`{}` has no stored body, so body revision {expected} cannot be \
710                             expanded",
711                            query.ref_id
712                        ),
713                    },
714                )));
715            }
716        }
717
718        // Evidence is part of Inspect's contract independently of whether the
719        // caller asks to render the incoming links. Resolve the direct graph
720        // once, then follow only typed evidence sources.
721        let links = self
722            .query_application
723            .get_node_relationships(GetNodeRelationshipsQuery {
724                node_id: query.ref_id.clone(),
725            })
726            .await?;
727        let mut evidence = Vec::new();
728        let supporting_refs = links
729            .incoming
730            .iter()
731            .filter(|relationship| relationship.relationship_type == "supports")
732            .map(|relationship| relationship.source_node_id.clone())
733            .collect::<BTreeSet<_>>();
734        // One source has no dispatches to amortize. Keep its existing direct
735        // operation; collect typed nodes and bodies together for larger sets.
736        let sources = if supporting_refs.len() == 1 {
737            let node_id = supporting_refs.into_iter().next().expect("one source");
738            vec![match self
739                .query_application
740                .get_node_detail(GetNodeDetailQuery { node_id })
741                .await
742            {
743                Ok(detail) => Some(detail),
744                Err(ApplicationError::NotFound(_)) => None,
745                Err(error) => return Err(error),
746            }]
747        } else {
748            self.query_application
749                .get_node_details(supporting_refs.into_iter().collect())
750                .await?
751        };
752        // A stale edge is not evidence; a present typed source with no body
753        // retains the same fallback as the single-node operation.
754        for evidence_detail in sources.into_iter().flatten() {
755            if !is_memory_evidence_kind(&evidence_detail.node.node_kind) {
756                continue;
757            }
758            evidence.push(InspectedEvidence {
759                supports: projected_evidence_supports(&evidence_detail, &query.ref_id),
760                detail: evidence_detail,
761            });
762        }
763        let raw_coordinates = if query.include_raw {
764            inspect_raw_coordinates(&query.ref_id, Some(&links))?
765        } else {
766            Vec::new()
767        };
768
769        Ok(InspectMemoryResult {
770            detail,
771            incoming: if include_incoming {
772                links.incoming.clone()
773            } else {
774                Vec::new()
775            },
776            outgoing: if include_outgoing {
777                links.outgoing.clone()
778            } else {
779                Vec::new()
780            },
781            evidence,
782            raw_coordinates,
783            include_details,
784            include_raw: query.include_raw,
785        })
786    }
787
788    async fn existing_memory_refs(
789        &self,
790        about: &str,
791    ) -> Result<ExistingMemoryRefs, ApplicationError> {
792        Ok(self
793            .existing_memory_bundle(about)
794            .await?
795            .as_ref()
796            .map(existing_refs_from_bundle)
797            .unwrap_or_default())
798    }
799
800    async fn existing_memory_bundle(
801        &self,
802        about: &str,
803    ) -> Result<Option<KmpBundle>, ApplicationError> {
804        match self
805            .query_application
806            .get_context(GetContextQuery {
807                root_node_id: about.to_string(),
808                role: "memory".to_string(),
809                // Existing-ref validation only needs direct structural memory edges:
810                // anchor -> dimensions, anchor -> entries, and anchor -> evidence.
811                // Full semantic traversal here grows with every writer relation and
812                // makes repeated ingest progressively slower.
813                depth: MEMORY_EXISTING_REFS_LOOKUP_DEPTH,
814                requested_scopes: Vec::new(),
815                render_options: ContextRenderOptions::default(),
816            })
817            .await
818        {
819            Ok(result) => Ok(Some(result.bundle)),
820            Err(ApplicationError::NotFound(_)) => Ok(None),
821            Err(error) => Err(error),
822        }
823    }
824
825    async fn validate_read_members(
826        &self,
827        about: &str,
828        members: &[(&str, &str)],
829    ) -> Result<(), ApplicationError> {
830        let mut graph_members = Vec::new();
831        for (path, member_ref) in members {
832            validate_ref_token(path, member_ref).map_err(ApplicationError::Validation)?;
833            if validate_supplied_member_ref(about, path, member_ref).is_err() {
834                graph_members.push((*path, *member_ref));
835            }
836        }
837        if graph_members.is_empty() {
838            return Ok(());
839        }
840
841        let visible = match self
842            .query_application
843            .get_context(GetContextQuery {
844                root_node_id: about.to_string(),
845                role: "memory-boundary".to_string(),
846                depth: MAX_NATIVE_GRAPH_TRAVERSAL_DEPTH,
847                requested_scopes: Vec::new(),
848                render_options: ContextRenderOptions::default(),
849            })
850            .await
851        {
852            Ok(result) => bundle_node_ids(&result.bundle),
853            Err(ApplicationError::NotFound(_)) => BTreeSet::new(),
854            Err(error) => return Err(error),
855        };
856        for (path, member_ref) in graph_members {
857            if !visible.contains(member_ref) {
858                return Err(ApplicationError::Validation(format!(
859                    "`{path}` `{member_ref}` does not belong to about `{about}`"
860                )));
861            }
862        }
863        Ok(())
864    }
865
866    async fn memory_context(
867        &self,
868        about: &str,
869        role: &str,
870        depth: u32,
871        dimensions: &DimensionSelection,
872        render_options: &ContextRenderOptions,
873    ) -> Result<GetContextResult, ApplicationError> {
874        let roots = self.memory_context_roots(about, dimensions).await?;
875        let requested_scopes = requested_dimension_scopes(about, dimensions, &roots);
876        let mut results = Vec::new();
877        for root in &roots {
878            results.push(
879                self.query_application
880                    .get_context(GetContextQuery {
881                        root_node_id: root.clone(),
882                        role: role.to_string(),
883                        depth,
884                        requested_scopes: requested_scopes.clone(),
885                        render_options: render_options.clone(),
886                    })
887                    .await?,
888            );
889        }
890
891        merge_context_results(results, render_options)
892    }
893
894    async fn memory_context_roots(
895        &self,
896        current_about: &str,
897        selection: &DimensionSelection,
898    ) -> Result<Vec<String>, ApplicationError> {
899        if selection.scope_mode() != DimensionScopeMode::AllAbouts {
900            return context_roots(current_about, selection);
901        }
902
903        let roots = if should_filter_all_abouts_by_dimensions(selection) {
904            // Kinds from `include`, exact ids from `scope_ids`, keys and
905            // values from the positive selectors: the index resolves any of
906            // them, and the filter that follows reads them all. The union is
907            // a superset of what the conjoined filter keeps, never less.
908            let dimension_ids = index_dimension_ids(selection);
909            self.query_application
910                .list_memory_abouts_by_dimensions(&dimension_ids)
911                .await?
912        } else {
913            self.query_application.list_memory_abouts().await?
914        };
915
916        let roots = prioritize_current_about(normalize_about_roots(roots), current_about);
917        if roots.is_empty() {
918            return Err(ApplicationError::NotFound(
919                "no memory abouts found for ALL_ABOUTS scope".to_string(),
920            ));
921        }
922        Ok(roots)
923    }
924}
925
926/// One temporal read before its filter: the context the graph returned and
927/// the selection resolved against the current about.
928struct TemporalRead {
929    bundle: KmpBundle,
930    dimensions: DimensionSelection,
931}
932
933/// Narrows the read to its selection and traverses it.
934fn temporal_result(
935    query: TemporalMemoryQuery,
936    read: TemporalRead,
937) -> Result<TemporalMemoryResult, ApplicationError> {
938    let TemporalRead { bundle, dimensions } = read;
939
940    let request = temporal_request(&query, &dimensions)?;
941
942    let traversal = TemporalMemoryTraversal::traverse(&bundle, &request)?;
943    let source_bundle = filter_bundle_by_memory_dimensions_with_labels(
944        &bundle,
945        &dimensions,
946        &traversal.proof_labels(&bundle)?,
947    )?;
948    Ok(TemporalMemoryResult {
949        traversal,
950        source_bundle,
951        include: query.include,
952    })
953}
954
955fn temporal_request(
956    query: &TemporalMemoryQuery,
957    dimensions: &DimensionSelection,
958) -> Result<TemporalTraversalRequest, ApplicationError> {
959    let request = TemporalTraversalRequest::new(query.direction, query.cursor.clone())
960        .with_entry_selection(query.entry_selection.clone())
961        .with_axis(query.axis)
962        .with_dimensions(dimensions.clone())
963        .with_requested_dimensions(query.dimensions.clone())
964        .with_window(query.window);
965    let request = if let Some(interval) = query.interval.clone() {
966        request.with_interval(interval)
967    } else {
968        request
969    };
970    let request = if let Some(limit_entries) = query.limit_entries {
971        request.with_limit_entries(limit_entries)?
972    } else {
973        request
974    };
975
976    Ok(request)
977}
978
979fn bundle_node_ids(bundle: &KmpBundle) -> BTreeSet<String> {
980    std::iter::once(bundle.root_node().node_id())
981        .chain(bundle.neighbor_nodes().iter().map(BundleNode::node_id))
982        .map(ToString::to_string)
983        .collect()
984}
985
986fn projected_evidence_supports(
987    evidence: &crate::queries::GetNodeDetailResult,
988    inspected_ref: &str,
989) -> Vec<String> {
990    evidence
991        .node
992        .properties
993        .get("payload_supports")
994        .and_then(|value| serde_json::from_str::<Vec<String>>(value).ok())
995        .filter(|supports| !supports.is_empty())
996        .unwrap_or_else(|| vec![inspected_ref.to_string()])
997}
998
999fn should_filter_all_abouts_by_dimensions(selection: &DimensionSelection) -> bool {
1000    selection.scope_mode() == DimensionScopeMode::AllAbouts
1001        && ((selection.mode() == DimensionSelectionMode::Only
1002            && !selection.dimensions().is_empty())
1003            || !selection.scope_ids().is_empty()
1004            || selection.selectors().iter().any(LabelSelector::is_positive))
1005}
1006
1007fn index_dimension_ids(selection: &DimensionSelection) -> Vec<String> {
1008    let mut dimension_ids = Vec::new();
1009    if selection.mode() == DimensionSelectionMode::Only {
1010        dimension_ids.extend(selection.dimensions().iter().cloned());
1011    }
1012    dimension_ids.extend(selection.scope_ids().iter().cloned());
1013    dimension_ids.extend(selection.positive_selector_ids());
1014    dimension_ids
1015}
1016
1017fn inspect_raw_coordinates(
1018    ref_id: &str,
1019    links: Option<&crate::queries::GetNodeRelationshipsResult>,
1020) -> Result<Vec<TemporalCoordinate>, ApplicationError> {
1021    let Some(links) = links else {
1022        return Ok(Vec::new());
1023    };
1024
1025    let mut coordinates = Vec::new();
1026    for relationship in links.incoming.iter().chain(links.outgoing.iter()) {
1027        if relationship.relationship_type != "contains_entry"
1028            || relationship.target_node_id != ref_id
1029        {
1030            continue;
1031        }
1032        if let Some(coordinate) =
1033            TemporalCoordinate::from_relation_explanation(&relationship.explanation)?
1034        {
1035            coordinates.push(coordinate);
1036        }
1037    }
1038
1039    Ok(coordinates)
1040}
1041
1042fn memory_render_options(
1043    token_budget: u32,
1044    max_tier: Option<ResolutionTier>,
1045    rehydration_mode: KmpMode,
1046    endpoint_hint: EndpointHint,
1047) -> ContextRenderOptions {
1048    ContextRenderOptions {
1049        focus_node_id: None,
1050        token_budget: (token_budget > 0).then_some(token_budget),
1051        max_tier,
1052        rehydration_mode,
1053        endpoint_hint,
1054    }
1055}
1056
1057fn requested_dimension_scopes(
1058    _current_about: &str,
1059    selection: &DimensionSelection,
1060    _context_roots: &[String],
1061) -> Vec<String> {
1062    // Hints are keys, bare values or complete refs. The adapter compares
1063    // them to the decoded label identity; callers never manufacture refs.
1064    if !selection.scope_ids().is_empty() {
1065        return selection.scope_ids().iter().cloned().collect();
1066    }
1067    if selection.mode() == DimensionSelectionMode::Only {
1068        return selection.dimensions().iter().cloned().collect();
1069    }
1070    Vec::new()
1071}
1072
1073fn context_roots(
1074    current_about: &str,
1075    selection: &DimensionSelection,
1076) -> Result<Vec<String>, ApplicationError> {
1077    match selection.scope_mode() {
1078        DimensionScopeMode::Abouts if !selection.abouts().is_empty() => {
1079            Ok(selection.abouts().iter().cloned().collect())
1080        }
1081        DimensionScopeMode::CurrentAbout => Ok(vec![current_about.to_string()]),
1082        DimensionScopeMode::Abouts => Err(ApplicationError::Validation(
1083            "dimension scope ABOUTS requires at least one about".to_string(),
1084        )),
1085        DimensionScopeMode::AllAbouts => Err(ApplicationError::Validation(
1086            "dimension scope ALL_ABOUTS must be resolved through the memory about index"
1087                .to_string(),
1088        )),
1089    }
1090}
1091
1092fn apply_dimension_selection(
1093    mut result: GetContextResult,
1094    dimensions: &DimensionSelection,
1095    render_options: &ContextRenderOptions,
1096) -> Result<GetContextResult, ApplicationError> {
1097    result.bundle = filter_bundle_by_memory_dimensions(&result.bundle, dimensions)?;
1098    result.rendered = render_graph_bundle_with_options(&result.bundle, render_options);
1099    Ok(result)
1100}
1101
1102fn normalize_about_roots(values: Vec<String>) -> Vec<String> {
1103    values
1104        .into_iter()
1105        .map(|value| value.trim().to_string())
1106        .filter(|value| !value.is_empty())
1107        .collect::<BTreeSet<_>>()
1108        .into_iter()
1109        .collect()
1110}
1111
1112fn prioritize_current_about(mut roots: Vec<String>, current_about: &str) -> Vec<String> {
1113    let current_about = current_about.trim();
1114    if current_about.is_empty() {
1115        return roots;
1116    }
1117    if let Some(position) = roots.iter().position(|root| root == current_about) {
1118        let root = roots.remove(position);
1119        roots.insert(0, root);
1120    }
1121    roots
1122}
1123
1124fn filter_bundle_by_memory_dimensions(
1125    bundle: &KmpBundle,
1126    dimensions: &DimensionSelection,
1127) -> Result<KmpBundle, ApplicationError> {
1128    filter_bundle_by_memory_dimensions_with_labels(bundle, dimensions, &labels_by_entry(bundle))
1129}
1130
1131fn filter_bundle_by_memory_dimensions_with_labels(
1132    bundle: &KmpBundle,
1133    dimensions: &DimensionSelection,
1134    labels: &BTreeMap<String, EntryLabels>,
1135) -> Result<KmpBundle, ApplicationError> {
1136    let mut included_node_ids = BTreeSet::from([bundle.root_node().node_id().to_string()]);
1137    let mut selected_entry_ids = BTreeSet::new();
1138    let node_kinds = bundle_node_kinds(bundle);
1139
1140    for relationship in bundle
1141        .relationships()
1142        .iter()
1143        .filter(|relationship| relationship.relationship_type() == "contains_entry")
1144    {
1145        if contains_entry_selected(relationship, dimensions, labels) {
1146            included_node_ids.insert(relationship.source_node_id().to_string());
1147            included_node_ids.insert(relationship.target_node_id().to_string());
1148            selected_entry_ids.insert(relationship.target_node_id().to_string());
1149        }
1150    }
1151
1152    for relationship in bundle.relationships().iter().filter(|relationship| {
1153        relationship.relationship_type() == "supports"
1154            && selected_entry_ids.contains(relationship.target_node_id())
1155            && node_kinds
1156                .get(relationship.source_node_id())
1157                .is_some_and(|kind| is_memory_evidence_kind(kind))
1158    }) {
1159        included_node_ids.insert(relationship.source_node_id().to_string());
1160    }
1161
1162    let neighbor_nodes = bundle
1163        .neighbor_nodes()
1164        .iter()
1165        .filter(|node| included_node_ids.contains(node.node_id()))
1166        .cloned()
1167        .collect::<Vec<_>>();
1168    let relationships = bundle
1169        .relationships()
1170        .iter()
1171        .filter(|relationship| {
1172            if relationship.relationship_type() == "contains_entry" {
1173                return contains_entry_selected(relationship, dimensions, labels);
1174            }
1175            included_node_ids.contains(relationship.source_node_id())
1176                && included_node_ids.contains(relationship.target_node_id())
1177        })
1178        .cloned()
1179        .collect::<Vec<_>>();
1180    let node_details = bundle
1181        .node_details()
1182        .iter()
1183        .filter(|detail| included_node_ids.contains(detail.node_id()))
1184        .cloned()
1185        .collect::<Vec<_>>();
1186
1187    KmpBundle::new(
1188        bundle.root_node_id().clone(),
1189        bundle.role().clone(),
1190        bundle.root_node().clone(),
1191        neighbor_nodes,
1192        relationships,
1193        node_details,
1194        bundle.metadata().clone(),
1195    )
1196    .map_err(Into::into)
1197}
1198
1199/// A `contains_entry` edge survives when its coordinate passes the
1200/// coordinate filters and the entry it points at passes every selector:
1201/// the first reads one coordinate, the second the entry's whole label map.
1202fn contains_entry_selected(
1203    relationship: &BundleRelationship,
1204    dimensions: &DimensionSelection,
1205    labels: &BTreeMap<String, EntryLabels>,
1206) -> bool {
1207    let explanation = relationship.explanation();
1208    let coordinate_passes = dimensions.includes_coordinate(
1209        explanation.dimension().unwrap_or_default(),
1210        explanation.scope_id().unwrap_or_default(),
1211    );
1212    coordinate_passes
1213        && (!dimensions.has_selectors()
1214            || dimensions.admits(
1215                labels
1216                    .get(relationship.target_node_id())
1217                    .unwrap_or(&EntryLabels::default()),
1218            ))
1219}
1220
1221fn bundle_node_kinds(bundle: &KmpBundle) -> BTreeMap<&str, &str> {
1222    let mut node_kinds =
1223        BTreeMap::from([(bundle.root_node().node_id(), bundle.root_node().node_kind())]);
1224    for node in bundle.neighbor_nodes() {
1225        node_kinds.insert(node.node_id(), node.node_kind());
1226    }
1227    node_kinds
1228}
1229
1230fn is_memory_evidence_kind(kind: &str) -> bool {
1231    matches!(kind, "memory_evidence" | "evidence")
1232}
1233
1234fn existing_refs_from_bundle(bundle: &KmpBundle) -> ExistingMemoryRefs {
1235    let mut refs = BTreeSet::from([bundle.root_node().node_id().to_string()]);
1236    let mut dimensions = BTreeSet::new();
1237    let mut max_sequences = BTreeMap::new();
1238
1239    let mut labels = BTreeSet::new();
1240    for node in bundle.neighbor_nodes() {
1241        refs.insert(node.node_id().to_string());
1242        if node.node_kind() == "memory_dimension" {
1243            dimensions.insert(node.node_id().to_string());
1244            if let Some(kind) = node.properties().get("dimension_kind") {
1245                let value = MemoryDimensionIdentity::parse(node.node_id())
1246                    .map(|identity| identity.dimension_id().to_string())
1247                    .unwrap_or_else(|| node.node_id().to_string());
1248                labels.insert((kind.clone(), value));
1249            }
1250        }
1251    }
1252
1253    for relationship in bundle
1254        .relationships()
1255        .iter()
1256        .filter(|relationship| relationship.relationship_type() == "contains_entry")
1257    {
1258        dimensions.insert(relationship.source_node_id().to_string());
1259        let explanation = relationship.explanation();
1260        if let (Some(dimension), Some(scope_id), Some(sequence)) = (
1261            explanation.dimension(),
1262            explanation.scope_id(),
1263            explanation.sequence(),
1264        ) {
1265            max_sequences
1266                .entry((dimension.to_string(), scope_id.to_string()))
1267                .and_modify(|current: &mut u32| *current = (*current).max(sequence))
1268                .or_insert(sequence);
1269        }
1270    }
1271
1272    ExistingMemoryRefs {
1273        refs,
1274        dimensions,
1275        labels,
1276        max_sequences,
1277        foreign: BTreeSet::new(),
1278    }
1279}
1280
1281fn merge_context_results(
1282    mut results: Vec<GetContextResult>,
1283    render_options: &ContextRenderOptions,
1284) -> Result<GetContextResult, ApplicationError> {
1285    let mut result = results.remove(0);
1286    if results.is_empty() {
1287        return Ok(result);
1288    }
1289
1290    let bundles = std::iter::once(result.bundle)
1291        .chain(results.into_iter().map(|other| other.bundle))
1292        .collect();
1293    result.bundle = super::merge_memory_bundles::merge(bundles)?;
1294    result.rendered = render_graph_bundle_with_options(&result.bundle, render_options);
1295    Ok(result)
1296}
1297
1298#[cfg(test)]
1299mod tests {
1300    use std::collections::BTreeMap;
1301
1302    use kmp_domain::{BundleMetadata, CaseId, RelationExplanation, RelationSemanticClass, Role};
1303
1304    use super::*;
1305
1306    #[test]
1307    fn all_abouts_scope_requires_about_index_resolution() {
1308        let selection = DimensionSelection::all().with_all_about_scope();
1309        let error = context_roots("question:current", &selection)
1310            .expect_err("ALL_ABOUTS must not fall back to current about directly");
1311
1312        assert!(matches!(
1313            error,
1314            ApplicationError::Validation(message)
1315                if message.contains("resolved through the memory about index")
1316        ));
1317    }
1318
1319    #[test]
1320    fn requested_scopes_preserves_keys_as_index_hints() {
1321        let selection = DimensionSelection::only(["timeline"]).with_all_about_scope();
1322        let scopes = requested_dimension_scopes(
1323            "question:current",
1324            &selection,
1325            &["question:a".to_string(), "question:b".to_string()],
1326        );
1327
1328        assert_eq!(scopes, vec!["timeline".to_string()]);
1329    }
1330
1331    #[test]
1332    fn requested_scopes_preserves_values_and_exact_refs_as_index_hints() {
1333        let selection = DimensionSelection::only(["conversation"])
1334            .with_about_scope(["question:a", "question:b"])
1335            .with_scope_ids([
1336                "conversation:alpha",
1337                "label:v1:question%3Ab:conversation:conversation%3Abeta",
1338            ]);
1339        let scopes = requested_dimension_scopes("question:current", &selection, &[]);
1340
1341        assert_eq!(
1342            scopes,
1343            vec![
1344                "conversation:alpha".to_string(),
1345                "label:v1:question%3Ab:conversation:conversation%3Abeta".to_string()
1346            ]
1347        );
1348    }
1349
1350    #[test]
1351    fn bundle_filter_narrows_same_dimension_kind_by_exact_scope_id() {
1352        let bundle = scoped_conversation_bundle();
1353        let selection = DimensionSelection::only(["conversation"])
1354            .resolve_current_about("question:a")
1355            .with_scope_ids(["conversation:alpha"]);
1356
1357        let filtered =
1358            filter_bundle_by_memory_dimensions(&bundle, &selection).expect("bundle should filter");
1359        let node_ids = filtered
1360            .neighbor_nodes()
1361            .iter()
1362            .map(|node| node.node_id())
1363            .collect::<Vec<_>>();
1364        let relationships = filtered
1365            .relationships()
1366            .iter()
1367            .map(|relationship| {
1368                (
1369                    relationship.source_node_id(),
1370                    relationship.target_node_id(),
1371                    relationship.relationship_type(),
1372                )
1373            })
1374            .collect::<Vec<_>>();
1375
1376        assert!(node_ids.contains(&"label:v1:question%3Aa:conversation:conversation%3Aalpha"));
1377        assert!(node_ids.contains(&"claim:alpha"));
1378        assert!(!node_ids.contains(&"label:v1:question%3Aa:conversation:conversation%3Abeta"));
1379        assert!(!node_ids.contains(&"claim:beta"));
1380        assert_eq!(
1381            relationships,
1382            vec![(
1383                "label:v1:question%3Aa:conversation:conversation%3Aalpha",
1384                "claim:alpha",
1385                "contains_entry"
1386            )]
1387        );
1388    }
1389
1390    #[test]
1391    fn bundle_filter_only_pulls_support_sources_when_source_is_memory_evidence() {
1392        let bundle = scoped_conversation_bundle_with_supports();
1393        let selection = DimensionSelection::only(["conversation"])
1394            .resolve_current_about("question:a")
1395            .with_scope_ids(["conversation:alpha"]);
1396
1397        let filtered =
1398            filter_bundle_by_memory_dimensions(&bundle, &selection).expect("bundle should filter");
1399        let node_ids = filtered
1400            .neighbor_nodes()
1401            .iter()
1402            .map(|node| node.node_id())
1403            .collect::<Vec<_>>();
1404        let relationships = filtered
1405            .relationships()
1406            .iter()
1407            .map(|relationship| {
1408                (
1409                    relationship.source_node_id(),
1410                    relationship.target_node_id(),
1411                    relationship.relationship_type(),
1412                )
1413            })
1414            .collect::<Vec<_>>();
1415
1416        assert!(node_ids.contains(&"claim:alpha"));
1417        assert!(node_ids.contains(&"evidence:alpha"));
1418        assert!(!node_ids.contains(&"claim:beta"));
1419        assert_eq!(
1420            relationships,
1421            vec![
1422                (
1423                    "label:v1:question%3Aa:conversation:conversation%3Aalpha",
1424                    "claim:alpha",
1425                    "contains_entry"
1426                ),
1427                ("evidence:alpha", "claim:alpha", "supports")
1428            ]
1429        );
1430    }
1431
1432    #[test]
1433    fn normalize_about_roots_trims_sorts_and_deduplicates() {
1434        assert_eq!(
1435            normalize_about_roots(vec![
1436                " question:b ".to_string(),
1437                String::new(),
1438                "question:a".to_string(),
1439                "question:b".to_string(),
1440            ]),
1441            vec!["question:a".to_string(), "question:b".to_string()]
1442        );
1443    }
1444
1445    /// A sweep is narrowed by dimension kind through `include` and by exact
1446    /// scope through `scope_ids`; either alone is enough to ask the index.
1447    #[test]
1448    fn a_sweep_is_narrowed_by_kinds_or_by_exact_scopes() {
1449        let by_kind = DimensionSelection::only(["incident"]).with_all_about_scope();
1450        assert!(should_filter_all_abouts_by_dimensions(&by_kind));
1451        let by_scope = DimensionSelection::all()
1452            .with_all_about_scope()
1453            .with_scope_ids(["incident:north-outage"]);
1454        assert!(should_filter_all_abouts_by_dimensions(&by_scope));
1455        let whole_sweep = DimensionSelection::all().with_all_about_scope();
1456        assert!(!should_filter_all_abouts_by_dimensions(&whole_sweep));
1457        let inside_one_about = DimensionSelection::only(["incident"]);
1458        assert!(!should_filter_all_abouts_by_dimensions(&inside_one_about));
1459    }
1460
1461    #[test]
1462    fn prioritize_current_about_keeps_all_roots_but_moves_current_first() {
1463        assert_eq!(
1464            prioritize_current_about(
1465                vec![
1466                    "question:a".to_string(),
1467                    "question:current".to_string(),
1468                    "question:z".to_string(),
1469                ],
1470                "question:current",
1471            ),
1472            vec![
1473                "question:current".to_string(),
1474                "question:a".to_string(),
1475                "question:z".to_string()
1476            ]
1477        );
1478    }
1479
1480    #[test]
1481    fn bundle_filter_reads_selectors_over_the_entry_not_the_coordinate() {
1482        use kmp_domain::LabelSelectorOperator;
1483
1484        let bundle = scoped_conversation_bundle();
1485        let keeps = |selection: DimensionSelection| {
1486            filter_bundle_by_memory_dimensions(&bundle, &selection)
1487                .expect("bundle should filter")
1488                .relationships()
1489                .iter()
1490                .filter(|relationship| relationship.relationship_type() == "contains_entry")
1491                .map(|relationship| relationship.target_node_id().to_string())
1492                .collect::<Vec<_>>()
1493        };
1494        let selector = |key: &str, operator: LabelSelectorOperator, values: &[&str]| {
1495            LabelSelector::new(key, operator, values.iter().copied()).expect("selector")
1496        };
1497
1498        assert_eq!(
1499            keeps(DimensionSelection::all().with_selectors([selector(
1500                "conversation",
1501                LabelSelectorOperator::In,
1502                &["conversation:alpha"]
1503            )])),
1504            vec!["claim:alpha"]
1505        );
1506        assert_eq!(
1507            keeps(DimensionSelection::all().with_selectors([selector(
1508                "conversation",
1509                LabelSelectorOperator::NotIn,
1510                &["conversation:alpha"]
1511            )])),
1512            vec!["claim:beta"]
1513        );
1514        assert!(
1515            keeps(DimensionSelection::all().with_selectors([selector(
1516                "conversation",
1517                LabelSelectorOperator::NotExists,
1518                &[]
1519            )]))
1520            .is_empty()
1521        );
1522        assert_eq!(
1523            keeps(DimensionSelection::all().with_selectors([selector(
1524                "conversation",
1525                LabelSelectorOperator::Exists,
1526                &[]
1527            )])),
1528            vec!["claim:alpha", "claim:beta"]
1529        );
1530    }
1531
1532    #[test]
1533    fn all_abouts_index_reads_positive_selectors_and_never_the_except_list() {
1534        use kmp_domain::LabelSelectorOperator;
1535
1536        let by_selector = DimensionSelection::all()
1537            .with_all_about_scope()
1538            .with_selectors([
1539                LabelSelector::new(
1540                    "incident",
1541                    LabelSelectorOperator::Exists,
1542                    Vec::<String>::new(),
1543                )
1544                .expect("selector"),
1545                LabelSelector::new("env", LabelSelectorOperator::In, ["prod"]).expect("selector"),
1546            ]);
1547        assert!(should_filter_all_abouts_by_dimensions(&by_selector));
1548        assert_eq!(index_dimension_ids(&by_selector), vec!["incident", "prod"]);
1549
1550        let negative_only = DimensionSelection::except(["task"])
1551            .with_all_about_scope()
1552            .with_selectors([LabelSelector::new(
1553                "customer",
1554                LabelSelectorOperator::NotIn,
1555                ["acme"],
1556            )
1557            .expect("selector")]);
1558        assert!(!should_filter_all_abouts_by_dimensions(&negative_only));
1559        assert!(index_dimension_ids(&negative_only).is_empty());
1560    }
1561
1562    fn scoped_conversation_bundle() -> KmpBundle {
1563        KmpBundle::new(
1564            CaseId::new("question:a").expect("case id should be valid"),
1565            Role::new("temporal-reader").expect("role should be valid"),
1566            BundleNode::new(
1567                "question:a",
1568                "question",
1569                "Question A",
1570                "Test question",
1571                "ACTIVE",
1572                Vec::new(),
1573                BTreeMap::new(),
1574            ),
1575            vec![
1576                memory_dimension_node("label:v1:question%3Aa:conversation:conversation%3Aalpha"),
1577                memory_dimension_node("label:v1:question%3Aa:conversation:conversation%3Abeta"),
1578                claim_node("claim:alpha"),
1579                claim_node("claim:beta"),
1580            ],
1581            vec![
1582                contains_entry(
1583                    "label:v1:question%3Aa:conversation:conversation%3Aalpha",
1584                    "claim:alpha",
1585                    1,
1586                ),
1587                contains_entry(
1588                    "label:v1:question%3Aa:conversation:conversation%3Abeta",
1589                    "claim:beta",
1590                    2,
1591                ),
1592                cross_scope_constraint("claim:beta", "claim:alpha"),
1593            ],
1594            Vec::new(),
1595            BundleMetadata::initial("test"),
1596        )
1597        .expect("test bundle should be valid")
1598    }
1599
1600    fn scoped_conversation_bundle_with_supports() -> KmpBundle {
1601        KmpBundle::new(
1602            CaseId::new("question:a").expect("case id should be valid"),
1603            Role::new("temporal-reader").expect("role should be valid"),
1604            BundleNode::new(
1605                "question:a",
1606                "question",
1607                "Question A",
1608                "Test question",
1609                "ACTIVE",
1610                Vec::new(),
1611                BTreeMap::new(),
1612            ),
1613            vec![
1614                memory_dimension_node("label:v1:question%3Aa:conversation:conversation%3Aalpha"),
1615                memory_dimension_node("label:v1:question%3Aa:conversation:conversation%3Abeta"),
1616                claim_node("claim:alpha"),
1617                claim_node("claim:beta"),
1618                evidence_node("evidence:alpha"),
1619            ],
1620            vec![
1621                contains_entry(
1622                    "label:v1:question%3Aa:conversation:conversation%3Aalpha",
1623                    "claim:alpha",
1624                    1,
1625                ),
1626                contains_entry(
1627                    "label:v1:question%3Aa:conversation:conversation%3Abeta",
1628                    "claim:beta",
1629                    2,
1630                ),
1631                supports("claim:beta", "claim:alpha"),
1632                supports("evidence:alpha", "claim:alpha"),
1633            ],
1634            Vec::new(),
1635            BundleMetadata::initial("test"),
1636        )
1637        .expect("test bundle should be valid")
1638    }
1639
1640    fn memory_dimension_node(node_id: &str) -> BundleNode {
1641        BundleNode::new(
1642            node_id,
1643            "memory_dimension",
1644            node_id,
1645            "Conversation scope",
1646            "ACTIVE",
1647            Vec::new(),
1648            BTreeMap::new(),
1649        )
1650    }
1651
1652    fn claim_node(node_id: &str) -> BundleNode {
1653        BundleNode::new(
1654            node_id,
1655            "claim",
1656            node_id,
1657            "Claim",
1658            "ACTIVE",
1659            Vec::new(),
1660            BTreeMap::new(),
1661        )
1662    }
1663
1664    fn evidence_node(node_id: &str) -> BundleNode {
1665        BundleNode::new(
1666            node_id,
1667            "memory_evidence",
1668            node_id,
1669            "Evidence",
1670            "ACTIVE",
1671            Vec::new(),
1672            BTreeMap::new(),
1673        )
1674    }
1675
1676    fn contains_entry(scope_id: &str, target_node_id: &str, sequence: u32) -> BundleRelationship {
1677        BundleRelationship::new(
1678            scope_id,
1679            target_node_id,
1680            "contains_entry",
1681            RelationExplanation::new(RelationSemanticClass::Structural)
1682                .with_dimension("conversation")
1683                .with_scope_id(scope_id)
1684                .with_sequence(sequence),
1685        )
1686    }
1687
1688    fn cross_scope_constraint(source_node_id: &str, target_node_id: &str) -> BundleRelationship {
1689        BundleRelationship::new(
1690            source_node_id,
1691            target_node_id,
1692            "contextual_constraint",
1693            RelationExplanation::new(RelationSemanticClass::Constraint)
1694                .with_rationale("Off-scope relation must not leak through exact scope filtering.")
1695                .with_confidence("medium"),
1696        )
1697    }
1698
1699    fn supports(source_node_id: &str, target_node_id: &str) -> BundleRelationship {
1700        BundleRelationship::new(
1701            source_node_id,
1702            target_node_id,
1703            "supports",
1704            RelationExplanation::new(RelationSemanticClass::Evidential)
1705                .with_rationale("Support relation for scoped filtering.")
1706                .with_confidence("medium"),
1707        )
1708    }
1709}