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}