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}
96
97/// Render a name list for an error message: `a, b, c` or `(none)`.
98fn fmt_list(names: &[String]) -> String {
99    if names.is_empty() {
100        "(none)".to_string()
101    } else {
102        names.join(", ")
103    }
104}
105
106/// Unpack a **v2 binding** (by canonical id) into the runtime
107/// [`ResolvedIngest`] shape the orchestration stages (cursor, brief,
108/// selection) consume.
109///
110/// The binding *is* the whole declaration: its inline sources become the
111/// primary [`ResolvedSource`]s verbatim, its `operations.build` supplies the
112/// run mode / trigger / batch / post-actions, and `deny_paths` / `intent` /
113/// `rules` come straight off the record. `binding_id` is the canonical
114/// `<mem>/<stem>`, which becomes the resolved ingest's `name` and
115/// `projection_ref` — every downstream key (sync_state, selection cache,
116/// brief header) is derived from it. A malformed `binding_id` is
117/// [`ResolveError::MalformedProjectionRef`]. Pure — no I/O.
118pub fn resolve_binding_run(
119    binding_id: &str,
120    binding: &Binding,
121) -> Result<ResolvedIngest, ResolveError> {
122    let (mem, name) = binding_id
123        .split_once('/')
124        .filter(|(m, n)| !m.is_empty() && !n.is_empty())
125        .ok_or_else(|| ResolveError::MalformedProjectionRef {
126            ingest: binding_id.to_string(),
127            projection: binding_id.to_string(),
128        })?;
129    let mem = mem.to_string();
130    let name = name.to_string();
131
132    let mut sources = Vec::with_capacity(binding.sources.len() + binding.reference_mems.len());
133    for source in &binding.sources {
134        sources.push(ResolvedSource::Primary(source.clone()));
135    }
136    for reference_mem in &binding.reference_mems {
137        sources.push(ResolvedSource::Reference {
138            mem: reference_mem.clone(),
139        });
140    }
141
142    // The build op supplies mode / trigger / batch / post-actions. An absent
143    // build block (a not-yet-built obligation) resolves to sane defaults — the
144    // build-path refusal is enforced at the brief entry point, not here, so
145    // read-only callers (status) keep working.
146    let build = binding.operations.build.as_ref();
147    let mode = build.map_or(BuildMode::Discovery, |b| b.mode);
148    let trigger = build.map_or(IngestTrigger::Loop, |b| b.trigger);
149    let batch_size = build.map_or(20, |b| b.batch_size);
150    let post_actions = build.and_then(|b| b.post_actions.clone());
151
152    Ok(ResolvedIngest {
153        name: binding_id.to_string(),
154        mode,
155        trigger,
156        batch_size,
157        deny_paths: binding.deny_paths.clone(),
158        projection_ref: binding_id.to_string(),
159        projection_mem: mem,
160        projection_name: name,
161        intent: binding.intent.clone(),
162        sources,
163        destination_mem: binding.destination_mem.clone(),
164        rules: binding.rules.clone(),
165        post_actions,
166    })
167}
168
169/// A primary source's resolved change-detection strategy — how "what
170/// changed since the last synced pass" is computed for it.
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172pub enum ChangeStrategy {
173    /// No change detection — the source is re-roamed whole (inert signal).
174    None,
175    /// Git-commit diff between the baseline commit and current `HEAD`.
176    Git,
177    /// Filesystem `(mtime, size)` stat-map digest + diff.
178    Mtime,
179    /// Graph snapshot diff (the source mem's snapshot token).
180    Graph,
181}
182
183/// Resolve a primary source's [`ChangeStrategy`]. A graph-typed source
184/// always uses [`Graph`] (its signal is the source mem's snapshot token,
185/// which the engine provides). Otherwise the source's declared strategy wins
186/// (`none`/`git`/`mtime`); `auto` — the default when unset, and any
187/// unrecognized value — probes for a git work tree over the source pointer
188/// (resolved against `workspace_root`): present → [`Git`], absent →
189/// [`Mtime`].
190///
191/// [`Graph`]: ChangeStrategy::Graph
192/// [`Git`]: ChangeStrategy::Git
193/// [`Mtime`]: ChangeStrategy::Mtime
194pub fn resolve_change_strategy(source: &Source, workspace_root: &Path) -> ChangeStrategy {
195    if source.medium_type == MediumType::Graph {
196        return ChangeStrategy::Graph;
197    }
198    // A detection-less medium (per the capability matrix — `web` this cycle)
199    // has no change signal: it resolves to the visible NoSignal (`none`), never
200    // a fabricated `mtime`/`git` token. This mirrors the graph special case
201    // above — the medium type overrides any declared value.
202    if !medium_capabilities(source.medium_type).change_signal {
203        return ChangeStrategy::None;
204    }
205    match source.change_detection.as_deref() {
206        Some("none") => ChangeStrategy::None,
207        Some("git") => ChangeStrategy::Git,
208        Some("mtime") => ChangeStrategy::Mtime,
209        // `auto`, unset, or any unrecognized value: probe the filesystem.
210        _ => {
211            if find_git_root(&source_base_path(source, workspace_root)).is_some() {
212                ChangeStrategy::Git
213            } else {
214                ChangeStrategy::Mtime
215            }
216        }
217    }
218}
219
220/// The on-disk base directory a path-based primary source resolves to: the
221/// source pointer joined onto the workspace root (`Path::join` yields the
222/// pointer verbatim when it is absolute), or the workspace root itself for
223/// an empty pointer. Only meaningful for path-namespaced mediums
224/// (codebase / filesystem / git) — a graph pointer is a mem id and a web
225/// pointer a URL.
226pub fn source_base_path(source: &Source, workspace_root: &Path) -> PathBuf {
227    if source.pointer.is_empty() {
228        workspace_root.to_path_buf()
229    } else {
230        workspace_root.join(&source.pointer)
231    }
232}
233
234/// Walk up from `start` looking for a `.git` entry (a directory *or* a file
235/// — a submodule/worktree gitlink is a file), returning the directory that
236/// contains it (the git work-tree root), or `None`. Bounded to 64 ancestors.
237/// Pure filesystem, no subprocess — deterministic for tests.
238pub fn find_git_root(start: &Path) -> Option<PathBuf> {
239    let mut dir = start.to_path_buf();
240    for _ in 0..64 {
241        if dir.join(".git").exists() {
242            return Some(dir);
243        }
244        match dir.parent() {
245            Some(parent) => dir = parent.to_path_buf(),
246            None => break,
247        }
248    }
249    None
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use crate::binding::{BINDING_VERSION, BuildOperation, Operations};
256    use crate::pipeline::{PatternEntry, PatternMode};
257
258    fn source(
259        name: &str,
260        medium_type: MediumType,
261        pointer: &str,
262        declared: Option<&str>,
263    ) -> Source {
264        Source {
265            name: name.to_string(),
266            medium_type,
267            pointer: pointer.to_string(),
268            change_detection: declared.map(str::to_string),
269            scope: vec![],
270            engagement: None,
271            preparation: None,
272        }
273    }
274
275    fn allow(path: &str) -> PatternEntry {
276        PatternEntry {
277            path: path.to_string(),
278            mode: PatternMode::Allow,
279        }
280    }
281
282    fn v2_binding(dest: &str, sources: Vec<Source>) -> Binding {
283        Binding {
284            version: BINDING_VERSION,
285            intent: Some("prose".to_string()),
286            sources,
287            reference_mems: vec![],
288            destination_mem: dest.to_string(),
289            deny_paths: vec![],
290            coverage_semantics: None,
291            rules: None,
292            prune: None,
293            operations: Operations {
294                build: Some(BuildOperation {
295                    mode: BuildMode::Discovery,
296                    trigger: IngestTrigger::Loop,
297                    batch_size: 20,
298                    post_actions: None,
299                }),
300                sync: None,
301                verify: None,
302            },
303        }
304    }
305
306    /// `resolve_binding_run` produces the runtime shape from a v2 binding
307    /// without any join: the id becomes `name`/`projection_ref`, `build.mode`
308    /// maps to the run mode, deny_paths / trigger / batch / post_actions come
309    /// off the build op, and each inline source carries over verbatim;
310    /// reference mems follow the primaries.
311    #[test]
312    fn resolve_binding_run_produces_runtime_shape() {
313        let mut swift_source = source("source-tree", MediumType::Codebase, "../app", None);
314        swift_source.scope = vec![allow("../app/**/*.swift")];
315        let mut binding = v2_binding("app", vec![swift_source.clone()]);
316        binding.intent = Some("swift".to_string());
317        binding.reference_mems = vec!["engine".to_string()];
318        binding.deny_paths = vec!["**/VISION.md".to_string()];
319        binding.operations.build.as_mut().unwrap().post_actions =
320            Some(serde_json::json!({ "archive_source": true }));
321
322        let r = resolve_binding_run("app/graph", &binding).unwrap();
323        assert_eq!(r.name, "app/graph");
324        assert_eq!(r.projection_ref, "app/graph");
325        assert_eq!(r.projection_mem, "app");
326        assert_eq!(r.projection_name, "graph");
327        assert_eq!(r.mode, BuildMode::Discovery);
328        assert_eq!(r.batch_size, 20);
329        assert_eq!(r.deny_paths, ["**/VISION.md"]);
330        assert_eq!(r.destination_mem, "app");
331        assert_eq!(r.intent.as_deref(), Some("swift"));
332        assert_eq!(
333            r.post_actions,
334            Some(serde_json::json!({ "archive_source": true }))
335        );
336        assert_eq!(r.sources.len(), 2);
337        assert_eq!(r.sources[0], ResolvedSource::Primary(swift_source));
338        assert_eq!(
339            r.sources[1],
340            ResolvedSource::Reference {
341                mem: "engine".to_string()
342            }
343        );
344    }
345
346    /// A one-shot binding maps to the one-shot run mode.
347    #[test]
348    fn resolve_binding_run_maps_one_shot() {
349        let mut binding = v2_binding("m", vec![]);
350        let build = binding.operations.build.as_mut().unwrap();
351        build.mode = BuildMode::OneShot;
352        build.trigger = IngestTrigger::Manual;
353        build.batch_size = 5;
354        let r = resolve_binding_run("m/lens", &binding).unwrap();
355        assert_eq!(r.mode, BuildMode::OneShot);
356        assert_eq!(r.batch_size, 5);
357    }
358
359    /// A malformed binding id (no `/`) is a located error.
360    #[test]
361    fn resolve_binding_run_malformed_id_errors() {
362        let binding = v2_binding("m", vec![]);
363        let err = resolve_binding_run("noslash", &binding).unwrap_err();
364        assert!(matches!(err, ResolveError::MalformedProjectionRef { .. }));
365    }
366
367    /// An absent build block resolves to sane defaults (read-only callers
368    /// keep working; the mutating-op refusal is enforced at the brief entry).
369    #[test]
370    fn absent_build_resolves_to_defaults() {
371        let mut binding = v2_binding("m", vec![]);
372        binding.operations.build = None;
373        let r = resolve_binding_run("m/p", &binding).unwrap();
374        assert_eq!(r.mode, BuildMode::Discovery);
375        assert_eq!(r.trigger, IngestTrigger::Loop);
376        assert_eq!(r.batch_size, 20);
377        assert_eq!(r.post_actions, None);
378    }
379
380    /// A graph-typed source always resolves to the graph strategy,
381    /// regardless of any declared value.
382    #[test]
383    fn graph_source_always_uses_graph_strategy() {
384        let root = Path::new("/nonexistent");
385        assert_eq!(
386            resolve_change_strategy(&source("f", MediumType::Graph, "", None), root),
387            ChangeStrategy::Graph
388        );
389        // Even a declared override does not change a graph source.
390        assert_eq!(
391            resolve_change_strategy(&source("f", MediumType::Graph, "", Some("mtime")), root),
392            ChangeStrategy::Graph
393        );
394    }
395
396    /// A `web` source (detection-less per the capability matrix) resolves to
397    /// the visible NoSignal `None` regardless of any declared value —
398    /// `status` renders `signal: none`, never a fabricated `mtime`/`git`
399    /// token.
400    #[test]
401    fn web_source_resolves_to_none_signal() {
402        let root = Path::new("/nonexistent");
403        assert_eq!(
404            resolve_change_strategy(
405                &source("w", MediumType::Web, "https://example.com", None),
406                root
407            ),
408            ChangeStrategy::None
409        );
410        // Even a declared override does not fabricate a signal for web.
411        assert_eq!(
412            resolve_change_strategy(
413                &source("w", MediumType::Web, "https://example.com", Some("mtime")),
414                root
415            ),
416            ChangeStrategy::None
417        );
418    }
419
420    /// A declared `none`/`git`/`mtime` wins for a non-graph source.
421    #[test]
422    fn declared_strategy_wins_for_non_graph() {
423        let root = Path::new("/nonexistent");
424        for (declared, expected) in [
425            ("none", ChangeStrategy::None),
426            ("git", ChangeStrategy::Git),
427            ("mtime", ChangeStrategy::Mtime),
428        ] {
429            assert_eq!(
430                resolve_change_strategy(
431                    &source("f", MediumType::Codebase, "x", Some(declared)),
432                    root
433                ),
434                expected,
435                "declared '{declared}'"
436            );
437        }
438    }
439
440    /// `auto` (unset, or an unrecognized value) probes the filesystem: a
441    /// pointer under a git work tree → git, otherwise → mtime.
442    #[test]
443    fn auto_probes_for_a_git_work_tree() {
444        let git = tempfile::tempdir().unwrap();
445        std::fs::create_dir(git.path().join(".git")).unwrap();
446        std::fs::create_dir(git.path().join("sub")).unwrap();
447        let plain = tempfile::tempdir().unwrap();
448
449        // Unset → auto → probe. Pointer resolves under the git root → Git.
450        assert_eq!(
451            resolve_change_strategy(&source("f", MediumType::Codebase, "sub", None), git.path()),
452            ChangeStrategy::Git
453        );
454        // An unrecognized declared value also falls through to the probe.
455        assert_eq!(
456            resolve_change_strategy(
457                &source("f", MediumType::Codebase, "sub", Some("weird")),
458                git.path()
459            ),
460            ChangeStrategy::Git
461        );
462        // No git work tree over the pointer → Mtime.
463        assert_eq!(
464            resolve_change_strategy(
465                &source("f", MediumType::Filesystem, ".", None),
466                plain.path()
467            ),
468            ChangeStrategy::Mtime
469        );
470    }
471
472    /// `find_git_root` returns the containing directory of a `.git` entry,
473    /// walking up from a nested start, and `None` when there is none.
474    #[test]
475    fn find_git_root_walks_up() {
476        let root = tempfile::tempdir().unwrap();
477        std::fs::create_dir(root.path().join(".git")).unwrap();
478        let nested = root.path().join("a/b/c");
479        std::fs::create_dir_all(&nested).unwrap();
480
481        assert_eq!(
482            find_git_root(&nested).as_deref(),
483            Some(root.path()),
484            "walks up to the work-tree root"
485        );
486
487        let plain = tempfile::tempdir().unwrap();
488        assert_eq!(find_git_root(plain.path()), None, "no .git anywhere above");
489    }
490}
491
492/// How a destination mem's process mem was resolved (agent-trust
493/// plan 14): by explicit declaration (`MemConfig.process_mem` on the
494/// destination — wins where present) or by the binding-name
495/// convention (the fallback, byte-identical to the pre-declaration
496/// behaviour). One resolution function for every consumer — the
497/// brief renderer and the open-questions health axis read this and
498/// nothing else, so pairing can never drift between surfaces.
499#[derive(Debug, Clone, PartialEq, Eq)]
500pub struct ProcessMemResolution {
501    /// The resolved process-mem name.
502    pub mem: String,
503    /// Whether that mem is actually mounted.
504    pub mounted: bool,
505    /// True when the name came from the destination's declaration
506    /// rather than the naming convention. A declared-but-unmounted
507    /// resolution is a typed finding for the caller to surface —
508    /// never a silent fallback to derivation.
509    pub declared: bool,
510}
511
512/// Resolve the process mem for `destination_mem`. `derived_name` is
513/// the convention-derived candidate (the binding / ingest name);
514/// pass it even when a declaration might exist — the declaration
515/// wins, the derivation remains the fallback.
516pub fn resolve_process_mem(
517    engine: &crate::Engine,
518    destination_mem: &str,
519    derived_name: &str,
520) -> ProcessMemResolution {
521    let mounted_names = engine.mem_names();
522    if let Some(declared) = engine
523        .mem_config_for(destination_mem)
524        .and_then(|c| c.process_mem.clone())
525    {
526        let mounted = mounted_names.iter().any(|m| *m == declared);
527        return ProcessMemResolution {
528            mem: declared,
529            mounted,
530            declared: true,
531        };
532    }
533    let mounted = mounted_names.contains(&derived_name);
534    ProcessMemResolution {
535        mem: derived_name.to_string(),
536        mounted,
537        declared: false,
538    }
539}