1use std::collections::{BTreeMap, BTreeSet};
65use std::path::{Path, PathBuf};
66use std::time::{SystemTime, UNIX_EPOCH};
67
68use serde::{Deserialize, Serialize};
69
70use crate::Engine;
71use crate::anchor::{Anchor, AnchorState, ObservedArtifactHash};
72use crate::binding::{
73 Binding, DEFAULT_ADJUDICATION_CAP, DEFAULT_FULL_RESYNC_EVERY, hash_binding, medium_capabilities,
74};
75use crate::workspace_store::{StoreError, WORKSPACE_STORE_DIR};
76
77use super::advance::is_single_component;
78use super::cursor::{compute_source_cursor, enumerate_source_artifacts};
79use super::refinement::{
80 ROTATION_ANCHOR_ADJUDICATION, bump_verify_runs, next_batch, next_rotation_batch,
81};
82use super::resolve::{ResolvedIngest, ResolvedSource};
83
84const STATE_DIR: &str = "state";
87const FINDINGS_DIR: &str = "findings";
89
90#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
107pub struct FindingKey {
108 pub binding_hash: String,
111 pub source_head: String,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(rename_all = "kebab-case")]
129pub enum FindingClass {
130 Drifted,
133 Wrong,
135 Uncovered,
137 UnresolvableAnchor,
139 QueuedForAdjudication,
142}
143
144impl FindingClass {
145 pub const WIRE_VALUES: &'static [&'static str] = &[
147 "drifted",
148 "wrong",
149 "uncovered",
150 "unresolvable-anchor",
151 "queued-for-adjudication",
152 ];
153
154 pub fn as_wire(&self) -> &'static str {
156 match self {
157 FindingClass::Drifted => "drifted",
158 FindingClass::Wrong => "wrong",
159 FindingClass::Uncovered => "uncovered",
160 FindingClass::UnresolvableAnchor => "unresolvable-anchor",
161 FindingClass::QueuedForAdjudication => "queued-for-adjudication",
162 }
163 }
164
165 pub fn from_wire(s: &str) -> Option<Self> {
167 match s {
168 "drifted" => Some(FindingClass::Drifted),
169 "wrong" => Some(FindingClass::Wrong),
170 "uncovered" => Some(FindingClass::Uncovered),
171 "unresolvable-anchor" => Some(FindingClass::UnresolvableAnchor),
172 "queued-for-adjudication" => Some(FindingClass::QueuedForAdjudication),
173 _ => None,
174 }
175 }
176}
177
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(tag = "kind", rename_all = "kebab-case")]
182pub enum FindingTarget {
183 Anchor {
186 entity: String,
188 artifact: String,
190 },
191 Artifact {
194 artifact: String,
196 },
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
204pub struct Finding {
205 pub key: FindingKey,
209 pub facet: String,
212 pub target: FindingTarget,
214 pub class: FindingClass,
216 pub detail: String,
218 pub created_at: String,
220}
221
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
234pub struct FindingsBatch {
235 pub key: FindingKey,
238 pub recorded_at: String,
240 pub findings: Vec<Finding>,
242}
243
244#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
258pub struct FindingsStore {
259 pub binding: String,
261 #[serde(default)]
264 pub batches: Vec<FindingsBatch>,
265}
266
267impl FindingsStore {
268 fn current_batch_index(&self, binding_hash: &str) -> Option<usize> {
273 self.batches
274 .iter()
275 .enumerate()
276 .filter(|(_, b)| b.key.binding_hash == binding_hash)
277 .max_by_key(|(i, b)| (b.recorded_at.parse::<u64>().unwrap_or(0), *i))
278 .map(|(i, _)| i)
279 }
280
281 pub fn record(&mut self, key: FindingKey, recorded_at: String, findings: Vec<Finding>) {
286 self.batches
287 .retain(|b| b.key.binding_hash != key.binding_hash);
288 self.batches.push(FindingsBatch {
289 key,
290 recorded_at,
291 findings,
292 });
293 }
294
295 pub fn current(&self, key: &FindingKey) -> &[Finding] {
300 self.current_batch_index(&key.binding_hash)
301 .map(|i| self.batches[i].findings.as_slice())
302 .unwrap_or(&[])
303 }
304
305 pub fn superseded(&self, key: &FindingKey) -> Vec<&Finding> {
310 let current = self.current_batch_index(&key.binding_hash);
311 self.batches
312 .iter()
313 .enumerate()
314 .filter(|(i, _)| Some(*i) != current)
315 .flat_map(|(_, b)| b.findings.iter())
316 .collect()
317 }
318}
319
320pub fn findings_store_path(workspace_root: &Path, mem: &str, name: &str) -> PathBuf {
327 workspace_root
328 .join(WORKSPACE_STORE_DIR)
329 .join(STATE_DIR)
330 .join(FINDINGS_DIR)
331 .join(mem)
332 .join(format!("{name}.json"))
333}
334
335pub const STANDALONE_KEY: &str = "standalone";
345
346#[derive(Debug, Clone, Serialize)]
351pub struct AnnotatedStandaloneFinding {
352 #[serde(flatten)]
353 pub finding: Finding,
354 pub already_seen: bool,
355}
356
357pub fn record_standalone_findings(
365 workspace_root: &Path,
366 report: &crate::engine::query::MemAnchorVerification,
367) -> Result<Vec<AnnotatedStandaloneFinding>, StoreError> {
368 let mem = &report.mem;
369 let key = FindingKey {
370 binding_hash: STANDALONE_KEY.to_string(),
371 source_head: String::new(),
372 };
373 let now = SystemTime::now()
374 .duration_since(UNIX_EPOCH)
375 .map(|d| d.as_secs())
376 .unwrap_or(0)
377 .to_string();
378
379 let findings: Vec<Finding> = report
380 .anchors
381 .iter()
382 .filter_map(|a| {
383 let class = match a.state.as_str() {
393 "drifted" => FindingClass::Drifted,
394 "unresolvable" => FindingClass::UnresolvableAnchor,
395 _ => return None,
396 };
397 Some(Finding {
398 key: key.clone(),
399 facet: STANDALONE_KEY.to_string(),
400 target: FindingTarget::Anchor {
401 entity: a.entity_id.clone(),
402 artifact: a.artifact.clone(),
403 },
404 class,
405 detail: format!("{} ({} {})", a.state, a.class, a.grain),
406 created_at: now.clone(),
407 })
408 })
409 .collect();
410
411 let mut store =
412 read_findings_store(workspace_root, mem, STANDALONE_KEY)?.unwrap_or_else(|| {
413 FindingsStore {
414 binding: format!("{mem}/{STANDALONE_KEY}"),
415 ..Default::default()
416 }
417 });
418 let prior: BTreeSet<(String, String)> = store
419 .current(&key)
420 .iter()
421 .map(|f| {
422 (
423 serde_json::to_string(&f.target).unwrap_or_default(),
424 f.class.as_wire().to_string(),
425 )
426 })
427 .collect();
428 let annotated: Vec<AnnotatedStandaloneFinding> = findings
429 .iter()
430 .map(|f| AnnotatedStandaloneFinding {
431 finding: f.clone(),
432 already_seen: prior.contains(&(
433 serde_json::to_string(&f.target).unwrap_or_default(),
434 f.class.as_wire().to_string(),
435 )),
436 })
437 .collect();
438 store.record(key, now, findings);
439 write_findings_store(workspace_root, mem, STANDALONE_KEY, &store)?;
440 Ok(annotated)
441}
442
443pub fn read_findings_store(
446 workspace_root: &Path,
447 mem: &str,
448 name: &str,
449) -> Result<Option<FindingsStore>, StoreError> {
450 let path = findings_store_path(workspace_root, mem, name);
451 match std::fs::read(&path) {
452 Ok(bytes) => serde_json::from_slice(&bytes)
453 .map(Some)
454 .map_err(|e| StoreError::Parse {
455 path,
456 message: e.to_string(),
457 }),
458 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
459 Err(e) => Err(StoreError::Io { path, source: e }),
460 }
461}
462
463pub(crate) fn ensure_selfignoring_store_dir(subtree_root: &Path) -> Result<(), StoreError> {
471 std::fs::create_dir_all(subtree_root).map_err(|e| StoreError::Io {
472 path: subtree_root.to_path_buf(),
473 source: e,
474 })?;
475 let gitignore = subtree_root.join(".gitignore");
476 if !gitignore.exists() {
477 let _ = std::fs::write(&gitignore, "*\n");
478 }
479 Ok(())
480}
481
482pub fn write_findings_store(
485 workspace_root: &Path,
486 mem: &str,
487 name: &str,
488 store: &FindingsStore,
489) -> Result<(), StoreError> {
490 ensure_selfignoring_store_dir(
491 &workspace_root
492 .join(WORKSPACE_STORE_DIR)
493 .join(STATE_DIR)
494 .join(FINDINGS_DIR),
495 )?;
496 let path = findings_store_path(workspace_root, mem, name);
497 if let Some(parent) = path.parent() {
498 std::fs::create_dir_all(parent).map_err(|e| StoreError::Io {
499 path: parent.to_path_buf(),
500 source: e,
501 })?;
502 }
503 let bytes = serde_json::to_vec_pretty(store).map_err(|e| StoreError::Parse {
504 path: path.clone(),
505 message: e.to_string(),
506 })?;
507 std::fs::write(&path, bytes).map_err(|e| StoreError::Io { path, source: e })
508}
509
510pub fn delete_findings_store(
513 workspace_root: &Path,
514 mem: &str,
515 name: &str,
516) -> Result<(), StoreError> {
517 let path = findings_store_path(workspace_root, mem, name);
518 match std::fs::remove_file(&path) {
519 Ok(()) => Ok(()),
520 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
521 Err(e) => Err(StoreError::Io { path, source: e }),
522 }
523}
524
525#[derive(Debug, thiserror::Error)]
531pub enum FindingsError {
532 #[error("malformed binding id '{0}': expected `<mem>/<stem>`")]
534 MalformedId(String),
535 #[error("findings store error: {0}")]
537 Store(#[source] StoreError),
538 #[error("source '{source_name}' unreachable: `{path}` does not exist")]
545 SourceUnreachable {
546 source_name: String,
548 path: String,
550 },
551 #[error(
560 "full verify refused: facet '{}' over medium type '{}' cannot be fully walked — {}",
561 .0.facet, .0.medium_type, .0.reason
562 )]
563 FullWalkNonEnumerable(FullResyncRefusal),
564}
565
566#[derive(Debug, Clone, PartialEq, Eq)]
568pub struct VerifyOutcome {
569 pub binding: String,
571 pub key: FindingKey,
573 pub recorded: usize,
575 pub superseded: usize,
577 pub backlog: usize,
579 pub full_resync: FullResyncDecision,
583 pub facet_heads: BTreeMap<String, String>,
587 pub hash_backfill: Vec<ObservedArtifactHash>,
597}
598
599pub fn record_verified_baseline(
614 engine: &mut Engine,
615 destination_mem: &str,
616 outcome: &VerifyOutcome,
617 note: Option<&str>,
618) -> Result<Vec<String>, crate::engine::EngineError> {
619 let mut written = Vec::with_capacity(outcome.facet_heads.len());
620 for (facet, token) in &outcome.facet_heads {
621 let key = format!("{}/{facet}#verified", outcome.binding);
622 engine.set_mem_sync_state(destination_mem, &key, token, note)?;
623 written.push(key);
624 }
625 Ok(written)
626}
627
628pub fn record_anchor_hash_backfill(
645 engine: &mut Engine,
646 destination_mem: &str,
647 outcome: &VerifyOutcome,
648 note: Option<&str>,
649) -> Result<usize, crate::engine::EngineError> {
650 engine.record_anchor_observed_hashes(destination_mem, &outcome.hash_backfill, note)
651}
652
653fn split_binding_id(binding_id: &str) -> Result<(String, String), FindingsError> {
657 binding_id
658 .split_once('/')
659 .filter(|(m, n)| is_single_component(m) && is_single_component(n))
660 .map(|(m, n)| (m.to_string(), n.to_string()))
661 .ok_or_else(|| FindingsError::MalformedId(binding_id.to_string()))
662}
663
664fn source_facet_label(resolved: &ResolvedIngest) -> String {
668 let facets: Vec<&str> = resolved
669 .sources
670 .iter()
671 .filter_map(|s| match s {
672 ResolvedSource::Primary(p) => Some(p.name.as_str()),
673 ResolvedSource::Reference { .. } => None,
674 })
675 .collect();
676 facets.join(",")
677}
678
679fn now_seconds() -> String {
681 let secs = SystemTime::now()
682 .duration_since(UNIX_EPOCH)
683 .map(|d| d.as_secs())
684 .unwrap_or(0);
685 secs.to_string()
686}
687
688fn current_facet_heads(
696 engine: &Engine,
697 workspace_root: &Path,
698 resolved: &ResolvedIngest,
699) -> BTreeMap<String, String> {
700 let binding_id = &resolved.name;
701 let prefix = format!("{binding_id}/");
702 let mut tokens: BTreeMap<String, String> = BTreeMap::new();
703
704 if let Some(cfg) = engine.mem_config_for(&resolved.destination_mem) {
706 for (k, v) in &cfg.sync_state {
707 if let Some(rest) = k.strip_prefix(&prefix)
708 && let Some(facet) = rest.strip_suffix("#synced")
709 {
710 tokens.insert(facet.to_string(), v.clone());
711 }
712 }
713 }
714
715 let cursor = compute_source_cursor(engine, resolved, workspace_root);
717 for c in cursor.write_commands.iter().chain(cursor.reseed.iter()) {
718 if let Some(rest) = c.key.strip_prefix(&prefix)
719 && let Some(facet) = rest.strip_suffix("#synced")
720 {
721 tokens.insert(facet.to_string(), c.token.clone());
722 }
723 }
724
725 tokens
726}
727
728fn join_facet_heads(tokens: &BTreeMap<String, String>) -> String {
731 tokens
732 .iter()
733 .map(|(facet, token)| format!("{facet}={token}"))
734 .collect::<Vec<_>>()
735 .join(";")
736}
737
738fn current_source_head(
742 engine: &Engine,
743 workspace_root: &Path,
744 resolved: &ResolvedIngest,
745) -> String {
746 join_facet_heads(¤t_facet_heads(engine, workspace_root, resolved))
747}
748
749fn binding_hash_of(binding: &Binding, _resolved: &ResolvedIngest) -> String {
752 hash_binding(binding)
753}
754
755fn current_key(
759 engine: &Engine,
760 workspace_root: &Path,
761 binding: &Binding,
762 resolved: &ResolvedIngest,
763) -> FindingKey {
764 FindingKey {
765 binding_hash: binding_hash_of(binding, resolved),
766 source_head: current_source_head(engine, workspace_root, resolved),
767 }
768}
769
770pub fn current_findings(
789 engine: &Engine,
790 workspace_root: &Path,
791 binding: &Binding,
792 resolved: &ResolvedIngest,
793) -> Result<(FindingKey, Vec<Finding>), FindingsError> {
794 let (mem, name) = split_binding_id(&resolved.name)?;
795 let key = current_key(engine, workspace_root, binding, resolved);
796 let mut findings = read_findings_store(workspace_root, &mem, &name)
797 .map_err(FindingsError::Store)?
798 .map(|s| s.current(&key).to_vec())
799 .unwrap_or_default();
800 let excluded: BTreeSet<String> =
801 crate::ingest::advance::read_advance_store(workspace_root, &mem, &name)
802 .ok()
803 .flatten()
804 .map(|state| state.exclusions.keys().cloned().collect())
805 .unwrap_or_default();
806 if !excluded.is_empty() {
807 findings.retain(|f| {
808 !(f.class == FindingClass::Uncovered
809 && matches!(&f.target, FindingTarget::Artifact { artifact } if excluded.contains(artifact)))
810 });
811 }
812 Ok((key, findings))
813}
814
815pub fn adjudicate_anchor(
826 key: &FindingKey,
827 facet: &str,
828 entity: &str,
829 anchor: &Anchor,
830 state: AnchorState,
831 created_at: &str,
832) -> Option<Finding> {
833 let (class, detail) = match state {
834 AnchorState::Resolves => return None,
835 AnchorState::Orphaned => (
836 FindingClass::UnresolvableAnchor,
837 format!(
838 "artifact '{}' the anchor references is no longer present in the medium",
839 anchor.artifact
840 ),
841 ),
842 AnchorState::Drifted | AnchorState::Recheck => {
843 if !anchor.class.is_hash_bearing() {
845 return None;
846 }
847 match state {
848 AnchorState::Drifted => (
849 FindingClass::Drifted,
850 format!(
851 "prepared-content hash of '{}' drifted from the anchored hash",
852 anchor.artifact
853 ),
854 ),
855 _ => (
856 FindingClass::QueuedForAdjudication,
857 format!(
858 "hash adjudication of '{}' deferred (recheck); queued",
859 anchor.artifact
860 ),
861 ),
862 }
863 }
864 };
865 Some(Finding {
866 key: key.clone(),
867 facet: facet.to_string(),
868 target: FindingTarget::Anchor {
869 entity: entity.to_string(),
870 artifact: anchor.artifact.clone(),
871 },
872 class,
873 detail,
874 created_at: created_at.to_string(),
875 })
876}
877
878#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
885pub struct FacetEnumerability {
886 pub facet: String,
888 pub medium_type: String,
890 pub enumerable: bool,
892}
893
894#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
899pub struct FullResyncRefusal {
900 pub facet: String,
902 pub medium_type: String,
904 pub reason: String,
906}
907
908#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
912#[serde(tag = "state", rename_all = "kebab-case")]
913pub enum FullResyncDecision {
914 Disabled,
917 NotDue {
920 run_count: u64,
922 every: u32,
924 runs_until_due: u32,
926 },
927 Due {
932 run_count: u64,
934 every: u32,
936 walked_facets: Vec<String>,
938 refused: Vec<FullResyncRefusal>,
940 },
941 Forced {
949 walked_facets: Vec<String>,
951 },
952}
953
954impl FullResyncDecision {
955 pub fn is_full_walk(&self) -> bool {
959 matches!(
960 self,
961 FullResyncDecision::Due { .. } | FullResyncDecision::Forced { .. }
962 )
963 }
964}
965
966pub fn schedule_full_resync(
972 every: u32,
973 run_count: u64,
974 facets: &[FacetEnumerability],
975) -> FullResyncDecision {
976 if every == 0 {
977 return FullResyncDecision::Disabled;
978 }
979 let modulo = run_count % u64::from(every);
980 if modulo != 0 {
981 return FullResyncDecision::NotDue {
982 run_count,
983 every,
984 runs_until_due: (u64::from(every) - modulo) as u32,
985 };
986 }
987 let mut walked_facets = Vec::new();
988 let mut refused = Vec::new();
989 for f in facets {
990 if f.enumerable {
991 walked_facets.push(f.facet.clone());
992 } else {
993 refused.push(FullResyncRefusal {
994 facet: f.facet.clone(),
995 medium_type: f.medium_type.clone(),
996 reason: format!(
997 "medium type '{}' is non-enumerable — a full-enumeration walk cannot cover \
998 it; the scheduled full resync refuses rather than claim full coverage",
999 f.medium_type
1000 ),
1001 });
1002 }
1003 }
1004 FullResyncDecision::Due {
1005 run_count,
1006 every,
1007 walked_facets,
1008 refused,
1009 }
1010}
1011
1012fn candidate_key(entity: &str, anchor: &Anchor) -> String {
1016 format!("{entity}\u{1f}{}", anchor.artifact)
1017}
1018
1019fn adjudicate_candidates(
1031 key: &FindingKey,
1032 facet: &str,
1033 candidates: &[(String, Anchor, AnchorState)],
1034 window: Option<&BTreeSet<String>>,
1035 created_at: &str,
1036) -> Vec<Finding> {
1037 let mut out = Vec::new();
1038 for (entity, anchor, state) in candidates {
1039 let ck = candidate_key(entity, anchor);
1040 let adjudicate_now = window.is_none_or(|w| w.contains(&ck));
1041 if adjudicate_now {
1042 if let Some(f) = adjudicate_anchor(key, facet, entity, anchor, *state, created_at) {
1043 out.push(f);
1044 }
1045 } else {
1046 out.push(Finding {
1050 key: key.clone(),
1051 facet: facet.to_string(),
1052 target: FindingTarget::Anchor {
1053 entity: entity.clone(),
1054 artifact: anchor.artifact.clone(),
1055 },
1056 class: FindingClass::QueuedForAdjudication,
1057 detail: format!(
1058 "adjudication of '{}' deferred (per-run adjudication cap reached); queued",
1059 anchor.artifact
1060 ),
1061 created_at: created_at.to_string(),
1062 });
1063 }
1064 }
1065 out
1066}
1067
1068fn target_key(target: &FindingTarget) -> String {
1072 match target {
1073 FindingTarget::Anchor { entity, artifact } => format!("a\u{1f}{entity}\u{1f}{artifact}"),
1074 FindingTarget::Artifact { artifact } => format!("f\u{1f}{artifact}"),
1075 }
1076}
1077
1078struct PassObservation {
1081 anchors_observed: BTreeSet<String>,
1084 anchors_existing: BTreeSet<String>,
1087 files_observed: BTreeSet<String>,
1090 s_d: BTreeSet<String>,
1092}
1093
1094fn merge_with_prior(
1119 mut fresh: Vec<Finding>,
1120 prior: &[Finding],
1121 obs: &PassObservation,
1122 covered_now: impl Fn(&str) -> bool,
1123) -> Vec<Finding> {
1124 let fresh_idx: BTreeMap<String, usize> = fresh
1125 .iter()
1126 .enumerate()
1127 .map(|(i, f)| (target_key(&f.target), i))
1128 .collect();
1129 let mut carried: Vec<Finding> = Vec::new();
1130 for f in prior {
1131 let tkey = target_key(&f.target);
1132 let observed = match &f.target {
1133 FindingTarget::Anchor { .. } => obs.anchors_observed.contains(&tkey),
1134 FindingTarget::Artifact { artifact } => obs.files_observed.contains(artifact),
1135 };
1136 if observed {
1137 if matches!(f.class, FindingClass::Drifted | FindingClass::Wrong)
1139 && let Some(&i) = fresh_idx.get(&tkey)
1140 && fresh[i].class == FindingClass::QueuedForAdjudication
1141 {
1142 fresh[i] = f.clone();
1143 }
1144 continue;
1145 }
1146 if fresh_idx.contains_key(&tkey) {
1147 continue; }
1149 let still_open = match &f.target {
1150 FindingTarget::Anchor { .. } => obs.anchors_existing.contains(&tkey),
1151 FindingTarget::Artifact { artifact } => {
1152 obs.s_d.contains(artifact) && !covered_now(artifact)
1153 }
1154 };
1155 if still_open {
1156 carried.push(f.clone());
1157 }
1158 }
1159 fresh.extend(carried);
1160 fresh
1161}
1162
1163pub fn verify_binding(
1177 engine: &Engine,
1178 workspace_root: &Path,
1179 binding: &Binding,
1180 resolved: &ResolvedIngest,
1181) -> Result<VerifyOutcome, FindingsError> {
1182 run_verify(engine, workspace_root, binding, resolved, false)
1183}
1184
1185pub fn verify_binding_full(
1201 engine: &Engine,
1202 workspace_root: &Path,
1203 binding: &Binding,
1204 resolved: &ResolvedIngest,
1205) -> Result<VerifyOutcome, FindingsError> {
1206 run_verify(engine, workspace_root, binding, resolved, true)
1207}
1208
1209fn run_verify(
1213 engine: &Engine,
1214 workspace_root: &Path,
1215 binding: &Binding,
1216 resolved: &ResolvedIngest,
1217 full: bool,
1218) -> Result<VerifyOutcome, FindingsError> {
1219 let binding_id = resolved.name.clone();
1220 let (mem, name) = split_binding_id(&binding_id)?;
1221
1222 if full {
1226 for source in &resolved.sources {
1227 if let ResolvedSource::Primary(p) = source {
1228 let medium_type = medium_type_wire(p.medium_type);
1229 if !medium_capabilities(p.medium_type).enumerable {
1230 return Err(FindingsError::FullWalkNonEnumerable(FullResyncRefusal {
1231 facet: p.name.clone(),
1232 medium_type: medium_type.clone(),
1233 reason: format!(
1234 "medium type '{medium_type}' is non-enumerable — a full-enumeration \
1235 walk cannot cover it; the full measurement refuses rather than \
1236 render a report with fabricated completeness"
1237 ),
1238 }));
1239 }
1240 }
1241 }
1242
1243 for source in &resolved.sources {
1259 if let ResolvedSource::Primary(p) = source
1260 && medium_capabilities(p.medium_type).enumerable
1261 {
1262 let walked = super::cursor::enumerate_source_artifacts_reported(
1263 engine,
1264 p,
1265 &resolved.deny_paths,
1266 workspace_root,
1267 );
1268 let medium_type = medium_type_wire(p.medium_type);
1269 if let Some(why) = walked.partiality_reason() {
1277 return Err(FindingsError::FullWalkNonEnumerable(FullResyncRefusal {
1278 facet: p.name.clone(),
1279 medium_type: medium_type.clone(),
1280 reason: format!(
1281 "this facet's enumeration is incomplete — {why} — so a full \
1282 measurement would claim complete coverage over a denominator \
1283 that is not the population. Fix those patterns first"
1284 ),
1285 }));
1286 }
1287 if walked.files.is_empty() {
1288 let remedy = if walked.legacy_dialect.is_empty() {
1293 "Check that its scope patterns actually select something".to_string()
1294 } else {
1295 format!(
1296 "its scope pattern(s) are still written against the workspace root \
1297 rather than the source pointer ({}), so they select nothing under \
1298 the pointer join — rewrite them relative to the pointer",
1299 walked
1300 .legacy_dialect
1301 .iter()
1302 .map(|n| n.pattern.as_str())
1303 .collect::<Vec<_>>()
1304 .join(", ")
1305 )
1306 };
1307 return Err(FindingsError::FullWalkNonEnumerable(FullResyncRefusal {
1308 facet: p.name.clone(),
1309 medium_type: medium_type.clone(),
1310 reason: format!(
1311 "medium type '{medium_type}' claims to be enumerable, but this \
1312 facet's enumeration yielded no artifacts — a full measurement over \
1313 an empty walk would report complete coverage of nothing. {remedy}"
1314 ),
1315 }));
1316 }
1317 }
1318 }
1319 }
1320
1321 for source in &resolved.sources {
1327 if let ResolvedSource::Primary(p) = source
1328 && matches!(
1329 p.medium_type,
1330 crate::pipeline::MediumType::Codebase
1331 | crate::pipeline::MediumType::Filesystem
1332 | crate::pipeline::MediumType::Git
1333 )
1334 {
1335 let base = super::resolve::source_base_path(p, workspace_root);
1336 let reachable = base.is_dir() && std::fs::read_dir(&base).is_ok();
1348 if !reachable {
1349 return Err(FindingsError::SourceUnreachable {
1350 source_name: p.name.clone(),
1351 path: base.display().to_string(),
1352 });
1353 }
1354 }
1355 }
1356
1357 for source in &resolved.sources {
1367 if let ResolvedSource::Primary(p) = source
1368 && p.medium_type == crate::pipeline::MediumType::Graph
1369 && !engine.mem_names().iter().any(|m| *m == p.pointer)
1370 {
1371 return Err(FindingsError::SourceUnreachable {
1372 source_name: p.name.clone(),
1373 path: format!("mem `{}` (not mounted in this workspace)", p.pointer),
1374 });
1375 }
1376 }
1377
1378 let facet_heads = current_facet_heads(engine, workspace_root, resolved);
1382 let key = FindingKey {
1383 binding_hash: binding_hash_of(binding, resolved),
1384 source_head: join_facet_heads(&facet_heads),
1385 };
1386 let now = now_seconds();
1387 let facet = source_facet_label(resolved);
1388 let cache_root = workspace_root.join(".memstead.cache").join("ingest");
1389
1390 let verify_op = binding.operations.verify.as_ref();
1396 let cap = verify_op.map_or(DEFAULT_ADJUDICATION_CAP, |v| v.adjudication_cap);
1397 let full_resync_every = verify_op.map_or(DEFAULT_FULL_RESYNC_EVERY, |v| v.full_resync_every);
1398 let sample_batch = verify_op
1399 .map_or(resolved.batch_size, |v| v.batch_size)
1400 .max(1) as usize;
1401
1402 let run_count = bump_verify_runs(&cache_root, &binding_id);
1408 let facet_enum: Vec<FacetEnumerability> = resolved
1409 .sources
1410 .iter()
1411 .filter_map(|s| match s {
1412 ResolvedSource::Primary(p) => Some(FacetEnumerability {
1413 facet: p.name.clone(),
1414 medium_type: medium_type_wire(p.medium_type),
1415 enumerable: medium_capabilities(p.medium_type).enumerable,
1416 }),
1417 ResolvedSource::Reference { .. } => None,
1418 })
1419 .collect();
1420 let full_resync = if full {
1421 FullResyncDecision::Forced {
1422 walked_facets: facet_enum.iter().map(|f| f.facet.clone()).collect(),
1423 }
1424 } else {
1425 schedule_full_resync(full_resync_every, run_count, &facet_enum)
1426 };
1427 let mut full_walk_files: Vec<String> = Vec::new();
1438 let full_resync = match full_resync {
1439 FullResyncDecision::Due {
1440 run_count,
1441 every,
1442 walked_facets,
1443 mut refused,
1444 } => {
1445 let mut kept: Vec<String> = Vec::new();
1446 for source in &resolved.sources {
1447 if let ResolvedSource::Primary(p) = source
1448 && walked_facets.iter().any(|f| f == &p.name)
1449 {
1450 let walked = super::cursor::enumerate_source_artifacts_reported(
1451 engine,
1452 p,
1453 &resolved.deny_paths,
1454 workspace_root,
1455 );
1456 if let Some(why) = walked.partiality_reason() {
1457 refused.push(FullResyncRefusal {
1458 facet: p.name.clone(),
1459 medium_type: medium_type_wire(p.medium_type),
1460 reason: format!(
1461 "this facet's enumeration is incomplete — {why} — so the \
1462 scheduled full walk refuses it rather than announce complete \
1463 coverage over a denominator that is not the population"
1464 ),
1465 });
1466 } else {
1467 kept.push(p.name.clone());
1468 full_walk_files.extend(walked.files);
1469 }
1470 }
1471 }
1472 FullResyncDecision::Due {
1473 run_count,
1474 every,
1475 walked_facets: kept,
1476 refused,
1477 }
1478 }
1479 FullResyncDecision::Forced { walked_facets } => {
1480 for source in &resolved.sources {
1481 if let ResolvedSource::Primary(p) = source
1482 && medium_capabilities(p.medium_type).enumerable
1483 {
1484 full_walk_files.extend(enumerate_source_artifacts(
1485 engine,
1486 p,
1487 &resolved.deny_paths,
1488 workspace_root,
1489 ));
1490 }
1491 }
1492 FullResyncDecision::Forced { walked_facets }
1493 }
1494 other => other,
1495 };
1496
1497 let mut findings: Vec<Finding> = Vec::new();
1498
1499 let mut existence: Vec<(String, Anchor, AnchorState)> = Vec::new();
1506 let mut candidates: Vec<(String, Anchor, AnchorState)> = Vec::new();
1507 let mut hash_backfill: Vec<ObservedArtifactHash> = Vec::new();
1516 let mut backfill_seen: BTreeSet<(String, String)> = BTreeSet::new();
1517 let mut anchors_existing: BTreeSet<String> = BTreeSet::new();
1520 let mut anchors_observed: BTreeSet<String> = BTreeSet::new();
1521 let population = crate::ingest::anchor_population::population_for(
1525 engine,
1526 resolved,
1527 Some(binding_hash_of(binding, resolved).as_str()),
1528 );
1529 for (eid, resolved_anchor) in population.included {
1530 let tkey = target_key(&FindingTarget::Anchor {
1531 entity: eid.as_ref().to_string(),
1532 artifact: resolved_anchor.anchor.artifact.clone(),
1533 });
1534 anchors_existing.insert(tkey.clone());
1535 let Some(state) = resolved_anchor.state else {
1536 continue;
1537 };
1538 anchors_observed.insert(tkey);
1539 let observed_hash = resolved_anchor.observed_hash;
1540 let anchor = resolved_anchor.anchor;
1541 match state {
1542 AnchorState::Resolves => {}
1543 AnchorState::Orphaned => existence.push((eid.as_ref().to_string(), anchor, state)),
1544 AnchorState::Drifted | AnchorState::Recheck => {
1545 if !anchor.class.is_hash_bearing() {
1548 continue;
1549 }
1550 if anchor.hash.is_none()
1551 && let Some(hash) = observed_hash
1552 {
1553 if backfill_seen.insert((eid.as_ref().to_string(), anchor.artifact.clone())) {
1556 hash_backfill.push(ObservedArtifactHash {
1557 entity: eid.as_ref().to_string(),
1558 artifact: anchor.artifact.clone(),
1559 hash,
1560 });
1561 }
1562 continue;
1563 }
1564 candidates.push((eid.as_ref().to_string(), anchor, state));
1565 }
1566 }
1567 }
1568 for (entity, anchor, state) in &existence {
1569 if let Some(f) = adjudicate_anchor(&key, &facet, entity, anchor, *state, &now) {
1570 findings.push(f);
1571 }
1572 }
1573 let window: Option<BTreeSet<String>> = if full || cap == 0 {
1579 None
1580 } else {
1581 let mut keys: Vec<String> = candidates
1582 .iter()
1583 .map(|(e, a, _)| candidate_key(e, a))
1584 .collect();
1585 keys.sort();
1586 keys.dedup();
1587 next_rotation_batch(
1588 &cache_root,
1589 &binding_id,
1590 ROTATION_ANCHOR_ADJUDICATION,
1591 keys,
1592 cap as usize,
1593 )
1594 .map(|b| b.files.into_iter().collect())
1595 };
1596 findings.extend(adjudicate_candidates(
1597 &key,
1598 &facet,
1599 &candidates,
1600 window.as_ref(),
1601 &now,
1602 ));
1603
1604 let sample_files: Vec<String> = if full_resync.is_full_walk() {
1612 let mut all = full_walk_files;
1615 all.sort();
1616 all.dedup();
1617 all
1618 } else {
1619 next_batch(engine, resolved, workspace_root, &cache_root, sample_batch)
1620 .map(|b| b.files)
1621 .unwrap_or_default()
1622 };
1623 let this_binding = binding_hash_of(binding, resolved);
1631 let entity_end_reconciled = engine
1636 .entity_set_is_reconcilable(&resolved.destination_mem)
1637 .is_ok();
1638 let covered_now = |artifact: &str| {
1639 engine
1640 .anchors_referencing_artifact(artifact)
1641 .iter()
1642 .any(|(eid, a)| {
1643 eid.mem() == resolved.destination_mem.as_str()
1644 && a.binding
1645 .as_deref()
1646 .map(|b| b == this_binding.as_str())
1647 .unwrap_or(true)
1648 && (!entity_end_reconciled || !engine.entity_is_absent(eid))
1649 })
1650 };
1651 let excluded: BTreeSet<String> =
1659 crate::ingest::advance::read_advance_store(workspace_root, &mem, &name)
1660 .ok()
1661 .flatten()
1662 .map(|state| state.exclusions.keys().cloned().collect())
1663 .unwrap_or_default();
1664 for file in &sample_files {
1665 if !covered_now(file) && !excluded.contains(file) {
1666 findings.push(Finding {
1667 key: key.clone(),
1668 facet: facet.clone(),
1669 target: FindingTarget::Artifact {
1670 artifact: file.clone(),
1671 },
1672 class: FindingClass::Uncovered,
1673 detail: "source artifact in scope has no anchor in the destination mem".to_string(),
1674 created_at: now.clone(),
1675 });
1676 }
1677 }
1678
1679 let mut store = read_findings_store(workspace_root, &mem, &name)
1686 .map_err(FindingsError::Store)?
1687 .unwrap_or_else(|| FindingsStore {
1688 binding: binding_id.clone(),
1689 ..Default::default()
1690 });
1691 let mut s_d: BTreeSet<String> = BTreeSet::new();
1692 for source in &resolved.sources {
1693 if let ResolvedSource::Primary(p) = source
1694 && medium_capabilities(p.medium_type).enumerable
1695 {
1696 s_d.extend(enumerate_source_artifacts(
1697 engine,
1698 p,
1699 &resolved.deny_paths,
1700 workspace_root,
1701 ));
1702 }
1703 }
1704 let obs = PassObservation {
1705 anchors_observed,
1706 anchors_existing,
1707 files_observed: sample_files.into_iter().collect(),
1708 s_d,
1709 };
1710 let prior = store.current(&key).to_vec();
1711 let findings = merge_with_prior(findings, &prior, &obs, covered_now);
1712
1713 let backlog = findings
1714 .iter()
1715 .filter(|f| f.class == FindingClass::QueuedForAdjudication)
1716 .count();
1717
1718 let recorded = findings.len();
1721 store.record(key.clone(), now, findings);
1722 let superseded = store.superseded(&key).len();
1723 write_findings_store(workspace_root, &mem, &name, &store).map_err(FindingsError::Store)?;
1724
1725 Ok(VerifyOutcome {
1726 binding: binding_id,
1727 key,
1728 recorded,
1729 superseded,
1730 backlog,
1731 full_resync,
1732 facet_heads,
1733 hash_backfill,
1734 })
1735}
1736
1737fn medium_type_wire(t: crate::pipeline::MediumType) -> String {
1740 serde_json::to_value(t)
1741 .ok()
1742 .and_then(|v| v.as_str().map(str::to_string))
1743 .unwrap_or_default()
1744}
1745
1746#[cfg(test)]
1747mod tests {
1748 use super::*;
1749 use crate::anchor::{Anchor, AnchorGrain, AnchorHashStability, AnchorProvenanceClass};
1750
1751 fn key(hash: &str, head: &str) -> FindingKey {
1752 FindingKey {
1753 binding_hash: hash.to_string(),
1754 source_head: head.to_string(),
1755 }
1756 }
1757
1758 fn anchor(class: AnchorProvenanceClass) -> Anchor {
1759 Anchor {
1760 artifact: "src/lib.rs".to_string(),
1761 grain: AnchorGrain::File,
1762 class,
1763 at_version: None,
1764 hash: if class.is_hash_bearing() {
1765 Some("h1".to_string())
1766 } else {
1767 None
1768 },
1769 hash_stability: AnchorHashStability::Stable,
1770 derived_from: Vec::new(),
1771 binding: None,
1772 source: None,
1773 span_unvalidated: false,
1774 hash_source: None,
1775 }
1776 }
1777
1778 #[test]
1781 fn store_round_trips_on_disk_and_delete_is_idempotent() {
1782 let tmp = tempfile::tempdir().unwrap();
1783 let root = tmp.path();
1784 assert!(
1785 read_findings_store(root, "engine", "graph")
1786 .unwrap()
1787 .is_none()
1788 );
1789
1790 let mut store = FindingsStore {
1791 binding: "engine/graph".to_string(),
1792 ..Default::default()
1793 };
1794 let k = key("hashA", "head1");
1795 store.record(
1796 k.clone(),
1797 "1".to_string(),
1798 vec![Finding {
1799 key: k.clone(),
1800 facet: "src".to_string(),
1801 target: FindingTarget::Artifact {
1802 artifact: "src/a.rs".to_string(),
1803 },
1804 class: FindingClass::Uncovered,
1805 detail: "d".to_string(),
1806 created_at: "1".to_string(),
1807 }],
1808 );
1809 write_findings_store(root, "engine", "graph", &store).unwrap();
1810 assert!(findings_store_path(root, "engine", "graph").exists());
1811
1812 let ignore = root
1815 .join(WORKSPACE_STORE_DIR)
1816 .join(STATE_DIR)
1817 .join(FINDINGS_DIR)
1818 .join(".gitignore");
1819 assert_eq!(std::fs::read_to_string(&ignore).unwrap(), "*\n");
1820
1821 let back = read_findings_store(root, "engine", "graph")
1823 .unwrap()
1824 .unwrap();
1825 assert_eq!(back, store);
1826 assert_eq!(back.current(&k).len(), 1);
1827
1828 delete_findings_store(root, "engine", "graph").unwrap();
1829 assert!(
1830 read_findings_store(root, "engine", "graph")
1831 .unwrap()
1832 .is_none()
1833 );
1834 delete_findings_store(root, "engine", "graph").unwrap();
1836 }
1837
1838 #[test]
1841 fn changed_binding_hash_supersedes_prior_findings() {
1842 let mut store = FindingsStore::default();
1843 let old = key("hashOLD", "head1");
1844 let new = key("hashNEW", "head1");
1845 let f_old = Finding {
1846 key: old.clone(),
1847 facet: "src".to_string(),
1848 target: FindingTarget::Artifact {
1849 artifact: "src/old.rs".to_string(),
1850 },
1851 class: FindingClass::Uncovered,
1852 detail: "old".to_string(),
1853 created_at: "1".to_string(),
1854 };
1855 store.record(old.clone(), "1".to_string(), vec![f_old.clone()]);
1856
1857 store.record(new.clone(), "2".to_string(), Vec::new());
1859 assert!(store.current(&new).is_empty(), "new key has its own view");
1860 let superseded = store.superseded(&new);
1861 assert_eq!(superseded.len(), 1, "old batch is segregated as superseded");
1862 assert_eq!(superseded[0], &f_old);
1863 assert!(!store.current(&new).contains(&f_old));
1865 }
1866
1867 #[test]
1875 fn impl_version_bump_invalidates_findings_by_construction() {
1876 use crate::binding::{
1877 PREPARATION_IMPL_VERSION, ScaffoldParams, hash_binding, hash_binding_at_impl_version,
1878 scaffold_binding,
1879 };
1880 let binding = scaffold_binding(ScaffoldParams {
1881 destination_mem: "plugin",
1882 source_name: "source-tree",
1883 pointer: "../public",
1884 medium_type: crate::pipeline::MediumType::Codebase,
1885 intent: None,
1886 additional_deny_paths: Vec::new(),
1887 })
1888 .binding;
1889 assert!(binding.sources[0].preparation.is_none());
1890 let _ = PREPARATION_IMPL_VERSION;
1894 let old = key(&hash_binding_at_impl_version(&binding, 0), "head1");
1895 let live = key(&hash_binding(&binding), "head1");
1896 assert_ne!(old.binding_hash, live.binding_hash);
1897
1898 let mut store = FindingsStore::default();
1899 let f_old = Finding {
1900 key: old.clone(),
1901 facet: "source-tree".to_string(),
1902 target: FindingTarget::Artifact {
1903 artifact: "src/old.rs".to_string(),
1904 },
1905 class: FindingClass::Uncovered,
1906 detail: "recorded before the bump".to_string(),
1907 created_at: "1".to_string(),
1908 };
1909 store.record(old.clone(), "1".to_string(), vec![f_old.clone()]);
1910
1911 assert!(
1912 store.current(&live).is_empty(),
1913 "a finding keyed on the pre-bump hash is invalid under the live hash"
1914 );
1915 assert_eq!(store.superseded(&live), vec![&f_old]);
1916 assert_eq!(
1917 store.current(&old),
1918 &[f_old.clone()][..],
1919 "nothing is deleted"
1920 );
1921 }
1922
1923 #[test]
1930 fn moved_source_head_keeps_findings_current_until_superseded() {
1931 let mut store = FindingsStore::default();
1932 let before = key("hashA", "head1");
1933 let after = key("hashA", "head2");
1934 let f = Finding {
1935 key: before.clone(),
1936 facet: "src".to_string(),
1937 target: FindingTarget::Anchor {
1938 entity: "engine--e".to_string(),
1939 artifact: "src/x.rs".to_string(),
1940 },
1941 class: FindingClass::UnresolvableAnchor,
1942 detail: "gone".to_string(),
1943 created_at: "1".to_string(),
1944 };
1945 store.record(before.clone(), "1".to_string(), vec![f.clone()]);
1946
1947 assert_eq!(store.current(&after), std::slice::from_ref(&f));
1950 assert_eq!(store.current(&after)[0].key.source_head, "head1");
1951 assert!(store.superseded(&after).is_empty());
1952
1953 store.record(after.clone(), "2".to_string(), Vec::new());
1956 assert!(store.current(&after).is_empty());
1957 assert!(store.current(&before).is_empty(), "at the old head too");
1958 assert_eq!(store.batches.len(), 1, "one batch per hash(D)");
1959 }
1960
1961 #[test]
1969 fn legacy_per_head_store_loads_and_presents_head_agnostically() {
1970 let tmp = tempfile::tempdir().unwrap();
1971 let root = tmp.path();
1972 let path = findings_store_path(root, "engine", "graph");
1973 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1974 std::fs::write(
1979 &path,
1980 r#"{
1981 "binding": "engine/graph",
1982 "batches": [
1983 {
1984 "key": { "binding_hash": "hashOLD", "source_head": "src=aaa" },
1985 "recorded_at": "100",
1986 "findings": [
1987 {
1988 "key": { "binding_hash": "hashOLD", "source_head": "src=aaa" },
1989 "facet": "src",
1990 "target": { "kind": "artifact", "artifact": "src/old.rs" },
1991 "class": "uncovered",
1992 "detail": "old declaration",
1993 "created_at": "100"
1994 }
1995 ]
1996 },
1997 {
1998 "key": { "binding_hash": "hashCUR", "source_head": "src=bbb" },
1999 "recorded_at": "200",
2000 "findings": [
2001 {
2002 "key": { "binding_hash": "hashCUR", "source_head": "src=bbb" },
2003 "facet": "src",
2004 "target": { "kind": "artifact", "artifact": "src/resolved-at-ccc.rs" },
2005 "class": "uncovered",
2006 "detail": "was open at bbb, absent from the ccc batch",
2007 "created_at": "200"
2008 }
2009 ]
2010 },
2011 {
2012 "key": { "binding_hash": "hashCUR", "source_head": "src=ccc" },
2013 "recorded_at": "300",
2014 "findings": [
2015 {
2016 "key": { "binding_hash": "hashCUR", "source_head": "src=ccc" },
2017 "facet": "src",
2018 "target": { "kind": "anchor", "entity": "engine--e", "artifact": "src/x.rs" },
2019 "class": "unresolvable-anchor",
2020 "detail": "gone",
2021 "created_at": "300"
2022 }
2023 ]
2024 }
2025 ]
2026 }"#,
2027 )
2028 .unwrap();
2029
2030 let mut store = read_findings_store(root, "engine", "graph")
2031 .unwrap()
2032 .expect("the legacy on-disk format loads as-is");
2033 assert_eq!(store.binding, "engine/graph");
2034 assert_eq!(store.batches.len(), 3, "loaded without loss");
2035
2036 let now = key("hashCUR", "src=ddd");
2039 let current = store.current(&now);
2040 assert_eq!(current.len(), 1);
2041 assert_eq!(current[0].detail, "gone");
2042 assert_eq!(
2043 current[0].key.source_head, "src=ccc",
2044 "the finding keeps the head it was observed at"
2045 );
2046 let superseded = store.superseded(&now);
2049 assert_eq!(superseded.len(), 2);
2050 assert!(
2051 !current.iter().any(|f| f.detail.contains("was open at bbb")),
2052 "the older same-hash batch was superseded at write time and is not resurrected"
2053 );
2054
2055 store.record(now.clone(), "400".to_string(), Vec::new());
2058 assert_eq!(store.batches.len(), 2, "hashCUR collapsed, hashOLD kept");
2059 assert_eq!(store.superseded(&now).len(), 1);
2060 }
2061
2062 #[test]
2067 fn merge_carries_unobserved_open_findings_and_closes_departed() {
2068 let k_old = key("h", "head1");
2069 let mk_artifact = |artifact: &str, detail: &str| Finding {
2070 key: k_old.clone(),
2071 facet: "src".to_string(),
2072 target: FindingTarget::Artifact {
2073 artifact: artifact.to_string(),
2074 },
2075 class: FindingClass::Uncovered,
2076 detail: detail.to_string(),
2077 created_at: "1".to_string(),
2078 };
2079 let anchor_finding = Finding {
2080 key: k_old.clone(),
2081 facet: "src".to_string(),
2082 target: FindingTarget::Anchor {
2083 entity: "engine--gone".to_string(),
2084 artifact: "src/gone.rs".to_string(),
2085 },
2086 class: FindingClass::UnresolvableAnchor,
2087 detail: "anchor since removed from the mem".to_string(),
2088 created_at: "1".to_string(),
2089 };
2090 let prior = vec![
2091 mk_artifact("src/unsampled.rs", "still open, not in this window"),
2092 mk_artifact("src/departed.rs", "left S(D)"),
2093 mk_artifact("src/now-covered.rs", "gained an anchor since"),
2094 mk_artifact("src/observed-clean.rs", "re-sampled and now covered"),
2095 anchor_finding,
2096 ];
2097 let obs = PassObservation {
2098 anchors_observed: BTreeSet::new(),
2099 anchors_existing: BTreeSet::new(), files_observed: ["src/observed-clean.rs".to_string()].into(),
2101 s_d: [
2102 "src/unsampled.rs".to_string(),
2103 "src/now-covered.rs".to_string(),
2104 "src/observed-clean.rs".to_string(),
2105 ]
2106 .into(),
2107 };
2108 let merged = merge_with_prior(Vec::new(), &prior, &obs, |artifact| {
2109 artifact == "src/now-covered.rs" || artifact == "src/observed-clean.rs"
2110 });
2111 assert_eq!(merged.len(), 1, "only the still-open unsampled one carries");
2112 assert_eq!(
2113 merged[0].target,
2114 FindingTarget::Artifact {
2115 artifact: "src/unsampled.rs".to_string()
2116 }
2117 );
2118 assert_eq!(
2119 merged[0].key.source_head, "head1",
2120 "a carried finding keeps the head it was observed at"
2121 );
2122 }
2123
2124 #[test]
2129 fn merge_deferral_never_downgrades_prior_adjudication() {
2130 let k_old = key("h", "head1");
2131 let k_new = key("h", "head2");
2132 let target = FindingTarget::Anchor {
2133 entity: "engine--e".to_string(),
2134 artifact: "src/x.rs".to_string(),
2135 };
2136 let prior_drifted = Finding {
2137 key: k_old.clone(),
2138 facet: "src".to_string(),
2139 target: target.clone(),
2140 class: FindingClass::Drifted,
2141 detail: "adjudicated drifted at head1".to_string(),
2142 created_at: "1".to_string(),
2143 };
2144 let fresh_queued = Finding {
2145 key: k_new.clone(),
2146 facet: "src".to_string(),
2147 target: target.clone(),
2148 class: FindingClass::QueuedForAdjudication,
2149 detail: "deferred by the cap this run".to_string(),
2150 created_at: "2".to_string(),
2151 };
2152 let obs = PassObservation {
2153 anchors_observed: [target_key(&target)].into(),
2154 anchors_existing: [target_key(&target)].into(),
2155 files_observed: BTreeSet::new(),
2156 s_d: BTreeSet::new(),
2157 };
2158 let merged = merge_with_prior(
2159 vec![fresh_queued],
2160 std::slice::from_ref(&prior_drifted),
2161 &obs,
2162 |_| true,
2163 );
2164 assert_eq!(merged.len(), 1);
2165 assert_eq!(
2166 merged[0].class,
2167 FindingClass::Drifted,
2168 "the prior verdict stands over a deferral"
2169 );
2170 assert_eq!(merged[0].key.source_head, "head1");
2171 }
2172
2173 #[test]
2176 fn informed_by_anchor_never_drifts() {
2177 let k = key("h", "s");
2178 for class in [
2179 AnchorProvenanceClass::InformedBy,
2180 AnchorProvenanceClass::Authored,
2181 ] {
2182 let a = anchor(class);
2183 assert!(
2184 adjudicate_anchor(&k, "f", "engine--e", &a, AnchorState::Drifted, "1").is_none(),
2185 "{class:?} must not produce a drift finding"
2186 );
2187 assert!(
2188 adjudicate_anchor(&k, "f", "engine--e", &a, AnchorState::Recheck, "1").is_none(),
2189 "{class:?} must not produce a queued finding"
2190 );
2191 }
2192 }
2193
2194 #[test]
2197 fn hash_bearing_drifts_and_orphan_is_class_independent() {
2198 let k = key("h", "s");
2199 let anchored = anchor(AnchorProvenanceClass::Anchored);
2200 let drifted =
2201 adjudicate_anchor(&k, "f", "engine--e", &anchored, AnchorState::Drifted, "1").unwrap();
2202 assert_eq!(drifted.class, FindingClass::Drifted);
2203 assert_eq!(drifted.key, k, "the finding carries its recording key (A2)");
2204
2205 let queued =
2206 adjudicate_anchor(&k, "f", "engine--e", &anchored, AnchorState::Recheck, "1").unwrap();
2207 assert_eq!(queued.class, FindingClass::QueuedForAdjudication);
2208
2209 let informed = anchor(AnchorProvenanceClass::InformedBy);
2211 let orphan =
2212 adjudicate_anchor(&k, "f", "engine--e", &informed, AnchorState::Orphaned, "1").unwrap();
2213 assert_eq!(orphan.class, FindingClass::UnresolvableAnchor);
2214
2215 assert!(
2217 adjudicate_anchor(&k, "f", "engine--e", &anchored, AnchorState::Resolves, "1")
2218 .is_none()
2219 );
2220 }
2221
2222 #[test]
2224 fn finding_class_wire_round_trips() {
2225 for w in FindingClass::WIRE_VALUES {
2226 let c = FindingClass::from_wire(w).expect("known wire value");
2227 assert_eq!(c.as_wire(), *w);
2228 }
2229 assert!(FindingClass::from_wire("nonsense").is_none());
2230 }
2231
2232 #[test]
2234 fn malformed_binding_id_refuses() {
2235 assert!(matches!(
2236 split_binding_id("../escape"),
2237 Err(FindingsError::MalformedId(_))
2238 ));
2239 assert!(matches!(
2240 split_binding_id("no-slash"),
2241 Err(FindingsError::MalformedId(_))
2242 ));
2243 assert_eq!(
2244 split_binding_id("engine/graph").unwrap(),
2245 ("engine".to_string(), "graph".to_string())
2246 );
2247 }
2248
2249 use crate::anchor::AnchorSidecar;
2252 use crate::binding::{
2253 BINDING_VERSION, BuildMode, BuildOperation, CoverageSemantics, DEFAULT_ADJUDICATION_CAP,
2254 DEFAULT_FULL_RESYNC_EVERY, Operations, VerifyOperation,
2255 };
2256 use crate::ingest::resolve::resolve_binding_run;
2257 use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode};
2258 use crate::pipeline_store::{load_pipeline_configs, write_binding};
2259 use crate::workspace::{
2260 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
2261 };
2262 use crate::workspace_store::WorkspaceStoreAdapter;
2263
2264 #[test]
2272 fn verify_persists_findings_readable_fresh() {
2273 let tmp = tempfile::tempdir().unwrap();
2274 let root = tmp.path();
2275 let mem_dir = root.join("mem");
2276 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2277 std::fs::write(
2278 mem_dir.join(".memstead").join("config.json"),
2279 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2280 )
2281 .unwrap();
2282
2283 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2286 std::fs::write(
2287 root.join(".memstead").join("workspace.toml"),
2288 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2289 )
2290 .unwrap();
2291 let mount = Mount {
2292 mem: "engine".to_string(),
2293 schema: Some("default@1.0.0".parse().unwrap()),
2294 storage: MountStorage::Folder {
2295 path: mem_dir.clone(),
2296 },
2297 capability: MountCapability::Write,
2298 lifecycle: MountLifecycle::Eager,
2299 cross_linkable: false,
2300 migration_target: None,
2301 };
2302 crate::FileWorkspaceStore::new()
2303 .save_state(
2304 root,
2305 &Workspace {
2306 mounts: vec![mount],
2307 settings: WorkspaceSettings::default(),
2308 },
2309 )
2310 .unwrap();
2311
2312 let out = std::process::Command::new("git")
2316 .args(["init", "-q"])
2317 .current_dir(root)
2318 .output()
2319 .unwrap();
2320 assert!(out.status.success());
2321 std::fs::create_dir_all(root.join("src")).unwrap();
2322 std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
2323 std::fs::write(root.join("src").join("uncovered.rs"), "fn b() {}\n").unwrap();
2324
2325 let mk = |artifact: &str, class: AnchorProvenanceClass| Anchor {
2328 artifact: artifact.to_string(),
2329 grain: AnchorGrain::File,
2330 class,
2331 at_version: None,
2332 hash: class.is_hash_bearing().then(|| "recorded".to_string()),
2333 hash_stability: AnchorHashStability::Stable,
2334 derived_from: Vec::new(),
2335 binding: None,
2336 source: None,
2337 span_unvalidated: false,
2338 hash_source: None,
2339 };
2340 std::fs::write(
2344 mem_dir.join("e.md"),
2345 "---\ntype: decision\n---\n\n# E\n\n## Decision\n\nBody.\n",
2346 )
2347 .unwrap();
2348 let mut sidecar = AnchorSidecar::default();
2349 sidecar.set(
2350 "engine--e",
2351 vec![
2352 mk("src/present.rs", AnchorProvenanceClass::Anchored), mk("src/gone.rs", AnchorProvenanceClass::Anchored), mk("src/present.rs", AnchorProvenanceClass::InformedBy), ],
2356 );
2357 std::fs::write(
2358 mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
2359 sidecar.to_bytes(),
2360 )
2361 .unwrap();
2362
2363 write_binding(
2365 root,
2366 "engine",
2367 "graph",
2368 &Binding {
2369 version: BINDING_VERSION,
2370 intent: None,
2371 sources: vec![crate::pipeline::Source {
2372 name: "graph".to_string(),
2373 medium_type: MediumType::Codebase,
2374 pointer: String::new(),
2375 change_detection: Some("git".to_string()),
2376 scope: vec![PatternEntry {
2377 path: "src/**/*.rs".to_string(),
2378 mode: PatternMode::Allow,
2379 }],
2380 engagement: None,
2381 preparation: None,
2382 }],
2383 reference_mems: Vec::new(),
2384 destination_mem: "engine".to_string(),
2385 deny_paths: Vec::new(),
2386 coverage_semantics: None,
2387 rules: None,
2388 prune: None,
2389 operations: Operations {
2390 build: Some(BuildOperation {
2391 mode: BuildMode::Discovery,
2392 trigger: IngestTrigger::Loop,
2393 batch_size: 20,
2394 post_actions: None,
2395 }),
2396 sync: None,
2397 verify: Some(VerifyOperation {
2398 trigger: IngestTrigger::Manual,
2399 batch_size: 20,
2400 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2401 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2402 }),
2403 },
2404 },
2405 )
2406 .unwrap();
2407
2408 let engine = Engine::from_workspace_root(root).unwrap();
2409
2410 let configs = load_pipeline_configs(root).unwrap();
2411 let binding = &configs.bindings[0].config;
2412 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
2413
2414 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2416 assert!(
2417 outcome.recorded >= 3,
2418 "orphan + drifted + uncovered at least"
2419 );
2420 assert_eq!(outcome.superseded, 0, "no prior key yet");
2421 assert_eq!(
2422 outcome.backlog, 0,
2423 "the mismatching hash adjudicated deterministically — nothing queued"
2424 );
2425 assert!(
2426 outcome.hash_backfill.is_empty(),
2427 "every hash-bearing anchor already carries a recorded hash — nothing to backfill"
2428 );
2429
2430 let store = read_findings_store(root, "engine", "graph")
2432 .unwrap()
2433 .unwrap();
2434 let current = store.current(&outcome.key);
2435 assert_eq!(current.len(), outcome.recorded);
2436
2437 let has = |c: FindingClass, art: &str| {
2438 current.iter().any(|f| {
2439 f.class == c
2440 && match &f.target {
2441 FindingTarget::Anchor { artifact, .. } => artifact == art,
2442 FindingTarget::Artifact { artifact } => artifact == art,
2443 }
2444 })
2445 };
2446 assert!(has(FindingClass::UnresolvableAnchor, "src/gone.rs"));
2447 assert!(
2448 has(FindingClass::Drifted, "src/present.rs"),
2449 "recorded-hash mismatch on a stable medium adjudicates drifted deterministically"
2450 );
2451 assert!(has(FindingClass::Uncovered, "src/uncovered.rs"));
2452 assert!(
2456 !current
2457 .iter()
2458 .any(|f| f.class == FindingClass::QueuedForAdjudication
2459 || f.class == FindingClass::Wrong),
2460 "deterministic adjudication leaves nothing queued"
2461 );
2462 assert!(!has(FindingClass::Uncovered, "src/present.rs"));
2464 }
2465
2466 #[test]
2472 fn finding_recorded_at_old_head_presents_in_brief_at_new_head() {
2473 use crate::ingest::render::render_sync_brief_for;
2474
2475 let tmp = tempfile::tempdir().unwrap();
2476 let root = tmp.path();
2477 let mem_dir = root.join("mem");
2478 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2479 std::fs::write(
2480 mem_dir.join(".memstead").join("config.json"),
2481 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2482 )
2483 .unwrap();
2484 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2485 std::fs::write(
2486 root.join(".memstead").join("workspace.toml"),
2487 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2488 )
2489 .unwrap();
2490 let mount = Mount {
2491 mem: "engine".to_string(),
2492 schema: Some("default@1.0.0".parse().unwrap()),
2493 storage: MountStorage::Folder {
2494 path: mem_dir.clone(),
2495 },
2496 capability: MountCapability::Write,
2497 lifecycle: MountLifecycle::Eager,
2498 cross_linkable: false,
2499 migration_target: None,
2500 };
2501 crate::FileWorkspaceStore::new()
2502 .save_state(
2503 root,
2504 &Workspace {
2505 mounts: vec![mount],
2506 settings: WorkspaceSettings::default(),
2507 },
2508 )
2509 .unwrap();
2510
2511 let git = |args: &[&str]| {
2513 let out = std::process::Command::new("git")
2514 .args(args)
2515 .current_dir(root)
2516 .env("GIT_AUTHOR_NAME", "t")
2517 .env("GIT_AUTHOR_EMAIL", "t@t")
2518 .env("GIT_COMMITTER_NAME", "t")
2519 .env("GIT_COMMITTER_EMAIL", "t@t")
2520 .output()
2521 .unwrap();
2522 assert!(
2523 out.status.success(),
2524 "git {args:?}: {}",
2525 String::from_utf8_lossy(&out.stderr)
2526 );
2527 };
2528 git(&["init", "-q"]);
2529 std::fs::create_dir_all(root.join("src")).unwrap();
2530 std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
2531 git(&["add", "-A"]);
2532 git(&["commit", "-qm", "head-a"]);
2533
2534 let mk = |artifact: &str| Anchor {
2537 artifact: artifact.to_string(),
2538 grain: AnchorGrain::File,
2539 class: AnchorProvenanceClass::InformedBy,
2540 at_version: None,
2541 hash: None,
2542 hash_stability: AnchorHashStability::Stable,
2543 derived_from: Vec::new(),
2544 binding: None,
2545 source: None,
2546 span_unvalidated: false,
2547 hash_source: None,
2548 };
2549 std::fs::write(
2553 mem_dir.join("e.md"),
2554 "---\ntype: decision\n---\n\n# E\n\n## Decision\n\nBody.\n",
2555 )
2556 .unwrap();
2557 let mut sidecar = AnchorSidecar::default();
2558 sidecar.set("engine--e", vec![mk("src/present.rs"), mk("src/gone.rs")]);
2559 std::fs::write(
2560 mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
2561 sidecar.to_bytes(),
2562 )
2563 .unwrap();
2564
2565 write_binding(
2566 root,
2567 "engine",
2568 "graph",
2569 &Binding {
2570 version: BINDING_VERSION,
2571 intent: None,
2572 sources: vec![crate::pipeline::Source {
2573 name: "graph".to_string(),
2574 medium_type: MediumType::Codebase,
2575 pointer: String::new(),
2576 change_detection: Some("git".to_string()),
2577 scope: vec![PatternEntry {
2578 path: "src/**/*.rs".to_string(),
2579 mode: PatternMode::Allow,
2580 }],
2581 engagement: None,
2582 preparation: None,
2583 }],
2584 reference_mems: Vec::new(),
2585 destination_mem: "engine".to_string(),
2586 deny_paths: Vec::new(),
2587 coverage_semantics: None,
2588 rules: None,
2589 prune: None,
2590 operations: Operations {
2591 build: None,
2592 sync: Some(crate::binding::SyncOperation {
2593 trigger: IngestTrigger::Manual,
2594 batch_size: 20,
2595 }),
2596 verify: Some(VerifyOperation {
2597 trigger: IngestTrigger::Manual,
2598 batch_size: 20,
2599 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2600 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2601 }),
2602 },
2603 },
2604 )
2605 .unwrap();
2606
2607 let configs = load_pipeline_configs(root).unwrap();
2609 let binding = &configs.bindings[0].config;
2610 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
2611 let head_a_outcome = {
2612 let engine = Engine::from_workspace_root(root).unwrap();
2613 verify_binding(&engine, root, binding, &resolved).unwrap()
2614 };
2615 assert!(
2616 head_a_outcome.key.source_head.contains("graph="),
2617 "the run observed a facet head"
2618 );
2619
2620 std::fs::write(root.join("src").join("present.rs"), "fn a() {} // more\n").unwrap();
2622 git(&["add", "-A"]);
2623 git(&["commit", "-qm", "head-b"]);
2624
2625 {
2628 let engine = Engine::from_workspace_root(root).unwrap();
2629 let (key_b, findings) = current_findings(&engine, root, binding, &resolved).unwrap();
2630 assert_ne!(
2631 key_b.source_head, head_a_outcome.key.source_head,
2632 "the head really moved"
2633 );
2634 assert_eq!(findings.len(), 1);
2635 assert_eq!(findings[0].class, FindingClass::UnresolvableAnchor);
2636 assert_eq!(
2637 findings[0].key.source_head, head_a_outcome.key.source_head,
2638 "the finding still records the head it was observed at"
2639 );
2640
2641 let brief = render_sync_brief_for(&engine, root, "engine/graph").unwrap();
2642 assert!(brief.contains("## Open findings to repair"));
2643 assert!(brief.contains("src/gone.rs"));
2644 }
2645
2646 std::fs::write(root.join("src").join("gone.rs"), "fn g() {}\n").unwrap();
2649 git(&["add", "-A"]);
2650 git(&["commit", "-qm", "head-c"]);
2651 {
2652 let engine = Engine::from_workspace_root(root).unwrap();
2653 verify_binding(&engine, root, binding, &resolved).unwrap();
2654 }
2655 {
2657 let engine = Engine::from_workspace_root(root).unwrap();
2658 let (_key, findings) = current_findings(&engine, root, binding, &resolved).unwrap();
2659 assert!(
2660 findings
2661 .iter()
2662 .all(|f| f.class != FindingClass::UnresolvableAnchor),
2663 "the resolved orphan finding must not re-present: {findings:?}"
2664 );
2665 }
2666 }
2667
2668 #[test]
2683 fn hashless_anchor_backfills_once_then_drift_adjudicates_deterministically() {
2684 let tmp = tempfile::tempdir().unwrap();
2685 let root = tmp.path();
2686 let mem_dir = root.join("mem");
2687 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2688 std::fs::write(
2689 mem_dir.join(".memstead").join("config.json"),
2690 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2691 )
2692 .unwrap();
2693 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2694 std::fs::write(
2695 root.join(".memstead").join("workspace.toml"),
2696 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2697 )
2698 .unwrap();
2699 let mount = Mount {
2700 mem: "engine".to_string(),
2701 schema: Some("default@1.0.0".parse().unwrap()),
2702 storage: MountStorage::Folder {
2703 path: mem_dir.clone(),
2704 },
2705 capability: MountCapability::Write,
2706 lifecycle: MountLifecycle::Eager,
2707 cross_linkable: false,
2708 migration_target: None,
2709 };
2710 crate::FileWorkspaceStore::new()
2711 .save_state(
2712 root,
2713 &Workspace {
2714 mounts: vec![mount],
2715 settings: WorkspaceSettings::default(),
2716 },
2717 )
2718 .unwrap();
2719
2720 let git = |args: &[&str]| {
2722 let out = std::process::Command::new("git")
2723 .args(args)
2724 .current_dir(root)
2725 .env("GIT_AUTHOR_NAME", "t")
2726 .env("GIT_AUTHOR_EMAIL", "t@t")
2727 .env("GIT_COMMITTER_NAME", "t")
2728 .env("GIT_COMMITTER_EMAIL", "t@t")
2729 .output()
2730 .unwrap();
2731 assert!(
2732 out.status.success(),
2733 "git {args:?}: {}",
2734 String::from_utf8_lossy(&out.stderr)
2735 );
2736 };
2737 git(&["init", "-q"]);
2738 std::fs::create_dir_all(root.join("src")).unwrap();
2739 std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
2740 std::fs::write(root.join("src").join("other.rs"), "fn o() {}\n").unwrap();
2741 git(&["add", "-A"]);
2742 git(&["commit", "-qm", "head-a"]);
2743
2744 let mk = |artifact: &str, class: AnchorProvenanceClass, stab: AnchorHashStability| Anchor {
2748 artifact: artifact.to_string(),
2749 grain: AnchorGrain::File,
2750 class,
2751 at_version: None,
2752 hash: None,
2753 hash_stability: stab,
2754 derived_from: if class == AnchorProvenanceClass::Derived {
2755 vec!["src/present.rs".to_string()]
2756 } else {
2757 Vec::new()
2758 },
2759 binding: None,
2760 source: None,
2761 span_unvalidated: false,
2762 hash_source: None,
2763 };
2764 use AnchorHashStability::{Stable, Unstable};
2765 std::fs::write(
2769 mem_dir.join("e.md"),
2770 "---\ntype: decision\n---\n\n# E\n\n## Decision\n\nBody.\n",
2771 )
2772 .unwrap();
2773 let mut sidecar = AnchorSidecar::default();
2774 sidecar.set(
2775 "engine--e",
2776 vec![
2777 mk("src/present.rs", AnchorProvenanceClass::Anchored, Stable),
2778 mk("src/present.rs", AnchorProvenanceClass::Derived, Stable),
2779 mk("src/other.rs", AnchorProvenanceClass::Anchored, Unstable),
2780 mk("src/present.rs", AnchorProvenanceClass::Authored, Stable),
2781 mk("src/present.rs", AnchorProvenanceClass::InformedBy, Stable),
2782 ],
2783 );
2784 std::fs::write(
2785 mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
2786 sidecar.to_bytes(),
2787 )
2788 .unwrap();
2789
2790 write_binding(
2791 root,
2792 "engine",
2793 "graph",
2794 &Binding {
2795 version: BINDING_VERSION,
2796 intent: None,
2797 sources: vec![crate::pipeline::Source {
2798 name: "graph".to_string(),
2799 medium_type: MediumType::Codebase,
2800 pointer: String::new(),
2801 change_detection: Some("git".to_string()),
2802 scope: vec![PatternEntry {
2803 path: "src/**/*.rs".to_string(),
2804 mode: PatternMode::Allow,
2805 }],
2806 engagement: None,
2807 preparation: None,
2808 }],
2809 reference_mems: Vec::new(),
2810 destination_mem: "engine".to_string(),
2811 deny_paths: Vec::new(),
2812 coverage_semantics: None,
2813 rules: None,
2814 prune: None,
2815 operations: Operations {
2816 build: None,
2817 sync: None,
2818 verify: Some(VerifyOperation {
2819 trigger: IngestTrigger::Manual,
2820 batch_size: 20,
2821 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2822 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2823 }),
2824 },
2825 },
2826 )
2827 .unwrap();
2828
2829 let configs = load_pipeline_configs(root).unwrap();
2830 let binding = &configs.bindings[0].config;
2831 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
2832
2833 {
2835 let mut engine = Engine::from_workspace_root(root).unwrap();
2836 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2837 let mut backfilled: Vec<(&str, &str)> = outcome
2840 .hash_backfill
2841 .iter()
2842 .map(|b| (b.entity.as_str(), b.artifact.as_str()))
2843 .collect();
2844 backfilled.sort();
2845 backfilled.dedup();
2846 assert_eq!(
2847 backfilled,
2848 vec![
2849 ("engine--e", "src/other.rs"),
2850 ("engine--e", "src/present.rs"),
2851 ],
2852 "hash-bearing anchors backfill; authored/informed-by never appear"
2853 );
2854 assert_eq!(
2857 outcome.backlog, 0,
2858 "no recheck queue for backfilled anchors"
2859 );
2860 let store = read_findings_store(root, "engine", "graph")
2861 .unwrap()
2862 .unwrap();
2863 assert!(
2864 store
2865 .current(&outcome.key)
2866 .iter()
2867 .all(|f| !matches!(f.target, FindingTarget::Anchor { .. })),
2868 "no anchor finding on the backfill pass: {:?}",
2869 store.current(&outcome.key)
2870 );
2871
2872 let written =
2874 record_anchor_hash_backfill(&mut engine, "engine", &outcome, None).unwrap();
2875 assert_eq!(
2876 written, 3,
2877 "anchored + derived + unstable-anchored gain hashes"
2878 );
2879 }
2880
2881 let expected_present = crate::anchor::prepared_content_hash(
2884 &std::fs::read(root.join("src").join("present.rs")).unwrap(),
2885 );
2886 {
2887 let sc = AnchorSidecar::from_bytes(
2888 &std::fs::read(mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH)).unwrap(),
2889 )
2890 .unwrap();
2891 for a in sc.get("engine--e") {
2892 if a.class.is_hash_bearing() {
2893 assert!(a.hash.is_some(), "hash-bearing anchor backfilled: {a:?}");
2894 } else {
2895 assert!(a.hash.is_none(), "non-hash class never gains a hash: {a:?}");
2896 }
2897 if a.artifact == "src/present.rs" && a.class.is_hash_bearing() {
2898 assert_eq!(a.hash.as_deref(), Some(expected_present.as_str()));
2899 }
2900 }
2901 }
2902
2903 {
2905 let mut engine = Engine::from_workspace_root(root).unwrap();
2906 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2907 assert!(
2908 outcome.hash_backfill.is_empty(),
2909 "backfill happens once — a re-verify observes an empty worklist"
2910 );
2911 assert_eq!(outcome.backlog, 0, "steady state: nothing re-queues");
2912 let store = read_findings_store(root, "engine", "graph")
2913 .unwrap()
2914 .unwrap();
2915 assert!(
2916 store
2917 .current(&outcome.key)
2918 .iter()
2919 .all(|f| !matches!(f.target, FindingTarget::Anchor { .. })),
2920 "recorded hashes match the source — no anchor finding"
2921 );
2922 let written =
2923 record_anchor_hash_backfill(&mut engine, "engine", &outcome, None).unwrap();
2924 assert_eq!(written, 0, "no write, no commit on the idempotent pass");
2925 }
2926
2927 std::fs::write(
2929 root.join("src").join("present.rs"),
2930 "fn a() { /* changed */ }\n",
2931 )
2932 .unwrap();
2933 std::fs::write(
2934 root.join("src").join("other.rs"),
2935 "fn o() { /* changed */ }\n",
2936 )
2937 .unwrap();
2938 git(&["add", "-A"]);
2939 git(&["commit", "-qm", "head-b"]);
2940
2941 {
2944 let engine = Engine::from_workspace_root(root).unwrap();
2945 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2946 assert!(
2947 outcome.hash_backfill.is_empty(),
2948 "recorded hashes are never overwritten by observation"
2949 );
2950 let store = read_findings_store(root, "engine", "graph")
2951 .unwrap()
2952 .unwrap();
2953 let current = store.current(&outcome.key);
2954 let drifted: Vec<&Finding> = current
2955 .iter()
2956 .filter(|f| f.class == FindingClass::Drifted)
2957 .collect();
2958 assert_eq!(
2961 drifted.len(),
2962 2,
2963 "stable-medium mismatch → drifted: {current:?}"
2964 );
2965 assert!(drifted.iter().all(|f| matches!(
2966 &f.target,
2967 FindingTarget::Anchor { artifact, .. } if artifact == "src/present.rs"
2968 )));
2969 assert!(
2972 current
2973 .iter()
2974 .any(|f| f.class == FindingClass::QueuedForAdjudication
2975 && matches!(
2976 &f.target,
2977 FindingTarget::Anchor { artifact, .. } if artifact == "src/other.rs"
2978 )),
2979 "unstable medium resolves recheck (queued), not drifted: {current:?}"
2980 );
2981 assert!(
2982 !current.iter().any(|f| f.class == FindingClass::Drifted
2983 && matches!(
2984 &f.target,
2985 FindingTarget::Anchor { artifact, .. } if artifact == "src/other.rs"
2986 )),
2987 "an unstable hash break must never assert drift"
2988 );
2989 }
2990 }
2991
2992 #[test]
2997 fn backfill_writer_refuses_non_hash_classes_and_never_overwrites() {
2998 let tmp = tempfile::tempdir().unwrap();
2999 let root = tmp.path();
3000 let mem_dir = root.join("mem");
3001 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3002 std::fs::write(
3003 mem_dir.join(".memstead").join("config.json"),
3004 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3005 )
3006 .unwrap();
3007 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3008 std::fs::write(
3009 root.join(".memstead").join("workspace.toml"),
3010 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3011 )
3012 .unwrap();
3013 crate::FileWorkspaceStore::new()
3014 .save_state(
3015 root,
3016 &Workspace {
3017 mounts: vec![Mount {
3018 mem: "engine".to_string(),
3019 schema: Some("default@1.0.0".parse().unwrap()),
3020 storage: MountStorage::Folder {
3021 path: mem_dir.clone(),
3022 },
3023 capability: MountCapability::Write,
3024 lifecycle: MountLifecycle::Eager,
3025 cross_linkable: false,
3026 migration_target: None,
3027 }],
3028 settings: WorkspaceSettings::default(),
3029 },
3030 )
3031 .unwrap();
3032
3033 let anchor = |class: AnchorProvenanceClass, hash: Option<&str>| Anchor {
3034 artifact: "src/a.rs".to_string(),
3035 grain: AnchorGrain::File,
3036 class,
3037 at_version: None,
3038 hash: hash.map(str::to_string),
3039 hash_stability: AnchorHashStability::Stable,
3040 derived_from: Vec::new(),
3041 binding: None,
3042 source: None,
3043 span_unvalidated: false,
3044 hash_source: None,
3045 };
3046 std::fs::write(
3050 mem_dir.join("e.md"),
3051 "---\ntype: decision\n---\n\n# E\n\n## Decision\n\nBody.\n",
3052 )
3053 .unwrap();
3054 let mut sidecar = AnchorSidecar::default();
3055 sidecar.set(
3056 "engine--e",
3057 vec![
3058 anchor(AnchorProvenanceClass::Authored, None),
3059 anchor(AnchorProvenanceClass::InformedBy, None),
3060 anchor(AnchorProvenanceClass::Anchored, Some("recorded")),
3061 ],
3062 );
3063 std::fs::write(
3064 mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
3065 sidecar.to_bytes(),
3066 )
3067 .unwrap();
3068
3069 let mut engine = Engine::from_workspace_root(root).unwrap();
3070 let written = engine
3071 .record_anchor_observed_hashes(
3072 "engine",
3073 &[crate::anchor::ObservedArtifactHash {
3074 entity: "engine--e".to_string(),
3075 artifact: "src/a.rs".to_string(),
3076 hash: "observed".to_string(),
3077 }],
3078 None,
3079 )
3080 .unwrap();
3081 assert_eq!(
3082 written, 0,
3083 "non-hash classes refuse the hash; a recorded hash is never overwritten"
3084 );
3085 let sc = AnchorSidecar::from_bytes(
3086 &std::fs::read(mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH)).unwrap(),
3087 )
3088 .unwrap();
3089 for a in sc.get("engine--e") {
3090 match a.class {
3091 AnchorProvenanceClass::Anchored => {
3092 assert_eq!(a.hash.as_deref(), Some("recorded"), "baseline stands")
3093 }
3094 _ => assert!(a.hash.is_none(), "non-hash class stays hash-less: {a:?}"),
3095 }
3096 }
3097 }
3098
3099 #[test]
3115 fn verify_refuses_unreachable_source_with_typed_error() {
3116 let tmp = tempfile::tempdir().unwrap();
3117 let root = tmp.path();
3118 let mem_dir = root.join("mem");
3119 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3120 std::fs::write(
3121 mem_dir.join(".memstead").join("config.json"),
3122 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3123 )
3124 .unwrap();
3125 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3126 std::fs::write(
3127 root.join(".memstead").join("workspace.toml"),
3128 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3129 )
3130 .unwrap();
3131 let mount = Mount {
3132 mem: "engine".to_string(),
3133 schema: Some("default@1.0.0".parse().unwrap()),
3134 storage: MountStorage::Folder {
3135 path: mem_dir.clone(),
3136 },
3137 capability: MountCapability::Write,
3138 lifecycle: MountLifecycle::Eager,
3139 cross_linkable: false,
3140 migration_target: None,
3141 };
3142 crate::FileWorkspaceStore::new()
3143 .save_state(
3144 root,
3145 &Workspace {
3146 mounts: vec![mount],
3147 settings: WorkspaceSettings::default(),
3148 },
3149 )
3150 .unwrap();
3151
3152 write_binding(
3156 root,
3157 "engine",
3158 "gone",
3159 &Binding {
3160 version: BINDING_VERSION,
3161 intent: None,
3162 sources: vec![crate::pipeline::Source {
3163 name: "gone".to_string(),
3164 medium_type: MediumType::Codebase,
3165 pointer: "vanished-src".to_string(),
3166 change_detection: Some("git".to_string()),
3167 scope: vec![PatternEntry {
3168 path: "**/*.rs".to_string(),
3169 mode: PatternMode::Allow,
3170 }],
3171 engagement: None,
3172 preparation: None,
3173 }],
3174 reference_mems: Vec::new(),
3175 destination_mem: "engine".to_string(),
3176 deny_paths: Vec::new(),
3177 coverage_semantics: None,
3178 rules: None,
3179 prune: None,
3180 operations: Operations {
3181 build: None,
3182 sync: None,
3183 verify: Some(VerifyOperation {
3184 trigger: IngestTrigger::Manual,
3185 batch_size: 20,
3186 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
3187 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
3188 }),
3189 },
3190 },
3191 )
3192 .unwrap();
3193
3194 let engine = Engine::from_workspace_root(root).unwrap();
3195 let configs = load_pipeline_configs(root).unwrap();
3196 let binding = &configs.bindings[0].config;
3197 let resolved = resolve_binding_run("engine/gone", binding).unwrap();
3198
3199 match verify_binding(&engine, root, binding, &resolved) {
3200 Err(FindingsError::SourceUnreachable { source_name, path }) => {
3201 assert_eq!(source_name, "gone");
3202 assert!(
3203 path.ends_with("vanished-src"),
3204 "refusal must name the resolved missing path, got `{path}`",
3205 );
3206 }
3207 other => panic!("expected SourceUnreachable refusal, got {other:?}"),
3208 }
3209
3210 assert!(
3213 !engine
3214 .mem_config_for("engine")
3215 .unwrap()
3216 .sync_state
3217 .keys()
3218 .any(|k| k.ends_with("#verified")),
3219 "a refused verify must not leave any #verified token",
3220 );
3221 }
3222
3223 #[test]
3224 fn completed_verify_records_the_verified_baseline() {
3225 let tmp = tempfile::tempdir().unwrap();
3226 let root = tmp.path();
3227 let mem_dir = root.join("mem");
3228 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3229 std::fs::write(
3230 mem_dir.join(".memstead").join("config.json"),
3231 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3232 )
3233 .unwrap();
3234 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3235 std::fs::write(
3236 root.join(".memstead").join("workspace.toml"),
3237 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3238 )
3239 .unwrap();
3240 let mount = Mount {
3241 mem: "engine".to_string(),
3242 schema: Some("default@1.0.0".parse().unwrap()),
3243 storage: MountStorage::Folder {
3244 path: mem_dir.clone(),
3245 },
3246 capability: MountCapability::Write,
3247 lifecycle: MountLifecycle::Eager,
3248 cross_linkable: false,
3249 migration_target: None,
3250 };
3251 crate::FileWorkspaceStore::new()
3252 .save_state(
3253 root,
3254 &Workspace {
3255 mounts: vec![mount],
3256 settings: WorkspaceSettings::default(),
3257 },
3258 )
3259 .unwrap();
3260 let out = std::process::Command::new("git")
3261 .args(["init", "-q"])
3262 .current_dir(root)
3263 .output()
3264 .unwrap();
3265 assert!(out.status.success());
3266
3267 write_binding(
3268 root,
3269 "engine",
3270 "graph",
3271 &Binding {
3272 version: BINDING_VERSION,
3273 intent: None,
3274 sources: vec![crate::pipeline::Source {
3275 name: "graph".to_string(),
3276 medium_type: MediumType::Codebase,
3277 pointer: String::new(),
3278 change_detection: Some("git".to_string()),
3279 scope: vec![PatternEntry {
3280 path: "src/**/*.rs".to_string(),
3281 mode: PatternMode::Allow,
3282 }],
3283 engagement: None,
3284 preparation: None,
3285 }],
3286 reference_mems: Vec::new(),
3287 destination_mem: "engine".to_string(),
3288 deny_paths: Vec::new(),
3289 coverage_semantics: None,
3290 rules: None,
3291 prune: None,
3292 operations: Operations {
3293 build: Some(BuildOperation {
3294 mode: BuildMode::Discovery,
3295 trigger: IngestTrigger::Loop,
3296 batch_size: 20,
3297 post_actions: None,
3298 }),
3299 sync: None,
3300 verify: Some(VerifyOperation {
3301 trigger: IngestTrigger::Manual,
3302 batch_size: 20,
3303 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
3304 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
3305 }),
3306 },
3307 },
3308 )
3309 .unwrap();
3310
3311 let mut engine = Engine::from_workspace_root(root).unwrap();
3312 engine
3315 .set_mem_sync_state("engine", "engine/graph/graph#synced", "deadbeef", None)
3316 .unwrap();
3317
3318 let configs = load_pipeline_configs(root).unwrap();
3319 let binding = &configs.bindings[0].config;
3320 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
3321
3322 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
3323 assert_eq!(
3325 outcome.facet_heads.get("graph").map(String::as_str),
3326 Some("deadbeef")
3327 );
3328 assert_eq!(outcome.key.source_head, "graph=deadbeef");
3329 assert_eq!(
3330 join_facet_heads(&outcome.facet_heads),
3331 outcome.key.source_head
3332 );
3333
3334 assert!(
3336 !engine
3337 .mem_config_for("engine")
3338 .unwrap()
3339 .sync_state
3340 .contains_key("engine/graph/graph#verified")
3341 );
3342
3343 let written = record_verified_baseline(&mut engine, "engine", &outcome, None).unwrap();
3344 assert_eq!(written, vec!["engine/graph/graph#verified".to_string()]);
3345
3346 assert_eq!(
3348 engine
3349 .mem_config_for("engine")
3350 .unwrap()
3351 .sync_state
3352 .get("engine/graph/graph#verified")
3353 .map(String::as_str),
3354 Some("deadbeef")
3355 );
3356 let disk: serde_json::Value = serde_json::from_slice(
3358 &std::fs::read(mem_dir.join(".memstead").join("config.json")).unwrap(),
3359 )
3360 .unwrap();
3361 assert_eq!(
3362 disk["syncState"]["engine/graph/graph#verified"],
3363 serde_json::json!("deadbeef")
3364 );
3365 }
3366
3367 #[test]
3374 fn adjudication_cap_queues_the_remainder() {
3375 let k = key("h", "s");
3376 let mk = |art: &str| {
3377 let mut a = anchor(AnchorProvenanceClass::Anchored);
3378 a.artifact = art.to_string();
3379 a
3380 };
3381 let candidates = vec![
3382 (
3383 "engine--a".to_string(),
3384 mk("src/a.rs"),
3385 AnchorState::Drifted,
3386 ),
3387 (
3388 "engine--b".to_string(),
3389 mk("src/b.rs"),
3390 AnchorState::Drifted,
3391 ),
3392 (
3393 "engine--c".to_string(),
3394 mk("src/c.rs"),
3395 AnchorState::Drifted,
3396 ),
3397 ];
3398 let window: BTreeSet<String> = [candidate_key("engine--a", &mk("src/a.rs"))]
3400 .into_iter()
3401 .collect();
3402 let out = adjudicate_candidates(&k, "f", &candidates, Some(&window), "1");
3403 let drifted = out
3404 .iter()
3405 .filter(|f| f.class == FindingClass::Drifted)
3406 .count();
3407 let queued = out
3408 .iter()
3409 .filter(|f| f.class == FindingClass::QueuedForAdjudication)
3410 .count();
3411 assert_eq!(drifted, 1, "only the in-window candidate is adjudicated");
3412 assert_eq!(queued, 2, "the remainder is queued as the tier-3 backlog");
3413 assert!(
3415 out.iter()
3416 .any(|f| f.class == FindingClass::QueuedForAdjudication
3417 && f.detail.contains("cap reached")),
3418 "capped remainder states it was deferred by the cap"
3419 );
3420
3421 let uncapped = adjudicate_candidates(&k, "f", &candidates, None, "1");
3423 assert_eq!(
3424 uncapped
3425 .iter()
3426 .filter(|f| f.class == FindingClass::Drifted)
3427 .count(),
3428 3,
3429 "uncapped adjudicates every candidate"
3430 );
3431 assert_eq!(
3432 uncapped
3433 .iter()
3434 .filter(|f| f.class == FindingClass::QueuedForAdjudication)
3435 .count(),
3436 0
3437 );
3438 }
3439
3440 #[test]
3446 fn full_resync_schedule_disabled_notdue_due() {
3447 let codebase = FacetEnumerability {
3448 facet: "src".to_string(),
3449 medium_type: "codebase".to_string(),
3450 enumerable: true,
3451 };
3452 assert_eq!(
3453 schedule_full_resync(0, 5, std::slice::from_ref(&codebase)),
3454 FullResyncDecision::Disabled
3455 );
3456 match schedule_full_resync(3, 2, std::slice::from_ref(&codebase)) {
3457 FullResyncDecision::NotDue { runs_until_due, .. } => assert_eq!(runs_until_due, 1),
3458 other => panic!("expected NotDue, got {other:?}"),
3459 }
3460 match schedule_full_resync(3, 3, std::slice::from_ref(&codebase)) {
3461 FullResyncDecision::Due {
3462 walked_facets,
3463 refused,
3464 ..
3465 } => {
3466 assert_eq!(walked_facets, vec!["src".to_string()]);
3467 assert!(refused.is_empty(), "enumerable facet is not refused");
3468 }
3469 other => panic!("expected Due, got {other:?}"),
3470 }
3471 }
3472
3473 #[test]
3476 fn full_resync_refuses_non_enumerable_medium() {
3477 let web = FacetEnumerability {
3478 facet: "manual".to_string(),
3479 medium_type: "web".to_string(),
3480 enumerable: false,
3481 };
3482 let d = schedule_full_resync(1, 1, &[web]);
3483 assert!(
3484 d.is_full_walk(),
3485 "a due sweep is a full walk even when refused"
3486 );
3487 match d {
3488 FullResyncDecision::Due {
3489 walked_facets,
3490 refused,
3491 ..
3492 } => {
3493 assert!(walked_facets.is_empty(), "nothing enumerable to walk");
3494 assert_eq!(refused.len(), 1, "the non-enumerable facet is refused");
3495 assert_eq!(refused[0].facet, "manual");
3496 assert_eq!(refused[0].medium_type, "web");
3497 assert!(
3498 refused[0].reason.contains("non-enumerable"),
3499 "the refusal is typed and states why"
3500 );
3501 }
3502 other => panic!("expected Due with a refusal, got {other:?}"),
3503 }
3504 }
3505
3506 #[test]
3511 fn full_resync_full_walk_covers_whole_source() {
3512 let tmp = tempfile::tempdir().unwrap();
3513 let root = tmp.path();
3514 let mem_dir = root.join("mem");
3515 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3516 std::fs::write(
3517 mem_dir.join(".memstead").join("config.json"),
3518 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3519 )
3520 .unwrap();
3521 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3522 std::fs::write(
3523 root.join(".memstead").join("workspace.toml"),
3524 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3525 )
3526 .unwrap();
3527 let mount = Mount {
3528 mem: "engine".to_string(),
3529 schema: Some("default@1.0.0".parse().unwrap()),
3530 storage: MountStorage::Folder {
3531 path: mem_dir.clone(),
3532 },
3533 capability: MountCapability::Write,
3534 lifecycle: MountLifecycle::Eager,
3535 cross_linkable: false,
3536 migration_target: None,
3537 };
3538 crate::FileWorkspaceStore::new()
3539 .save_state(
3540 root,
3541 &Workspace {
3542 mounts: vec![mount],
3543 settings: WorkspaceSettings::default(),
3544 },
3545 )
3546 .unwrap();
3547 let out = std::process::Command::new("git")
3548 .args(["init", "-q"])
3549 .current_dir(root)
3550 .output()
3551 .unwrap();
3552 assert!(out.status.success());
3553 std::fs::create_dir_all(root.join("src")).unwrap();
3554 for f in ["a.rs", "b.rs", "c.rs"] {
3555 std::fs::write(root.join("src").join(f), "fn x() {}\n").unwrap();
3556 }
3557
3558 write_binding(
3559 root,
3560 "engine",
3561 "graph",
3562 &Binding {
3563 version: BINDING_VERSION,
3564 intent: None,
3565 sources: vec![crate::pipeline::Source {
3566 name: "graph".to_string(),
3567 medium_type: MediumType::Codebase,
3568 pointer: String::new(),
3569 change_detection: Some("git".to_string()),
3570 scope: vec![PatternEntry {
3571 path: "src/**/*.rs".to_string(),
3572 mode: PatternMode::Allow,
3573 }],
3574 engagement: None,
3575 preparation: None,
3576 }],
3577 reference_mems: Vec::new(),
3578 destination_mem: "engine".to_string(),
3579 deny_paths: Vec::new(),
3580 coverage_semantics: None,
3581 rules: None,
3582 prune: None,
3583 operations: Operations {
3584 build: Some(BuildOperation {
3585 mode: BuildMode::Discovery,
3586 trigger: IngestTrigger::Loop,
3587 batch_size: 20,
3588 post_actions: None,
3589 }),
3590 sync: None,
3591 verify: Some(VerifyOperation {
3592 trigger: IngestTrigger::Manual,
3593 batch_size: 1, adjudication_cap: DEFAULT_ADJUDICATION_CAP,
3595 full_resync_every: 1, }),
3597 },
3598 },
3599 )
3600 .unwrap();
3601
3602 let engine = Engine::from_workspace_root(root).unwrap();
3603 let configs = load_pipeline_configs(root).unwrap();
3604 let binding = &configs.bindings[0].config;
3605 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
3606
3607 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
3608 match &outcome.full_resync {
3610 FullResyncDecision::Due {
3611 walked_facets,
3612 refused,
3613 run_count,
3614 ..
3615 } => {
3616 assert_eq!(*run_count, 1);
3617 assert_eq!(walked_facets, &vec!["graph".to_string()]);
3618 assert!(refused.is_empty());
3619 }
3620 other => panic!("expected a due full walk, got {other:?}"),
3621 }
3622 let store = read_findings_store(root, "engine", "graph")
3624 .unwrap()
3625 .unwrap();
3626 let uncovered = store
3627 .current(&outcome.key)
3628 .iter()
3629 .filter(|f| f.class == FindingClass::Uncovered)
3630 .count();
3631 assert_eq!(
3632 uncovered, 3,
3633 "the scheduled full walk covers the whole source, not a batch of one"
3634 );
3635 }
3636
3637 #[test]
3644 fn scheduled_full_walk_demotes_partial_facet_to_refusal() {
3645 let tmp = tempfile::tempdir().unwrap();
3646 let root = tmp.path();
3647 let mem_dir = root.join("mem");
3648 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3649 std::fs::write(
3650 mem_dir.join(".memstead").join("config.json"),
3651 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3652 )
3653 .unwrap();
3654 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3655 std::fs::write(
3656 root.join(".memstead").join("workspace.toml"),
3657 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3658 )
3659 .unwrap();
3660 let mount = Mount {
3661 mem: "engine".to_string(),
3662 schema: Some("default@1.0.0".parse().unwrap()),
3663 storage: MountStorage::Folder {
3664 path: mem_dir.clone(),
3665 },
3666 capability: MountCapability::Write,
3667 lifecycle: MountLifecycle::Eager,
3668 cross_linkable: false,
3669 migration_target: None,
3670 };
3671 crate::FileWorkspaceStore::new()
3672 .save_state(
3673 root,
3674 &Workspace {
3675 mounts: vec![mount],
3676 settings: WorkspaceSettings::default(),
3677 },
3678 )
3679 .unwrap();
3680 let out = std::process::Command::new("git")
3681 .args(["init", "-q"])
3682 .current_dir(root)
3683 .output()
3684 .unwrap();
3685 assert!(out.status.success());
3686 std::fs::create_dir_all(root.join("src")).unwrap();
3687 std::fs::write(root.join("src").join("a.rs"), "fn x() {}\n").unwrap();
3688
3689 write_binding(
3690 root,
3691 "engine",
3692 "graph",
3693 &Binding {
3694 version: BINDING_VERSION,
3695 intent: None,
3696 sources: vec![crate::pipeline::Source {
3697 name: "graph".to_string(),
3698 medium_type: MediumType::Codebase,
3699 pointer: "src".to_string(),
3700 change_detection: Some("git".to_string()),
3701 scope: vec![
3705 PatternEntry {
3706 path: "**/*.rs".to_string(),
3707 mode: PatternMode::Allow,
3708 },
3709 PatternEntry {
3710 path: "src/nested.rs".to_string(),
3711 mode: PatternMode::Allow,
3712 },
3713 ],
3714 engagement: None,
3715 preparation: None,
3716 }],
3717 reference_mems: Vec::new(),
3718 destination_mem: "engine".to_string(),
3719 deny_paths: Vec::new(),
3720 coverage_semantics: None,
3721 rules: None,
3722 prune: None,
3723 operations: Operations {
3724 build: Some(BuildOperation {
3725 mode: BuildMode::Discovery,
3726 trigger: IngestTrigger::Loop,
3727 batch_size: 20,
3728 post_actions: None,
3729 }),
3730 sync: None,
3731 verify: Some(VerifyOperation {
3732 trigger: IngestTrigger::Manual,
3733 batch_size: 1,
3734 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
3735 full_resync_every: 1, }),
3737 },
3738 },
3739 )
3740 .unwrap();
3741
3742 let engine = Engine::from_workspace_root(root).unwrap();
3743 let configs = load_pipeline_configs(root).unwrap();
3744 let binding = &configs.bindings[0].config;
3745 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
3746
3747 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
3748 match &outcome.full_resync {
3749 FullResyncDecision::Due {
3750 walked_facets,
3751 refused,
3752 ..
3753 } => {
3754 assert!(
3755 walked_facets.is_empty(),
3756 "a partial facet must not be announced as walked-in-full: {walked_facets:?}"
3757 );
3758 assert_eq!(refused.len(), 1, "the partial facet is refused, typed");
3759 assert_eq!(refused[0].facet, "graph");
3760 assert!(
3761 refused[0].reason.contains("incomplete"),
3762 "the refusal names the partiality: {}",
3763 refused[0].reason
3764 );
3765 }
3766 other => panic!("expected a due full walk decision, got {other:?}"),
3767 }
3768 }
3769
3770 #[test]
3781 fn full_verify_uncaps_adjudication_and_walks_whole_source() {
3782 let tmp = tempfile::tempdir().unwrap();
3783 let root = tmp.path();
3784 let mem_dir = root.join("mem");
3785 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3786 std::fs::write(
3787 mem_dir.join(".memstead").join("config.json"),
3788 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3789 )
3790 .unwrap();
3791 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3792 std::fs::write(
3793 root.join(".memstead").join("workspace.toml"),
3794 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3795 )
3796 .unwrap();
3797 crate::FileWorkspaceStore::new()
3798 .save_state(
3799 root,
3800 &Workspace {
3801 mounts: vec![Mount {
3802 mem: "engine".to_string(),
3803 schema: Some("default@1.0.0".parse().unwrap()),
3804 storage: MountStorage::Folder {
3805 path: mem_dir.clone(),
3806 },
3807 capability: MountCapability::Write,
3808 lifecycle: MountLifecycle::Eager,
3809 cross_linkable: false,
3810 migration_target: None,
3811 }],
3812 settings: WorkspaceSettings::default(),
3813 },
3814 )
3815 .unwrap();
3816 let out = std::process::Command::new("git")
3817 .args(["init", "-q"])
3818 .current_dir(root)
3819 .output()
3820 .unwrap();
3821 assert!(out.status.success());
3822 std::fs::create_dir_all(root.join("src")).unwrap();
3823 for f in ["a.rs", "b.rs", "c.rs", "d.rs", "e.rs", "f.rs"] {
3825 std::fs::write(root.join("src").join(f), "fn x() {}\n").unwrap();
3826 }
3827 let mk = |art: &str| Anchor {
3828 artifact: art.to_string(),
3829 grain: AnchorGrain::File,
3830 class: AnchorProvenanceClass::Anchored,
3831 at_version: None,
3832 hash: Some("stale-recorded-hash".to_string()), hash_stability: AnchorHashStability::Stable,
3834 derived_from: Vec::new(),
3835 binding: None,
3836 source: None,
3837 span_unvalidated: false,
3838 hash_source: None,
3839 };
3840 std::fs::write(
3844 mem_dir.join("e.md"),
3845 "---\ntype: decision\n---\n\n# E\n\n## Decision\n\nBody.\n",
3846 )
3847 .unwrap();
3848 let mut sidecar = AnchorSidecar::default();
3849 sidecar.set(
3850 "engine--e",
3851 vec![mk("src/a.rs"), mk("src/b.rs"), mk("src/c.rs")],
3852 );
3853 std::fs::write(
3854 mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
3855 sidecar.to_bytes(),
3856 )
3857 .unwrap();
3858
3859 write_binding(
3860 root,
3861 "engine",
3862 "graph",
3863 &Binding {
3864 version: BINDING_VERSION,
3865 intent: None,
3866 sources: vec![crate::pipeline::Source {
3867 name: "graph".to_string(),
3868 medium_type: MediumType::Codebase,
3869 pointer: String::new(),
3870 change_detection: Some("git".to_string()),
3871 scope: vec![PatternEntry {
3872 path: "src/**/*.rs".to_string(),
3873 mode: PatternMode::Allow,
3874 }],
3875 engagement: None,
3876 preparation: None,
3877 }],
3878 reference_mems: Vec::new(),
3879 destination_mem: "engine".to_string(),
3880 deny_paths: Vec::new(),
3881 coverage_semantics: None,
3882 rules: None,
3883 prune: None,
3884 operations: Operations {
3885 build: None,
3886 sync: None,
3887 verify: Some(VerifyOperation {
3888 trigger: IngestTrigger::Manual,
3889 batch_size: 1, adjudication_cap: 1, full_resync_every: 0, }),
3893 },
3894 },
3895 )
3896 .unwrap();
3897
3898 let engine = Engine::from_workspace_root(root).unwrap();
3899 let configs = load_pipeline_configs(root).unwrap();
3900 let binding = &configs.bindings[0].config;
3901 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
3902
3903 let sampled = verify_binding(&engine, root, binding, &resolved).unwrap();
3907 assert_eq!(sampled.full_resync, FullResyncDecision::Disabled);
3908 let store = read_findings_store(root, "engine", "graph")
3909 .unwrap()
3910 .unwrap();
3911 let current = store.current(&sampled.key);
3912 let count = |c: FindingClass| current.iter().filter(|f| f.class == c).count();
3913 assert_eq!(count(FindingClass::Drifted), 1, "cap-1 adjudicates one");
3914 assert_eq!(
3915 count(FindingClass::QueuedForAdjudication),
3916 2,
3917 "the remainder queues"
3918 );
3919 assert!(
3920 current
3921 .iter()
3922 .any(|f| f.class == FindingClass::QueuedForAdjudication
3923 && f.detail.contains("cap reached")),
3924 "the sampled deferral states the cap"
3925 );
3926 assert!(
3927 count(FindingClass::Uncovered) <= 1,
3928 "batch-1 sample looks at one artifact"
3929 );
3930
3931 let full = verify_binding_full(&engine, root, binding, &resolved).unwrap();
3934 assert_eq!(
3935 full.full_resync,
3936 FullResyncDecision::Forced {
3937 walked_facets: vec!["graph".to_string()]
3938 }
3939 );
3940 assert_eq!(full.backlog, 0, "cap treated as unlimited — no backlog");
3941 let store = read_findings_store(root, "engine", "graph")
3942 .unwrap()
3943 .unwrap();
3944 let current = store.current(&full.key);
3945 let count = |c: FindingClass| current.iter().filter(|f| f.class == c).count();
3946 assert_eq!(
3947 count(FindingClass::Drifted),
3948 3,
3949 "every candidate adjudicated"
3950 );
3951 assert_eq!(count(FindingClass::QueuedForAdjudication), 0);
3952 assert_eq!(
3953 count(FindingClass::Uncovered),
3954 3,
3955 "the whole S(D) walked — every uncovered file flagged"
3956 );
3957 assert!(
3958 current.iter().all(|f| !f.detail.contains("cap reached")),
3959 "a full run's findings carry no cap-deferral caveat"
3960 );
3961 }
3962
3963 #[test]
3968 fn full_verify_refuses_non_enumerable_medium_typed() {
3969 let tmp = tempfile::tempdir().unwrap();
3970 let root = tmp.path();
3971 let mem_dir = root.join("mem");
3972 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3973 std::fs::write(
3974 mem_dir.join(".memstead").join("config.json"),
3975 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3976 )
3977 .unwrap();
3978 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3979 std::fs::write(
3980 root.join(".memstead").join("workspace.toml"),
3981 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3982 )
3983 .unwrap();
3984 crate::FileWorkspaceStore::new()
3985 .save_state(
3986 root,
3987 &Workspace {
3988 mounts: vec![Mount {
3989 mem: "engine".to_string(),
3990 schema: Some("default@1.0.0".parse().unwrap()),
3991 storage: MountStorage::Folder {
3992 path: mem_dir.clone(),
3993 },
3994 capability: MountCapability::Write,
3995 lifecycle: MountLifecycle::Eager,
3996 cross_linkable: false,
3997 migration_target: None,
3998 }],
3999 settings: WorkspaceSettings::default(),
4000 },
4001 )
4002 .unwrap();
4003
4004 write_binding(
4006 root,
4007 "engine",
4008 "manual",
4009 &Binding {
4010 version: BINDING_VERSION,
4011 intent: None,
4012 sources: vec![crate::pipeline::Source {
4013 name: "manual".to_string(),
4014 medium_type: MediumType::Web,
4015 pointer: "https://example.com/docs".to_string(),
4016 change_detection: None,
4017 scope: Vec::new(),
4018 engagement: None,
4019 preparation: None,
4020 }],
4021 reference_mems: Vec::new(),
4022 destination_mem: "engine".to_string(),
4023 deny_paths: Vec::new(),
4024 coverage_semantics: Some(CoverageSemantics::Curated),
4025 rules: None,
4026 prune: None,
4027 operations: Operations {
4028 build: None,
4029 sync: None,
4030 verify: Some(VerifyOperation {
4031 trigger: IngestTrigger::Manual,
4032 batch_size: 20,
4033 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
4034 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
4035 }),
4036 },
4037 },
4038 )
4039 .unwrap();
4040
4041 let engine = Engine::from_workspace_root(root).unwrap();
4042 let configs = load_pipeline_configs(root).unwrap();
4043 let binding = &configs.bindings[0].config;
4044 let resolved = resolve_binding_run("engine/manual", binding).unwrap();
4045
4046 let err = verify_binding_full(&engine, root, binding, &resolved).unwrap_err();
4048 match &err {
4049 FindingsError::FullWalkNonEnumerable(refusal) => {
4050 assert_eq!(refusal.facet, "manual");
4051 assert_eq!(refusal.medium_type, "web");
4052 assert!(refusal.reason.contains("non-enumerable"));
4053 }
4054 other => panic!("expected FullWalkNonEnumerable, got {other:?}"),
4055 }
4056 assert!(
4057 read_findings_store(root, "engine", "manual")
4058 .unwrap()
4059 .is_none(),
4060 "a refused full run records nothing"
4061 );
4062
4063 let sampled = verify_binding(&engine, root, binding, &resolved).unwrap();
4065 assert_eq!(sampled.binding, "engine/manual");
4066 }
4067
4068 fn sourceless_binding() -> crate::binding::Binding {
4069 crate::binding::Binding {
4070 version: crate::binding::BINDING_VERSION,
4071 intent: None,
4072 sources: Vec::new(),
4073 reference_mems: Vec::new(),
4074 destination_mem: "m".to_string(),
4075 deny_paths: Vec::new(),
4076 coverage_semantics: None,
4077 rules: None,
4078 prune: None,
4079 operations: crate::binding::Operations {
4080 build: None,
4081 sync: None,
4082 verify: None,
4083 },
4084 }
4085 }
4086
4087 fn uncovered(key: &FindingKey, artifact: &str) -> Finding {
4088 Finding {
4089 key: key.clone(),
4090 facet: "src".to_string(),
4091 target: FindingTarget::Artifact {
4092 artifact: artifact.to_string(),
4093 },
4094 class: FindingClass::Uncovered,
4095 detail: "source artifact in scope has no anchor in the destination mem".to_string(),
4096 created_at: "1".to_string(),
4097 }
4098 }
4099
4100 #[test]
4106 fn current_findings_drops_ledger_excluded_uncovered_without_a_verify() {
4107 let ws = tempfile::tempdir().unwrap();
4108 let root = ws.path();
4109 let engine = crate::engine::Engine::from_mounts(Vec::new()).unwrap();
4110 let binding = sourceless_binding();
4111 let resolved = resolve_binding_run("m/s", &binding).unwrap();
4112
4113 let key = FindingKey {
4114 binding_hash: crate::binding::hash_binding(&binding),
4115 source_head: String::new(),
4116 };
4117 let mut store = FindingsStore {
4118 binding: "m/s".to_string(),
4119 ..Default::default()
4120 };
4121 store.record(
4122 key.clone(),
4123 "1".to_string(),
4124 vec![uncovered(&key, "docs/a.md"), uncovered(&key, "docs/b.md")],
4125 );
4126 write_findings_store(root, "m", "s", &store).unwrap();
4127
4128 let (_, before) = current_findings(&engine, root, &binding, &resolved).unwrap();
4130 assert_eq!(before.len(), 2);
4131
4132 let state = crate::ingest::advance::AdvanceState {
4135 binding: "m/s".to_string(),
4136 exclusions: [("docs/a.md".to_string(), "generated; no entity".to_string())]
4137 .into_iter()
4138 .collect(),
4139 ..Default::default()
4140 };
4141 crate::ingest::advance::write_advance_store(root, "m", "s", &state).unwrap();
4142
4143 let (_, after) = current_findings(&engine, root, &binding, &resolved).unwrap();
4144 assert_eq!(after.len(), 1);
4145 assert!(matches!(
4146 &after[0].target,
4147 FindingTarget::Artifact { artifact } if artifact == "docs/b.md"
4148 ));
4149 }
4150
4151 #[test]
4155 fn current_findings_never_serves_superseded_batches() {
4156 let ws = tempfile::tempdir().unwrap();
4157 let root = ws.path();
4158 let engine = crate::engine::Engine::from_mounts(Vec::new()).unwrap();
4159 let binding = sourceless_binding();
4160 let resolved = resolve_binding_run("m/s", &binding).unwrap();
4161
4162 let old_key = key("a-prior-binding-hash", "head0");
4163 let cur_key = FindingKey {
4164 binding_hash: crate::binding::hash_binding(&binding),
4165 source_head: String::new(),
4166 };
4167 let mut store = FindingsStore {
4168 binding: "m/s".to_string(),
4169 ..Default::default()
4170 };
4171 store.record(
4172 old_key.clone(),
4173 "1".to_string(),
4174 vec![uncovered(&old_key, "docs/stale.md")],
4175 );
4176 store.record(
4177 cur_key.clone(),
4178 "2".to_string(),
4179 vec![uncovered(&cur_key, "docs/live.md")],
4180 );
4181 write_findings_store(root, "m", "s", &store).unwrap();
4182
4183 let (_, current) = current_findings(&engine, root, &binding, &resolved).unwrap();
4184 assert_eq!(current.len(), 1);
4185 assert!(matches!(
4186 ¤t[0].target,
4187 FindingTarget::Artifact { artifact } if artifact == "docs/live.md"
4188 ));
4189 }
4190}