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}