Skip to main content

memstead_base/ingest/
resolve.rs

1//! Ingest runtime resolution — turn a stored v2 [`Binding`] into a
2//! [`ResolvedIngest`]: the shape the selection, backoff, change-detection,
3//! and brief-assembly stages all read.
4//!
5//! Since the 2026-07 single-record consolidation there is **no join**: a v2
6//! binding carries its sources inline, so resolution is a pure unpacking —
7//! the binding id supplies the identity, the `operations.build` block the
8//! schedule, and each inline [`Source`] *is* the resolved primary source.
9//! The cross-record reference errors of the three-file era (dangling facet /
10//! medium refs) are gone with the references; in-record source validation
11//! lives in [`crate::binding::validate_binding`].
12//!
13//! Resolving a source's *change-detection strategy* (which reads the source's
14//! declared strategy and probes the filesystem for a git work tree) is the
15//! separate, filesystem-touching concern at the bottom of this module.
16
17use std::path::{Path, PathBuf};
18
19use crate::binding::{Binding, BuildMode, medium_capabilities};
20pub use crate::pipeline::Source;
21use crate::pipeline::{IngestTrigger, MediumType};
22
23/// A binding source resolved to what the run needs: a **primary** inline
24/// source (the territory to read and write back), or a read-only
25/// **reference** mem supplying cross-mem context.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum ResolvedSource {
28    /// An inline primary source (both halves: where it lives / which part).
29    Primary(Source),
30    /// A read-only reference mem (cross-mem context, never written).
31    Reference {
32        /// The reference mem's id.
33        mem: String,
34    },
35}
36
37/// A v2 binding unpacked into the runtime shape the orchestration stages
38/// (cursor, brief, selection) consume.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct ResolvedIngest {
41    /// The run's identity — the canonical binding id `<mem>/<stem>`, the
42    /// string every downstream key (sync_state, selection cache, brief header)
43    /// derives from.
44    pub name: String,
45    /// The build mode — discovery / one-shot (`refinement` is deleted).
46    /// Defaults to discovery when the binding declares no `build` block.
47    pub mode: BuildMode,
48    /// Loop / manual / on-event.
49    pub trigger: IngestTrigger,
50    /// How many artifacts a single run processes.
51    pub batch_size: u32,
52    /// Paths excluded for this binding's runs, on top of source scope.
53    pub deny_paths: Vec<String>,
54    /// The binding id verbatim (`"<mem>/<name>"`).
55    pub projection_ref: String,
56    /// The binding's owning mem (the part before the `/`).
57    pub projection_mem: String,
58    /// The binding's name (the part after the `/`).
59    pub projection_name: String,
60    /// The binding's intent — prose for the agent (the brief's "about
61    /// the source" block).
62    pub intent: Option<String>,
63    /// The resolved sources, in declaration order: inline primaries first,
64    /// then reference mems.
65    pub sources: Vec<ResolvedSource>,
66    /// The single mem this binding writes into.
67    pub destination_mem: String,
68    /// Free-form binding rules (e.g. one-shot lens `routing`).
69    pub rules: Option<serde_json::Value>,
70    /// Free-form post-run actions (e.g. one-shot `archive_source`).
71    pub post_actions: Option<serde_json::Value>,
72}
73
74/// Why a binding could not be resolved. With inline sources the only
75/// structural failures left are identity-level: an unknown binding id, or a
76/// malformed one.
77#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
78pub enum ResolveError {
79    /// No binding with the given id in the store.
80    #[error("binding '{name}' not found; available: {}", fmt_list(available))]
81    BindingNotFound {
82        /// The requested binding id.
83        name: String,
84        /// The binding ids that do exist.
85        available: Vec<String>,
86    },
87    /// The binding id is not the required `"<mem>/<name>"`.
88    #[error("malformed binding id '{projection}'; expected \"<mem>/<name>\"")]
89    MalformedProjectionRef {
90        /// The binding whose id is malformed.
91        ingest: String,
92        /// The malformed value.
93        projection: String,
94    },
95    /// A source's scope carries a pattern its medium's namespace cannot
96    /// express, so the declared selection reaches nothing. Refused when the
97    /// binding is resolved for a run, not only when it is edited: a scope
98    /// nothing interprets is decorative wherever it came from, and
99    /// hand-editing the record is a route the CLI's own refusal text
100    /// recommends.
101    #[error("binding '{binding}' cannot run: {reason}")]
102    UninterpretableScope {
103        /// The binding whose scope cannot be interpreted.
104        binding: String,
105        /// What is wrong and how to fix it.
106        reason: String,
107    },
108}
109
110/// Render a name list for an error message: `a, b, c` or `(none)`.
111fn fmt_list(names: &[String]) -> String {
112    if names.is_empty() {
113        "(none)".to_string()
114    } else {
115        names.join(", ")
116    }
117}
118
119/// Unpack a **v2 binding** (by canonical id) into the runtime
120/// [`ResolvedIngest`] shape the orchestration stages (cursor, brief,
121/// selection) consume.
122///
123/// The binding *is* the whole declaration: its inline sources become the
124/// primary [`ResolvedSource`]s verbatim, its `operations.build` supplies the
125/// run mode / trigger / batch / post-actions, and `deny_paths` / `intent` /
126/// `rules` come straight off the record. `binding_id` is the canonical
127/// `<mem>/<stem>`, which becomes the resolved ingest's `name` and
128/// `projection_ref` — every downstream key (sync_state, selection cache,
129/// brief header) is derived from it. A malformed `binding_id` is
130/// [`ResolveError::MalformedProjectionRef`]. Pure — no I/O.
131pub fn resolve_binding_run(
132    binding_id: &str,
133    binding: &Binding,
134) -> Result<ResolvedIngest, ResolveError> {
135    let (mem, name) = binding_id
136        .split_once('/')
137        .filter(|(m, n)| !m.is_empty() && !n.is_empty())
138        .ok_or_else(|| ResolveError::MalformedProjectionRef {
139            ingest: binding_id.to_string(),
140            projection: binding_id.to_string(),
141        })?;
142    let mem = mem.to_string();
143    let name = name.to_string();
144
145    // A scope rule nothing interprets is refused HERE, on the run path, and
146    // not only on the edit paths that call `validate_binding`. Scaffolding the
147    // right shape only protects bindings this engine wrote: every graph
148    // binding scaffolded before the entity vocabulary existed carries the path
149    // glob `**/*`, and a hand-edited record is a route the CLI's own
150    // name-collision refusal points people at. Left unguarded, such a binding
151    // ran clean over an S(D) of zero and recorded a `#verified` baseline for a
152    // measurement that never happened.
153    for source in &binding.sources {
154        match source.medium_type {
155            crate::pipeline::MediumType::Graph => {
156                for rule in &source.scope {
157                    if crate::ingest::cursor::parse_entity_selector(&rule.path).is_none() {
158                        return Err(ResolveError::UninterpretableScope {
159                            binding: binding_id.to_string(),
160                            reason: format!(
161                                "source '{}' is a graph medium, but its scope pattern '{}' is \
162                                 not an entity selector — a graph source selects entities, not \
163                                 paths. Write '*' for the whole mem, 'type:<entity_type>', or \
164                                 'id:<glob>'",
165                                source.name, rule.path
166                            ),
167                        });
168                    }
169                }
170            }
171            // A web source has no scope vocabulary at all, so any rule on it
172            // is uninterpretable — the same class, and gating the run-path
173            // refusal on graph alone is how it survived the declaration gate
174            // being graph-only.
175            crate::pipeline::MediumType::Web => {
176                if let Some(rule) = source.scope.first() {
177                    return Err(ResolveError::UninterpretableScope {
178                        binding: binding_id.to_string(),
179                        reason: format!(
180                            "source '{}' is a web medium, which has no scope vocabulary — its \
181                             scope pattern '{}' would select nothing while looking like \
182                             selection. Remove the scope rule",
183                            source.name, rule.path
184                        ),
185                    });
186                }
187            }
188            crate::pipeline::MediumType::Codebase
189            | crate::pipeline::MediumType::Filesystem
190            | crate::pipeline::MediumType::Git => {}
191        }
192    }
193
194    let mut sources = Vec::with_capacity(binding.sources.len() + binding.reference_mems.len());
195    for source in &binding.sources {
196        sources.push(ResolvedSource::Primary(source.clone()));
197    }
198    for reference_mem in &binding.reference_mems {
199        sources.push(ResolvedSource::Reference {
200            mem: reference_mem.clone(),
201        });
202    }
203
204    // The build op supplies mode / trigger / batch / post-actions. An absent
205    // build block (a not-yet-built obligation) resolves to sane defaults — the
206    // build-path refusal is enforced at the brief entry point, not here, so
207    // read-only callers (status) keep working.
208    let build = binding.operations.build.as_ref();
209    let mode = build.map_or(BuildMode::Discovery, |b| b.mode);
210    let trigger = build.map_or(IngestTrigger::Loop, |b| b.trigger);
211    let batch_size = build.map_or(20, |b| b.batch_size);
212    let post_actions = build.and_then(|b| b.post_actions.clone());
213
214    Ok(ResolvedIngest {
215        name: binding_id.to_string(),
216        mode,
217        trigger,
218        batch_size,
219        deny_paths: binding.deny_paths.clone(),
220        projection_ref: binding_id.to_string(),
221        projection_mem: mem,
222        projection_name: name,
223        intent: binding.intent.clone(),
224        sources,
225        destination_mem: binding.destination_mem.clone(),
226        rules: binding.rules.clone(),
227        post_actions,
228    })
229}
230
231/// A primary source's resolved change-detection strategy — how "what
232/// changed since the last synced pass" is computed for it.
233#[derive(Debug, Clone, Copy, PartialEq, Eq)]
234pub enum ChangeStrategy {
235    /// No change detection — the source is re-roamed whole (inert signal).
236    None,
237    /// Git-commit diff between the baseline commit and current `HEAD`.
238    Git,
239    /// Filesystem `(mtime, size)` stat-map digest + diff.
240    Mtime,
241    /// Graph snapshot diff (the source mem's snapshot token).
242    Graph,
243}
244
245/// Resolve a primary source's [`ChangeStrategy`]. A graph-typed source
246/// always uses [`Graph`] (its signal is the source mem's snapshot token,
247/// which the engine provides). Otherwise the source's declared strategy wins
248/// (`none`/`git`/`mtime`); `auto` — the default when unset, and any
249/// unrecognized value — probes for a git work tree over the source pointer
250/// (resolved against `workspace_root`): present → [`Git`], absent →
251/// [`Mtime`].
252///
253/// [`Graph`]: ChangeStrategy::Graph
254/// [`Git`]: ChangeStrategy::Git
255/// [`Mtime`]: ChangeStrategy::Mtime
256pub fn resolve_change_strategy(source: &Source, workspace_root: &Path) -> ChangeStrategy {
257    if source.medium_type == MediumType::Graph {
258        return ChangeStrategy::Graph;
259    }
260    // A detection-less medium (per the capability matrix — `web` this cycle)
261    // has no change signal: it resolves to the visible NoSignal (`none`), never
262    // a fabricated `mtime`/`git` token. This mirrors the graph special case
263    // above — the medium type overrides any declared value.
264    if !medium_capabilities(source.medium_type).change_signal {
265        return ChangeStrategy::None;
266    }
267    match source.change_detection.as_deref() {
268        Some("none") => ChangeStrategy::None,
269        // A declared strategy is NOT second-guessed here: this resolver maps
270        // the declaration, and an author who writes `git` means git. Whether
271        // the checkout can actually deliver that signal is a question about
272        // this pass, not about the binding — a `git archive` or Docker `COPY`
273        // has the sources and no `.git`, and the honest place to say so is the
274        // freshness row, which reports what the run observed. See
275        // `FacetFreshness::change_detectable`.
276        Some("git") => ChangeStrategy::Git,
277        Some("mtime") => ChangeStrategy::Mtime,
278        // `auto`, unset, or any unrecognized value: probe the filesystem.
279        _ => {
280            if find_git_root(&source_base_path(source, workspace_root)).is_some() {
281                ChangeStrategy::Git
282            } else {
283                ChangeStrategy::Mtime
284            }
285        }
286    }
287}
288
289/// The on-disk base directory a path-based primary source resolves to: the
290/// source pointer joined onto the workspace root (`Path::join` yields the
291/// pointer verbatim when it is absolute), or the workspace root itself for
292/// an empty pointer. Only meaningful for path-namespaced mediums
293/// (codebase / filesystem / git) — a graph pointer is a mem id and a web
294/// pointer a URL.
295pub fn source_base_path(source: &Source, workspace_root: &Path) -> PathBuf {
296    if source.pointer.is_empty() {
297        workspace_root.to_path_buf()
298    } else {
299        workspace_root.join(&source.pointer)
300    }
301}
302
303/// Walk up from `start` looking for a `.git` entry (a directory *or* a file
304/// — a submodule/worktree gitlink is a file), returning the directory that
305/// contains it (the git work-tree root), or `None`. Bounded to 64 ancestors.
306/// Pure filesystem, no subprocess — deterministic for tests.
307pub fn find_git_root(start: &Path) -> Option<PathBuf> {
308    let mut dir = start.to_path_buf();
309    for _ in 0..64 {
310        if dir.join(".git").exists() {
311            return Some(dir);
312        }
313        match dir.parent() {
314            Some(parent) => dir = parent.to_path_buf(),
315            None => break,
316        }
317    }
318    None
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use crate::binding::{BINDING_VERSION, BuildOperation, Operations};
325    use crate::pipeline::{PatternEntry, PatternMode};
326
327    fn source(
328        name: &str,
329        medium_type: MediumType,
330        pointer: &str,
331        declared: Option<&str>,
332    ) -> Source {
333        Source {
334            name: name.to_string(),
335            medium_type,
336            pointer: pointer.to_string(),
337            change_detection: declared.map(str::to_string),
338            scope: vec![],
339            engagement: None,
340            preparation: None,
341        }
342    }
343
344    fn allow(path: &str) -> PatternEntry {
345        PatternEntry {
346            path: path.to_string(),
347            mode: PatternMode::Allow,
348        }
349    }
350
351    fn v2_binding(dest: &str, sources: Vec<Source>) -> Binding {
352        Binding {
353            version: BINDING_VERSION,
354            intent: Some("prose".to_string()),
355            sources,
356            reference_mems: vec![],
357            destination_mem: dest.to_string(),
358            deny_paths: vec![],
359            coverage_semantics: None,
360            rules: None,
361            prune: None,
362            operations: Operations {
363                build: Some(BuildOperation {
364                    mode: BuildMode::Discovery,
365                    trigger: IngestTrigger::Loop,
366                    batch_size: 20,
367                    post_actions: None,
368                }),
369                sync: None,
370                verify: None,
371            },
372        }
373    }
374
375    /// `resolve_binding_run` produces the runtime shape from a v2 binding
376    /// without any join: the id becomes `name`/`projection_ref`, `build.mode`
377    /// maps to the run mode, deny_paths / trigger / batch / post_actions come
378    /// off the build op, and each inline source carries over verbatim;
379    /// reference mems follow the primaries.
380    #[test]
381    fn resolve_binding_run_produces_runtime_shape() {
382        let mut swift_source = source("source-tree", MediumType::Codebase, "../app", None);
383        swift_source.scope = vec![allow("../app/**/*.swift")];
384        let mut binding = v2_binding("app", vec![swift_source.clone()]);
385        binding.intent = Some("swift".to_string());
386        binding.reference_mems = vec!["engine".to_string()];
387        binding.deny_paths = vec!["**/VISION.md".to_string()];
388        binding.operations.build.as_mut().unwrap().post_actions =
389            Some(serde_json::json!({ "archive_source": true }));
390
391        let r = resolve_binding_run("app/graph", &binding).unwrap();
392        assert_eq!(r.name, "app/graph");
393        assert_eq!(r.projection_ref, "app/graph");
394        assert_eq!(r.projection_mem, "app");
395        assert_eq!(r.projection_name, "graph");
396        assert_eq!(r.mode, BuildMode::Discovery);
397        assert_eq!(r.batch_size, 20);
398        assert_eq!(r.deny_paths, ["**/VISION.md"]);
399        assert_eq!(r.destination_mem, "app");
400        assert_eq!(r.intent.as_deref(), Some("swift"));
401        assert_eq!(
402            r.post_actions,
403            Some(serde_json::json!({ "archive_source": true }))
404        );
405        assert_eq!(r.sources.len(), 2);
406        assert_eq!(r.sources[0], ResolvedSource::Primary(swift_source));
407        assert_eq!(
408            r.sources[1],
409            ResolvedSource::Reference {
410                mem: "engine".to_string()
411            }
412        );
413    }
414
415    /// A one-shot binding maps to the one-shot run mode.
416    #[test]
417    fn resolve_binding_run_maps_one_shot() {
418        let mut binding = v2_binding("m", vec![]);
419        let build = binding.operations.build.as_mut().unwrap();
420        build.mode = BuildMode::OneShot;
421        build.trigger = IngestTrigger::Manual;
422        build.batch_size = 5;
423        let r = resolve_binding_run("m/lens", &binding).unwrap();
424        assert_eq!(r.mode, BuildMode::OneShot);
425        assert_eq!(r.batch_size, 5);
426    }
427
428    /// A malformed binding id (no `/`) is a located error.
429    #[test]
430    fn resolve_binding_run_malformed_id_errors() {
431        let binding = v2_binding("m", vec![]);
432        let err = resolve_binding_run("noslash", &binding).unwrap_err();
433        assert!(matches!(err, ResolveError::MalformedProjectionRef { .. }));
434    }
435
436    /// An absent build block resolves to sane defaults (read-only callers
437    /// keep working; the mutating-op refusal is enforced at the brief entry).
438    #[test]
439    fn absent_build_resolves_to_defaults() {
440        let mut binding = v2_binding("m", vec![]);
441        binding.operations.build = None;
442        let r = resolve_binding_run("m/p", &binding).unwrap();
443        assert_eq!(r.mode, BuildMode::Discovery);
444        assert_eq!(r.trigger, IngestTrigger::Loop);
445        assert_eq!(r.batch_size, 20);
446        assert_eq!(r.post_actions, None);
447    }
448
449    /// A graph-typed source always resolves to the graph strategy,
450    /// regardless of any declared value.
451    #[test]
452    fn graph_source_always_uses_graph_strategy() {
453        let root = Path::new("/nonexistent");
454        assert_eq!(
455            resolve_change_strategy(&source("f", MediumType::Graph, "", None), root),
456            ChangeStrategy::Graph
457        );
458        // Even a declared override does not change a graph source.
459        assert_eq!(
460            resolve_change_strategy(&source("f", MediumType::Graph, "", Some("mtime")), root),
461            ChangeStrategy::Graph
462        );
463    }
464
465    /// A `web` source (detection-less per the capability matrix) resolves to
466    /// the visible NoSignal `None` regardless of any declared value —
467    /// `status` renders `signal: none`, never a fabricated `mtime`/`git`
468    /// token.
469    #[test]
470    fn web_source_resolves_to_none_signal() {
471        let root = Path::new("/nonexistent");
472        assert_eq!(
473            resolve_change_strategy(
474                &source("w", MediumType::Web, "https://example.com", None),
475                root
476            ),
477            ChangeStrategy::None
478        );
479        // Even a declared override does not fabricate a signal for web.
480        assert_eq!(
481            resolve_change_strategy(
482                &source("w", MediumType::Web, "https://example.com", Some("mtime")),
483                root
484            ),
485            ChangeStrategy::None
486        );
487    }
488
489    /// A declared `none`/`git`/`mtime` wins for a non-graph source.
490    #[test]
491    fn declared_strategy_wins_for_non_graph() {
492        let root = Path::new("/nonexistent");
493        for (declared, expected) in [
494            ("none", ChangeStrategy::None),
495            ("git", ChangeStrategy::Git),
496            ("mtime", ChangeStrategy::Mtime),
497        ] {
498            assert_eq!(
499                resolve_change_strategy(
500                    &source("f", MediumType::Codebase, "x", Some(declared)),
501                    root
502                ),
503                expected,
504                "declared '{declared}'"
505            );
506        }
507    }
508
509    /// `auto` (unset, or an unrecognized value) probes the filesystem: a
510    /// pointer under a git work tree → git, otherwise → mtime.
511    #[test]
512    fn auto_probes_for_a_git_work_tree() {
513        let git = tempfile::tempdir().unwrap();
514        std::fs::create_dir(git.path().join(".git")).unwrap();
515        std::fs::create_dir(git.path().join("sub")).unwrap();
516        let plain = tempfile::tempdir().unwrap();
517
518        // Unset → auto → probe. Pointer resolves under the git root → Git.
519        assert_eq!(
520            resolve_change_strategy(&source("f", MediumType::Codebase, "sub", None), git.path()),
521            ChangeStrategy::Git
522        );
523        // An unrecognized declared value also falls through to the probe.
524        assert_eq!(
525            resolve_change_strategy(
526                &source("f", MediumType::Codebase, "sub", Some("weird")),
527                git.path()
528            ),
529            ChangeStrategy::Git
530        );
531        // No git work tree over the pointer → Mtime.
532        assert_eq!(
533            resolve_change_strategy(
534                &source("f", MediumType::Filesystem, ".", None),
535                plain.path()
536            ),
537            ChangeStrategy::Mtime
538        );
539    }
540
541    /// `find_git_root` returns the containing directory of a `.git` entry,
542    /// walking up from a nested start, and `None` when there is none.
543    #[test]
544    fn find_git_root_walks_up() {
545        let root = tempfile::tempdir().unwrap();
546        std::fs::create_dir(root.path().join(".git")).unwrap();
547        let nested = root.path().join("a/b/c");
548        std::fs::create_dir_all(&nested).unwrap();
549
550        assert_eq!(
551            find_git_root(&nested).as_deref(),
552            Some(root.path()),
553            "walks up to the work-tree root"
554        );
555
556        let plain = tempfile::tempdir().unwrap();
557        assert_eq!(find_git_root(plain.path()), None, "no .git anywhere above");
558    }
559}
560
561/// How a destination mem's process mem was resolved (agent-trust
562/// plan 14): by explicit declaration (`MemConfig.process_mem` on the
563/// destination — wins where present) or by the binding-name
564/// convention (the fallback, byte-identical to the pre-declaration
565/// behaviour). One resolution function for every consumer — the
566/// brief renderer and the open-questions health axis read this and
567/// nothing else, so pairing can never drift between surfaces.
568#[derive(Debug, Clone, PartialEq, Eq)]
569pub struct ProcessMemResolution {
570    /// The resolved process-mem name.
571    pub mem: String,
572    /// Whether that mem is actually mounted.
573    pub mounted: bool,
574    /// True when the name came from the destination's declaration
575    /// rather than the naming convention. A declared-but-unmounted
576    /// resolution is a typed finding for the caller to surface —
577    /// never a silent fallback to derivation.
578    pub declared: bool,
579}
580
581/// Resolve the process mem for `destination_mem`. `derived_name` is
582/// the convention-derived candidate (the binding / ingest name);
583/// pass it even when a declaration might exist — the declaration
584/// wins, the derivation remains the fallback.
585pub fn resolve_process_mem(
586    engine: &crate::Engine,
587    destination_mem: &str,
588    derived_name: &str,
589) -> ProcessMemResolution {
590    let mounted_names = engine.mem_names();
591    if let Some(declared) = engine
592        .mem_config_for(destination_mem)
593        .and_then(|c| c.process_mem.clone())
594    {
595        let mounted = mounted_names.iter().any(|m| *m == declared);
596        return ProcessMemResolution {
597            mem: declared,
598            mounted,
599            declared: true,
600        };
601    }
602    let mounted = mounted_names.contains(&derived_name);
603    ProcessMemResolution {
604        mem: derived_name.to_string(),
605        mounted,
606        declared: false,
607    }
608}