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() {
384 "drifted" => FindingClass::Drifted,
385 "unresolvable" => FindingClass::UnresolvableAnchor,
386 _ => return None,
387 };
388 Some(Finding {
389 key: key.clone(),
390 facet: STANDALONE_KEY.to_string(),
391 target: FindingTarget::Anchor {
392 entity: a.entity_id.clone(),
393 artifact: a.artifact.clone(),
394 },
395 class,
396 detail: format!("{} ({} {})", a.state, a.class, a.grain),
397 created_at: now.clone(),
398 })
399 })
400 .collect();
401
402 let mut store =
403 read_findings_store(workspace_root, mem, STANDALONE_KEY)?.unwrap_or_else(|| {
404 FindingsStore {
405 binding: format!("{mem}/{STANDALONE_KEY}"),
406 ..Default::default()
407 }
408 });
409 let prior: BTreeSet<(String, String)> = store
410 .current(&key)
411 .iter()
412 .map(|f| {
413 (
414 serde_json::to_string(&f.target).unwrap_or_default(),
415 f.class.as_wire().to_string(),
416 )
417 })
418 .collect();
419 let annotated: Vec<AnnotatedStandaloneFinding> = findings
420 .iter()
421 .map(|f| AnnotatedStandaloneFinding {
422 finding: f.clone(),
423 already_seen: prior.contains(&(
424 serde_json::to_string(&f.target).unwrap_or_default(),
425 f.class.as_wire().to_string(),
426 )),
427 })
428 .collect();
429 store.record(key, now, findings);
430 write_findings_store(workspace_root, mem, STANDALONE_KEY, &store)?;
431 Ok(annotated)
432}
433
434pub fn read_findings_store(
437 workspace_root: &Path,
438 mem: &str,
439 name: &str,
440) -> Result<Option<FindingsStore>, StoreError> {
441 let path = findings_store_path(workspace_root, mem, name);
442 match std::fs::read(&path) {
443 Ok(bytes) => serde_json::from_slice(&bytes)
444 .map(Some)
445 .map_err(|e| StoreError::Parse {
446 path,
447 message: e.to_string(),
448 }),
449 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
450 Err(e) => Err(StoreError::Io { path, source: e }),
451 }
452}
453
454pub(crate) fn ensure_selfignoring_store_dir(subtree_root: &Path) -> Result<(), StoreError> {
462 std::fs::create_dir_all(subtree_root).map_err(|e| StoreError::Io {
463 path: subtree_root.to_path_buf(),
464 source: e,
465 })?;
466 let gitignore = subtree_root.join(".gitignore");
467 if !gitignore.exists() {
468 let _ = std::fs::write(&gitignore, "*\n");
469 }
470 Ok(())
471}
472
473pub fn write_findings_store(
476 workspace_root: &Path,
477 mem: &str,
478 name: &str,
479 store: &FindingsStore,
480) -> Result<(), StoreError> {
481 ensure_selfignoring_store_dir(
482 &workspace_root
483 .join(WORKSPACE_STORE_DIR)
484 .join(STATE_DIR)
485 .join(FINDINGS_DIR),
486 )?;
487 let path = findings_store_path(workspace_root, mem, name);
488 if let Some(parent) = path.parent() {
489 std::fs::create_dir_all(parent).map_err(|e| StoreError::Io {
490 path: parent.to_path_buf(),
491 source: e,
492 })?;
493 }
494 let bytes = serde_json::to_vec_pretty(store).map_err(|e| StoreError::Parse {
495 path: path.clone(),
496 message: e.to_string(),
497 })?;
498 std::fs::write(&path, bytes).map_err(|e| StoreError::Io { path, source: e })
499}
500
501pub fn delete_findings_store(
504 workspace_root: &Path,
505 mem: &str,
506 name: &str,
507) -> Result<(), StoreError> {
508 let path = findings_store_path(workspace_root, mem, name);
509 match std::fs::remove_file(&path) {
510 Ok(()) => Ok(()),
511 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
512 Err(e) => Err(StoreError::Io { path, source: e }),
513 }
514}
515
516#[derive(Debug, thiserror::Error)]
522pub enum FindingsError {
523 #[error("malformed binding id '{0}': expected `<mem>/<stem>`")]
525 MalformedId(String),
526 #[error("findings store error: {0}")]
528 Store(#[source] StoreError),
529 #[error("source '{source_name}' unreachable: `{path}` does not exist")]
536 SourceUnreachable {
537 source_name: String,
539 path: String,
541 },
542 #[error(
551 "full verify refused: facet '{}' over medium type '{}' cannot be fully walked — {}",
552 .0.facet, .0.medium_type, .0.reason
553 )]
554 FullWalkNonEnumerable(FullResyncRefusal),
555}
556
557#[derive(Debug, Clone, PartialEq, Eq)]
559pub struct VerifyOutcome {
560 pub binding: String,
562 pub key: FindingKey,
564 pub recorded: usize,
566 pub superseded: usize,
568 pub backlog: usize,
570 pub full_resync: FullResyncDecision,
574 pub facet_heads: BTreeMap<String, String>,
578 pub hash_backfill: Vec<ObservedArtifactHash>,
588}
589
590pub fn record_verified_baseline(
605 engine: &mut Engine,
606 destination_mem: &str,
607 outcome: &VerifyOutcome,
608 note: Option<&str>,
609) -> Result<Vec<String>, crate::engine::EngineError> {
610 let mut written = Vec::with_capacity(outcome.facet_heads.len());
611 for (facet, token) in &outcome.facet_heads {
612 let key = format!("{}/{facet}#verified", outcome.binding);
613 engine.set_mem_sync_state(destination_mem, &key, token, note)?;
614 written.push(key);
615 }
616 Ok(written)
617}
618
619pub fn record_anchor_hash_backfill(
636 engine: &mut Engine,
637 destination_mem: &str,
638 outcome: &VerifyOutcome,
639 note: Option<&str>,
640) -> Result<usize, crate::engine::EngineError> {
641 engine.record_anchor_observed_hashes(destination_mem, &outcome.hash_backfill, note)
642}
643
644fn split_binding_id(binding_id: &str) -> Result<(String, String), FindingsError> {
648 binding_id
649 .split_once('/')
650 .filter(|(m, n)| is_single_component(m) && is_single_component(n))
651 .map(|(m, n)| (m.to_string(), n.to_string()))
652 .ok_or_else(|| FindingsError::MalformedId(binding_id.to_string()))
653}
654
655fn source_facet_label(resolved: &ResolvedIngest) -> String {
659 let facets: Vec<&str> = resolved
660 .sources
661 .iter()
662 .filter_map(|s| match s {
663 ResolvedSource::Primary(p) => Some(p.name.as_str()),
664 ResolvedSource::Reference { .. } => None,
665 })
666 .collect();
667 facets.join(",")
668}
669
670fn now_seconds() -> String {
672 let secs = SystemTime::now()
673 .duration_since(UNIX_EPOCH)
674 .map(|d| d.as_secs())
675 .unwrap_or(0);
676 secs.to_string()
677}
678
679fn current_facet_heads(
687 engine: &Engine,
688 workspace_root: &Path,
689 resolved: &ResolvedIngest,
690) -> BTreeMap<String, String> {
691 let binding_id = &resolved.name;
692 let prefix = format!("{binding_id}/");
693 let mut tokens: BTreeMap<String, String> = BTreeMap::new();
694
695 if let Some(cfg) = engine.mem_config_for(&resolved.destination_mem) {
697 for (k, v) in &cfg.sync_state {
698 if let Some(rest) = k.strip_prefix(&prefix)
699 && let Some(facet) = rest.strip_suffix("#synced")
700 {
701 tokens.insert(facet.to_string(), v.clone());
702 }
703 }
704 }
705
706 let cursor = compute_source_cursor(engine, resolved, workspace_root);
708 for c in cursor.write_commands.iter().chain(cursor.reseed.iter()) {
709 if let Some(rest) = c.key.strip_prefix(&prefix)
710 && let Some(facet) = rest.strip_suffix("#synced")
711 {
712 tokens.insert(facet.to_string(), c.token.clone());
713 }
714 }
715
716 tokens
717}
718
719fn join_facet_heads(tokens: &BTreeMap<String, String>) -> String {
722 tokens
723 .iter()
724 .map(|(facet, token)| format!("{facet}={token}"))
725 .collect::<Vec<_>>()
726 .join(";")
727}
728
729fn current_source_head(
733 engine: &Engine,
734 workspace_root: &Path,
735 resolved: &ResolvedIngest,
736) -> String {
737 join_facet_heads(¤t_facet_heads(engine, workspace_root, resolved))
738}
739
740fn binding_hash_of(binding: &Binding, _resolved: &ResolvedIngest) -> String {
743 hash_binding(binding)
744}
745
746fn current_key(
750 engine: &Engine,
751 workspace_root: &Path,
752 binding: &Binding,
753 resolved: &ResolvedIngest,
754) -> FindingKey {
755 FindingKey {
756 binding_hash: binding_hash_of(binding, resolved),
757 source_head: current_source_head(engine, workspace_root, resolved),
758 }
759}
760
761pub fn current_findings(
771 engine: &Engine,
772 workspace_root: &Path,
773 binding: &Binding,
774 resolved: &ResolvedIngest,
775) -> Result<(FindingKey, Vec<Finding>), FindingsError> {
776 let (mem, name) = split_binding_id(&resolved.name)?;
777 let key = current_key(engine, workspace_root, binding, resolved);
778 let findings = read_findings_store(workspace_root, &mem, &name)
779 .map_err(FindingsError::Store)?
780 .map(|s| s.current(&key).to_vec())
781 .unwrap_or_default();
782 Ok((key, findings))
783}
784
785pub fn adjudicate_anchor(
796 key: &FindingKey,
797 facet: &str,
798 entity: &str,
799 anchor: &Anchor,
800 state: AnchorState,
801 created_at: &str,
802) -> Option<Finding> {
803 let (class, detail) = match state {
804 AnchorState::Resolves => return None,
805 AnchorState::Orphaned => (
806 FindingClass::UnresolvableAnchor,
807 format!(
808 "artifact '{}' the anchor references is no longer present in the medium",
809 anchor.artifact
810 ),
811 ),
812 AnchorState::Drifted | AnchorState::Recheck => {
813 if !anchor.class.is_hash_bearing() {
815 return None;
816 }
817 match state {
818 AnchorState::Drifted => (
819 FindingClass::Drifted,
820 format!(
821 "prepared-content hash of '{}' drifted from the anchored hash",
822 anchor.artifact
823 ),
824 ),
825 _ => (
826 FindingClass::QueuedForAdjudication,
827 format!(
828 "hash adjudication of '{}' deferred (recheck); queued",
829 anchor.artifact
830 ),
831 ),
832 }
833 }
834 };
835 Some(Finding {
836 key: key.clone(),
837 facet: facet.to_string(),
838 target: FindingTarget::Anchor {
839 entity: entity.to_string(),
840 artifact: anchor.artifact.clone(),
841 },
842 class,
843 detail,
844 created_at: created_at.to_string(),
845 })
846}
847
848#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
855pub struct FacetEnumerability {
856 pub facet: String,
858 pub medium_type: String,
860 pub enumerable: bool,
862}
863
864#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
869pub struct FullResyncRefusal {
870 pub facet: String,
872 pub medium_type: String,
874 pub reason: String,
876}
877
878#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
882#[serde(tag = "state", rename_all = "kebab-case")]
883pub enum FullResyncDecision {
884 Disabled,
887 NotDue {
890 run_count: u64,
892 every: u32,
894 runs_until_due: u32,
896 },
897 Due {
902 run_count: u64,
904 every: u32,
906 walked_facets: Vec<String>,
908 refused: Vec<FullResyncRefusal>,
910 },
911 Forced {
919 walked_facets: Vec<String>,
921 },
922}
923
924impl FullResyncDecision {
925 pub fn is_full_walk(&self) -> bool {
929 matches!(
930 self,
931 FullResyncDecision::Due { .. } | FullResyncDecision::Forced { .. }
932 )
933 }
934}
935
936pub fn schedule_full_resync(
942 every: u32,
943 run_count: u64,
944 facets: &[FacetEnumerability],
945) -> FullResyncDecision {
946 if every == 0 {
947 return FullResyncDecision::Disabled;
948 }
949 let modulo = run_count % u64::from(every);
950 if modulo != 0 {
951 return FullResyncDecision::NotDue {
952 run_count,
953 every,
954 runs_until_due: (u64::from(every) - modulo) as u32,
955 };
956 }
957 let mut walked_facets = Vec::new();
958 let mut refused = Vec::new();
959 for f in facets {
960 if f.enumerable {
961 walked_facets.push(f.facet.clone());
962 } else {
963 refused.push(FullResyncRefusal {
964 facet: f.facet.clone(),
965 medium_type: f.medium_type.clone(),
966 reason: format!(
967 "medium type '{}' is non-enumerable — a full-enumeration walk cannot cover \
968 it; the scheduled full resync refuses rather than claim full coverage",
969 f.medium_type
970 ),
971 });
972 }
973 }
974 FullResyncDecision::Due {
975 run_count,
976 every,
977 walked_facets,
978 refused,
979 }
980}
981
982fn candidate_key(entity: &str, anchor: &Anchor) -> String {
986 format!("{entity}\u{1f}{}", anchor.artifact)
987}
988
989fn adjudicate_candidates(
1001 key: &FindingKey,
1002 facet: &str,
1003 candidates: &[(String, Anchor, AnchorState)],
1004 window: Option<&BTreeSet<String>>,
1005 created_at: &str,
1006) -> Vec<Finding> {
1007 let mut out = Vec::new();
1008 for (entity, anchor, state) in candidates {
1009 let ck = candidate_key(entity, anchor);
1010 let adjudicate_now = window.is_none_or(|w| w.contains(&ck));
1011 if adjudicate_now {
1012 if let Some(f) = adjudicate_anchor(key, facet, entity, anchor, *state, created_at) {
1013 out.push(f);
1014 }
1015 } else {
1016 out.push(Finding {
1020 key: key.clone(),
1021 facet: facet.to_string(),
1022 target: FindingTarget::Anchor {
1023 entity: entity.clone(),
1024 artifact: anchor.artifact.clone(),
1025 },
1026 class: FindingClass::QueuedForAdjudication,
1027 detail: format!(
1028 "adjudication of '{}' deferred (per-run adjudication cap reached); queued",
1029 anchor.artifact
1030 ),
1031 created_at: created_at.to_string(),
1032 });
1033 }
1034 }
1035 out
1036}
1037
1038fn target_key(target: &FindingTarget) -> String {
1042 match target {
1043 FindingTarget::Anchor { entity, artifact } => format!("a\u{1f}{entity}\u{1f}{artifact}"),
1044 FindingTarget::Artifact { artifact } => format!("f\u{1f}{artifact}"),
1045 }
1046}
1047
1048struct PassObservation {
1051 anchors_observed: BTreeSet<String>,
1054 anchors_existing: BTreeSet<String>,
1057 files_observed: BTreeSet<String>,
1060 s_d: BTreeSet<String>,
1062}
1063
1064fn merge_with_prior(
1089 mut fresh: Vec<Finding>,
1090 prior: &[Finding],
1091 obs: &PassObservation,
1092 covered_now: impl Fn(&str) -> bool,
1093) -> Vec<Finding> {
1094 let fresh_idx: BTreeMap<String, usize> = fresh
1095 .iter()
1096 .enumerate()
1097 .map(|(i, f)| (target_key(&f.target), i))
1098 .collect();
1099 let mut carried: Vec<Finding> = Vec::new();
1100 for f in prior {
1101 let tkey = target_key(&f.target);
1102 let observed = match &f.target {
1103 FindingTarget::Anchor { .. } => obs.anchors_observed.contains(&tkey),
1104 FindingTarget::Artifact { artifact } => obs.files_observed.contains(artifact),
1105 };
1106 if observed {
1107 if matches!(f.class, FindingClass::Drifted | FindingClass::Wrong)
1109 && let Some(&i) = fresh_idx.get(&tkey)
1110 && fresh[i].class == FindingClass::QueuedForAdjudication
1111 {
1112 fresh[i] = f.clone();
1113 }
1114 continue;
1115 }
1116 if fresh_idx.contains_key(&tkey) {
1117 continue; }
1119 let still_open = match &f.target {
1120 FindingTarget::Anchor { .. } => obs.anchors_existing.contains(&tkey),
1121 FindingTarget::Artifact { artifact } => {
1122 obs.s_d.contains(artifact) && !covered_now(artifact)
1123 }
1124 };
1125 if still_open {
1126 carried.push(f.clone());
1127 }
1128 }
1129 fresh.extend(carried);
1130 fresh
1131}
1132
1133pub fn verify_binding(
1147 engine: &Engine,
1148 workspace_root: &Path,
1149 binding: &Binding,
1150 resolved: &ResolvedIngest,
1151) -> Result<VerifyOutcome, FindingsError> {
1152 run_verify(engine, workspace_root, binding, resolved, false)
1153}
1154
1155pub fn verify_binding_full(
1171 engine: &Engine,
1172 workspace_root: &Path,
1173 binding: &Binding,
1174 resolved: &ResolvedIngest,
1175) -> Result<VerifyOutcome, FindingsError> {
1176 run_verify(engine, workspace_root, binding, resolved, true)
1177}
1178
1179fn run_verify(
1183 engine: &Engine,
1184 workspace_root: &Path,
1185 binding: &Binding,
1186 resolved: &ResolvedIngest,
1187 full: bool,
1188) -> Result<VerifyOutcome, FindingsError> {
1189 let binding_id = resolved.name.clone();
1190 let (mem, name) = split_binding_id(&binding_id)?;
1191
1192 if full {
1196 for source in &resolved.sources {
1197 if let ResolvedSource::Primary(p) = source {
1198 let medium_type = medium_type_wire(p.medium_type);
1199 if !medium_capabilities(p.medium_type).enumerable {
1200 return Err(FindingsError::FullWalkNonEnumerable(FullResyncRefusal {
1201 facet: p.name.clone(),
1202 medium_type: medium_type.clone(),
1203 reason: format!(
1204 "medium type '{medium_type}' is non-enumerable — a full-enumeration \
1205 walk cannot cover it; the full measurement refuses rather than \
1206 render a report with fabricated completeness"
1207 ),
1208 }));
1209 }
1210 }
1211 }
1212
1213 for source in &resolved.sources {
1229 if let ResolvedSource::Primary(p) = source
1230 && medium_capabilities(p.medium_type).enumerable
1231 && enumerate_source_artifacts(engine, p, &resolved.deny_paths, workspace_root)
1232 .is_empty()
1233 {
1234 let medium_type = medium_type_wire(p.medium_type);
1235 return Err(FindingsError::FullWalkNonEnumerable(FullResyncRefusal {
1236 facet: p.name.clone(),
1237 medium_type: medium_type.clone(),
1238 reason: format!(
1239 "medium type '{medium_type}' claims to be enumerable, but this facet's \
1240 enumeration yielded no artifacts — a full measurement over an empty \
1241 walk would report complete coverage of nothing. Check that its scope \
1242 patterns actually select something"
1243 ),
1244 }));
1245 }
1246 }
1247 }
1248
1249 for source in &resolved.sources {
1255 if let ResolvedSource::Primary(p) = source
1256 && matches!(
1257 p.medium_type,
1258 crate::pipeline::MediumType::Codebase
1259 | crate::pipeline::MediumType::Filesystem
1260 | crate::pipeline::MediumType::Git
1261 )
1262 {
1263 let base = super::resolve::source_base_path(p, workspace_root);
1264 let reachable = base.is_dir() && std::fs::read_dir(&base).is_ok();
1276 if !reachable {
1277 return Err(FindingsError::SourceUnreachable {
1278 source_name: p.name.clone(),
1279 path: base.display().to_string(),
1280 });
1281 }
1282 }
1283 }
1284
1285 for source in &resolved.sources {
1295 if let ResolvedSource::Primary(p) = source
1296 && p.medium_type == crate::pipeline::MediumType::Graph
1297 && !engine.mem_names().iter().any(|m| *m == p.pointer)
1298 {
1299 return Err(FindingsError::SourceUnreachable {
1300 source_name: p.name.clone(),
1301 path: format!("mem `{}` (not mounted in this workspace)", p.pointer),
1302 });
1303 }
1304 }
1305
1306 let facet_heads = current_facet_heads(engine, workspace_root, resolved);
1310 let key = FindingKey {
1311 binding_hash: binding_hash_of(binding, resolved),
1312 source_head: join_facet_heads(&facet_heads),
1313 };
1314 let now = now_seconds();
1315 let facet = source_facet_label(resolved);
1316 let cache_root = workspace_root.join(".memstead.cache").join("ingest");
1317
1318 let verify_op = binding.operations.verify.as_ref();
1324 let cap = verify_op.map_or(DEFAULT_ADJUDICATION_CAP, |v| v.adjudication_cap);
1325 let full_resync_every = verify_op.map_or(DEFAULT_FULL_RESYNC_EVERY, |v| v.full_resync_every);
1326 let sample_batch = verify_op
1327 .map_or(resolved.batch_size, |v| v.batch_size)
1328 .max(1) as usize;
1329
1330 let run_count = bump_verify_runs(&cache_root, &binding_id);
1336 let facet_enum: Vec<FacetEnumerability> = resolved
1337 .sources
1338 .iter()
1339 .filter_map(|s| match s {
1340 ResolvedSource::Primary(p) => Some(FacetEnumerability {
1341 facet: p.name.clone(),
1342 medium_type: medium_type_wire(p.medium_type),
1343 enumerable: medium_capabilities(p.medium_type).enumerable,
1344 }),
1345 ResolvedSource::Reference { .. } => None,
1346 })
1347 .collect();
1348 let full_resync = if full {
1349 FullResyncDecision::Forced {
1350 walked_facets: facet_enum.iter().map(|f| f.facet.clone()).collect(),
1351 }
1352 } else {
1353 schedule_full_resync(full_resync_every, run_count, &facet_enum)
1354 };
1355
1356 let mut findings: Vec<Finding> = Vec::new();
1357
1358 let mut existence: Vec<(String, Anchor, AnchorState)> = Vec::new();
1365 let mut candidates: Vec<(String, Anchor, AnchorState)> = Vec::new();
1366 let mut hash_backfill: Vec<ObservedArtifactHash> = Vec::new();
1375 let mut backfill_seen: BTreeSet<(String, String)> = BTreeSet::new();
1376 let mut anchors_existing: BTreeSet<String> = BTreeSet::new();
1379 let mut anchors_observed: BTreeSet<String> = BTreeSet::new();
1380 for (eid, resolved_anchor) in engine.mem_anchors_resolved(&resolved.destination_mem) {
1381 let tkey = target_key(&FindingTarget::Anchor {
1382 entity: eid.as_ref().to_string(),
1383 artifact: resolved_anchor.anchor.artifact.clone(),
1384 });
1385 anchors_existing.insert(tkey.clone());
1386 let Some(state) = resolved_anchor.state else {
1387 continue;
1388 };
1389 anchors_observed.insert(tkey);
1390 let observed_hash = resolved_anchor.observed_hash;
1391 let anchor = resolved_anchor.anchor;
1392 match state {
1393 AnchorState::Resolves => {}
1394 AnchorState::Orphaned => existence.push((eid.as_ref().to_string(), anchor, state)),
1395 AnchorState::Drifted | AnchorState::Recheck => {
1396 if !anchor.class.is_hash_bearing() {
1399 continue;
1400 }
1401 if anchor.hash.is_none()
1402 && let Some(hash) = observed_hash
1403 {
1404 if backfill_seen.insert((eid.as_ref().to_string(), anchor.artifact.clone())) {
1407 hash_backfill.push(ObservedArtifactHash {
1408 entity: eid.as_ref().to_string(),
1409 artifact: anchor.artifact.clone(),
1410 hash,
1411 });
1412 }
1413 continue;
1414 }
1415 candidates.push((eid.as_ref().to_string(), anchor, state));
1416 }
1417 }
1418 }
1419 for (entity, anchor, state) in &existence {
1420 if let Some(f) = adjudicate_anchor(&key, &facet, entity, anchor, *state, &now) {
1421 findings.push(f);
1422 }
1423 }
1424 let window: Option<BTreeSet<String>> = if full || cap == 0 {
1430 None
1431 } else {
1432 let mut keys: Vec<String> = candidates
1433 .iter()
1434 .map(|(e, a, _)| candidate_key(e, a))
1435 .collect();
1436 keys.sort();
1437 keys.dedup();
1438 next_rotation_batch(
1439 &cache_root,
1440 &binding_id,
1441 ROTATION_ANCHOR_ADJUDICATION,
1442 keys,
1443 cap as usize,
1444 )
1445 .map(|b| b.files.into_iter().collect())
1446 };
1447 findings.extend(adjudicate_candidates(
1448 &key,
1449 &facet,
1450 &candidates,
1451 window.as_ref(),
1452 &now,
1453 ));
1454
1455 let sample_files: Vec<String> = if full_resync.is_full_walk() {
1463 let mut all: Vec<String> = Vec::new();
1464 for source in &resolved.sources {
1465 if let ResolvedSource::Primary(p) = source
1466 && medium_capabilities(p.medium_type).enumerable
1467 {
1468 all.extend(enumerate_source_artifacts(
1469 engine,
1470 p,
1471 &resolved.deny_paths,
1472 workspace_root,
1473 ));
1474 }
1475 }
1476 all.sort();
1477 all.dedup();
1478 all
1479 } else {
1480 next_batch(engine, resolved, workspace_root, &cache_root, sample_batch)
1481 .map(|b| b.files)
1482 .unwrap_or_default()
1483 };
1484 let covered_now = |artifact: &str| {
1485 engine
1486 .anchors_referencing_artifact(artifact)
1487 .iter()
1488 .any(|(eid, _)| eid.mem() == resolved.destination_mem.as_str())
1489 };
1490 for file in &sample_files {
1491 if !covered_now(file) {
1492 findings.push(Finding {
1493 key: key.clone(),
1494 facet: facet.clone(),
1495 target: FindingTarget::Artifact {
1496 artifact: file.clone(),
1497 },
1498 class: FindingClass::Uncovered,
1499 detail: "source artifact in scope has no anchor in the destination mem".to_string(),
1500 created_at: now.clone(),
1501 });
1502 }
1503 }
1504
1505 let mut store = read_findings_store(workspace_root, &mem, &name)
1512 .map_err(FindingsError::Store)?
1513 .unwrap_or_else(|| FindingsStore {
1514 binding: binding_id.clone(),
1515 ..Default::default()
1516 });
1517 let mut s_d: BTreeSet<String> = BTreeSet::new();
1518 for source in &resolved.sources {
1519 if let ResolvedSource::Primary(p) = source
1520 && medium_capabilities(p.medium_type).enumerable
1521 {
1522 s_d.extend(enumerate_source_artifacts(
1523 engine,
1524 p,
1525 &resolved.deny_paths,
1526 workspace_root,
1527 ));
1528 }
1529 }
1530 let obs = PassObservation {
1531 anchors_observed,
1532 anchors_existing,
1533 files_observed: sample_files.into_iter().collect(),
1534 s_d,
1535 };
1536 let prior = store.current(&key).to_vec();
1537 let findings = merge_with_prior(findings, &prior, &obs, covered_now);
1538
1539 let backlog = findings
1540 .iter()
1541 .filter(|f| f.class == FindingClass::QueuedForAdjudication)
1542 .count();
1543
1544 let recorded = findings.len();
1547 store.record(key.clone(), now, findings);
1548 let superseded = store.superseded(&key).len();
1549 write_findings_store(workspace_root, &mem, &name, &store).map_err(FindingsError::Store)?;
1550
1551 Ok(VerifyOutcome {
1552 binding: binding_id,
1553 key,
1554 recorded,
1555 superseded,
1556 backlog,
1557 full_resync,
1558 facet_heads,
1559 hash_backfill,
1560 })
1561}
1562
1563fn medium_type_wire(t: crate::pipeline::MediumType) -> String {
1566 serde_json::to_value(t)
1567 .ok()
1568 .and_then(|v| v.as_str().map(str::to_string))
1569 .unwrap_or_default()
1570}
1571
1572#[cfg(test)]
1573mod tests {
1574 use super::*;
1575 use crate::anchor::{Anchor, AnchorGrain, AnchorHashStability, AnchorProvenanceClass};
1576
1577 fn key(hash: &str, head: &str) -> FindingKey {
1578 FindingKey {
1579 binding_hash: hash.to_string(),
1580 source_head: head.to_string(),
1581 }
1582 }
1583
1584 fn anchor(class: AnchorProvenanceClass) -> Anchor {
1585 Anchor {
1586 artifact: "src/lib.rs".to_string(),
1587 grain: AnchorGrain::File,
1588 class,
1589 at_version: None,
1590 hash: if class.is_hash_bearing() {
1591 Some("h1".to_string())
1592 } else {
1593 None
1594 },
1595 hash_stability: AnchorHashStability::Stable,
1596 derived_from: Vec::new(),
1597 binding: None,
1598 source: None,
1599 }
1600 }
1601
1602 #[test]
1605 fn store_round_trips_on_disk_and_delete_is_idempotent() {
1606 let tmp = tempfile::tempdir().unwrap();
1607 let root = tmp.path();
1608 assert!(
1609 read_findings_store(root, "engine", "graph")
1610 .unwrap()
1611 .is_none()
1612 );
1613
1614 let mut store = FindingsStore {
1615 binding: "engine/graph".to_string(),
1616 ..Default::default()
1617 };
1618 let k = key("hashA", "head1");
1619 store.record(
1620 k.clone(),
1621 "1".to_string(),
1622 vec![Finding {
1623 key: k.clone(),
1624 facet: "src".to_string(),
1625 target: FindingTarget::Artifact {
1626 artifact: "src/a.rs".to_string(),
1627 },
1628 class: FindingClass::Uncovered,
1629 detail: "d".to_string(),
1630 created_at: "1".to_string(),
1631 }],
1632 );
1633 write_findings_store(root, "engine", "graph", &store).unwrap();
1634 assert!(findings_store_path(root, "engine", "graph").exists());
1635
1636 let ignore = root
1639 .join(WORKSPACE_STORE_DIR)
1640 .join(STATE_DIR)
1641 .join(FINDINGS_DIR)
1642 .join(".gitignore");
1643 assert_eq!(std::fs::read_to_string(&ignore).unwrap(), "*\n");
1644
1645 let back = read_findings_store(root, "engine", "graph")
1647 .unwrap()
1648 .unwrap();
1649 assert_eq!(back, store);
1650 assert_eq!(back.current(&k).len(), 1);
1651
1652 delete_findings_store(root, "engine", "graph").unwrap();
1653 assert!(
1654 read_findings_store(root, "engine", "graph")
1655 .unwrap()
1656 .is_none()
1657 );
1658 delete_findings_store(root, "engine", "graph").unwrap();
1660 }
1661
1662 #[test]
1665 fn changed_binding_hash_supersedes_prior_findings() {
1666 let mut store = FindingsStore::default();
1667 let old = key("hashOLD", "head1");
1668 let new = key("hashNEW", "head1");
1669 let f_old = Finding {
1670 key: old.clone(),
1671 facet: "src".to_string(),
1672 target: FindingTarget::Artifact {
1673 artifact: "src/old.rs".to_string(),
1674 },
1675 class: FindingClass::Uncovered,
1676 detail: "old".to_string(),
1677 created_at: "1".to_string(),
1678 };
1679 store.record(old.clone(), "1".to_string(), vec![f_old.clone()]);
1680
1681 store.record(new.clone(), "2".to_string(), Vec::new());
1683 assert!(store.current(&new).is_empty(), "new key has its own view");
1684 let superseded = store.superseded(&new);
1685 assert_eq!(superseded.len(), 1, "old batch is segregated as superseded");
1686 assert_eq!(superseded[0], &f_old);
1687 assert!(!store.current(&new).contains(&f_old));
1689 }
1690
1691 #[test]
1699 fn impl_version_bump_invalidates_findings_by_construction() {
1700 use crate::binding::{
1701 PREPARATION_IMPL_VERSION, ScaffoldParams, hash_binding, hash_binding_at_impl_version,
1702 scaffold_binding,
1703 };
1704 let binding = scaffold_binding(ScaffoldParams {
1705 destination_mem: "plugin",
1706 source_name: "source-tree",
1707 pointer: "../public",
1708 medium_type: crate::pipeline::MediumType::Codebase,
1709 intent: None,
1710 additional_deny_paths: Vec::new(),
1711 })
1712 .binding;
1713 assert!(binding.sources[0].preparation.is_none());
1714 let _ = PREPARATION_IMPL_VERSION;
1718 let old = key(&hash_binding_at_impl_version(&binding, 0), "head1");
1719 let live = key(&hash_binding(&binding), "head1");
1720 assert_ne!(old.binding_hash, live.binding_hash);
1721
1722 let mut store = FindingsStore::default();
1723 let f_old = Finding {
1724 key: old.clone(),
1725 facet: "source-tree".to_string(),
1726 target: FindingTarget::Artifact {
1727 artifact: "src/old.rs".to_string(),
1728 },
1729 class: FindingClass::Uncovered,
1730 detail: "recorded before the bump".to_string(),
1731 created_at: "1".to_string(),
1732 };
1733 store.record(old.clone(), "1".to_string(), vec![f_old.clone()]);
1734
1735 assert!(
1736 store.current(&live).is_empty(),
1737 "a finding keyed on the pre-bump hash is invalid under the live hash"
1738 );
1739 assert_eq!(store.superseded(&live), vec![&f_old]);
1740 assert_eq!(
1741 store.current(&old),
1742 &[f_old.clone()][..],
1743 "nothing is deleted"
1744 );
1745 }
1746
1747 #[test]
1754 fn moved_source_head_keeps_findings_current_until_superseded() {
1755 let mut store = FindingsStore::default();
1756 let before = key("hashA", "head1");
1757 let after = key("hashA", "head2");
1758 let f = Finding {
1759 key: before.clone(),
1760 facet: "src".to_string(),
1761 target: FindingTarget::Anchor {
1762 entity: "engine--e".to_string(),
1763 artifact: "src/x.rs".to_string(),
1764 },
1765 class: FindingClass::UnresolvableAnchor,
1766 detail: "gone".to_string(),
1767 created_at: "1".to_string(),
1768 };
1769 store.record(before.clone(), "1".to_string(), vec![f.clone()]);
1770
1771 assert_eq!(store.current(&after), std::slice::from_ref(&f));
1774 assert_eq!(store.current(&after)[0].key.source_head, "head1");
1775 assert!(store.superseded(&after).is_empty());
1776
1777 store.record(after.clone(), "2".to_string(), Vec::new());
1780 assert!(store.current(&after).is_empty());
1781 assert!(store.current(&before).is_empty(), "at the old head too");
1782 assert_eq!(store.batches.len(), 1, "one batch per hash(D)");
1783 }
1784
1785 #[test]
1793 fn legacy_per_head_store_loads_and_presents_head_agnostically() {
1794 let tmp = tempfile::tempdir().unwrap();
1795 let root = tmp.path();
1796 let path = findings_store_path(root, "engine", "graph");
1797 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1798 std::fs::write(
1803 &path,
1804 r#"{
1805 "binding": "engine/graph",
1806 "batches": [
1807 {
1808 "key": { "binding_hash": "hashOLD", "source_head": "src=aaa" },
1809 "recorded_at": "100",
1810 "findings": [
1811 {
1812 "key": { "binding_hash": "hashOLD", "source_head": "src=aaa" },
1813 "facet": "src",
1814 "target": { "kind": "artifact", "artifact": "src/old.rs" },
1815 "class": "uncovered",
1816 "detail": "old declaration",
1817 "created_at": "100"
1818 }
1819 ]
1820 },
1821 {
1822 "key": { "binding_hash": "hashCUR", "source_head": "src=bbb" },
1823 "recorded_at": "200",
1824 "findings": [
1825 {
1826 "key": { "binding_hash": "hashCUR", "source_head": "src=bbb" },
1827 "facet": "src",
1828 "target": { "kind": "artifact", "artifact": "src/resolved-at-ccc.rs" },
1829 "class": "uncovered",
1830 "detail": "was open at bbb, absent from the ccc batch",
1831 "created_at": "200"
1832 }
1833 ]
1834 },
1835 {
1836 "key": { "binding_hash": "hashCUR", "source_head": "src=ccc" },
1837 "recorded_at": "300",
1838 "findings": [
1839 {
1840 "key": { "binding_hash": "hashCUR", "source_head": "src=ccc" },
1841 "facet": "src",
1842 "target": { "kind": "anchor", "entity": "engine--e", "artifact": "src/x.rs" },
1843 "class": "unresolvable-anchor",
1844 "detail": "gone",
1845 "created_at": "300"
1846 }
1847 ]
1848 }
1849 ]
1850 }"#,
1851 )
1852 .unwrap();
1853
1854 let mut store = read_findings_store(root, "engine", "graph")
1855 .unwrap()
1856 .expect("the legacy on-disk format loads as-is");
1857 assert_eq!(store.binding, "engine/graph");
1858 assert_eq!(store.batches.len(), 3, "loaded without loss");
1859
1860 let now = key("hashCUR", "src=ddd");
1863 let current = store.current(&now);
1864 assert_eq!(current.len(), 1);
1865 assert_eq!(current[0].detail, "gone");
1866 assert_eq!(
1867 current[0].key.source_head, "src=ccc",
1868 "the finding keeps the head it was observed at"
1869 );
1870 let superseded = store.superseded(&now);
1873 assert_eq!(superseded.len(), 2);
1874 assert!(
1875 !current.iter().any(|f| f.detail.contains("was open at bbb")),
1876 "the older same-hash batch was superseded at write time and is not resurrected"
1877 );
1878
1879 store.record(now.clone(), "400".to_string(), Vec::new());
1882 assert_eq!(store.batches.len(), 2, "hashCUR collapsed, hashOLD kept");
1883 assert_eq!(store.superseded(&now).len(), 1);
1884 }
1885
1886 #[test]
1891 fn merge_carries_unobserved_open_findings_and_closes_departed() {
1892 let k_old = key("h", "head1");
1893 let mk_artifact = |artifact: &str, detail: &str| Finding {
1894 key: k_old.clone(),
1895 facet: "src".to_string(),
1896 target: FindingTarget::Artifact {
1897 artifact: artifact.to_string(),
1898 },
1899 class: FindingClass::Uncovered,
1900 detail: detail.to_string(),
1901 created_at: "1".to_string(),
1902 };
1903 let anchor_finding = Finding {
1904 key: k_old.clone(),
1905 facet: "src".to_string(),
1906 target: FindingTarget::Anchor {
1907 entity: "engine--gone".to_string(),
1908 artifact: "src/gone.rs".to_string(),
1909 },
1910 class: FindingClass::UnresolvableAnchor,
1911 detail: "anchor since removed from the mem".to_string(),
1912 created_at: "1".to_string(),
1913 };
1914 let prior = vec![
1915 mk_artifact("src/unsampled.rs", "still open, not in this window"),
1916 mk_artifact("src/departed.rs", "left S(D)"),
1917 mk_artifact("src/now-covered.rs", "gained an anchor since"),
1918 mk_artifact("src/observed-clean.rs", "re-sampled and now covered"),
1919 anchor_finding,
1920 ];
1921 let obs = PassObservation {
1922 anchors_observed: BTreeSet::new(),
1923 anchors_existing: BTreeSet::new(), files_observed: ["src/observed-clean.rs".to_string()].into(),
1925 s_d: [
1926 "src/unsampled.rs".to_string(),
1927 "src/now-covered.rs".to_string(),
1928 "src/observed-clean.rs".to_string(),
1929 ]
1930 .into(),
1931 };
1932 let merged = merge_with_prior(Vec::new(), &prior, &obs, |artifact| {
1933 artifact == "src/now-covered.rs" || artifact == "src/observed-clean.rs"
1934 });
1935 assert_eq!(merged.len(), 1, "only the still-open unsampled one carries");
1936 assert_eq!(
1937 merged[0].target,
1938 FindingTarget::Artifact {
1939 artifact: "src/unsampled.rs".to_string()
1940 }
1941 );
1942 assert_eq!(
1943 merged[0].key.source_head, "head1",
1944 "a carried finding keeps the head it was observed at"
1945 );
1946 }
1947
1948 #[test]
1953 fn merge_deferral_never_downgrades_prior_adjudication() {
1954 let k_old = key("h", "head1");
1955 let k_new = key("h", "head2");
1956 let target = FindingTarget::Anchor {
1957 entity: "engine--e".to_string(),
1958 artifact: "src/x.rs".to_string(),
1959 };
1960 let prior_drifted = Finding {
1961 key: k_old.clone(),
1962 facet: "src".to_string(),
1963 target: target.clone(),
1964 class: FindingClass::Drifted,
1965 detail: "adjudicated drifted at head1".to_string(),
1966 created_at: "1".to_string(),
1967 };
1968 let fresh_queued = Finding {
1969 key: k_new.clone(),
1970 facet: "src".to_string(),
1971 target: target.clone(),
1972 class: FindingClass::QueuedForAdjudication,
1973 detail: "deferred by the cap this run".to_string(),
1974 created_at: "2".to_string(),
1975 };
1976 let obs = PassObservation {
1977 anchors_observed: [target_key(&target)].into(),
1978 anchors_existing: [target_key(&target)].into(),
1979 files_observed: BTreeSet::new(),
1980 s_d: BTreeSet::new(),
1981 };
1982 let merged = merge_with_prior(
1983 vec![fresh_queued],
1984 std::slice::from_ref(&prior_drifted),
1985 &obs,
1986 |_| true,
1987 );
1988 assert_eq!(merged.len(), 1);
1989 assert_eq!(
1990 merged[0].class,
1991 FindingClass::Drifted,
1992 "the prior verdict stands over a deferral"
1993 );
1994 assert_eq!(merged[0].key.source_head, "head1");
1995 }
1996
1997 #[test]
2000 fn informed_by_anchor_never_drifts() {
2001 let k = key("h", "s");
2002 for class in [
2003 AnchorProvenanceClass::InformedBy,
2004 AnchorProvenanceClass::Authored,
2005 ] {
2006 let a = anchor(class);
2007 assert!(
2008 adjudicate_anchor(&k, "f", "engine--e", &a, AnchorState::Drifted, "1").is_none(),
2009 "{class:?} must not produce a drift finding"
2010 );
2011 assert!(
2012 adjudicate_anchor(&k, "f", "engine--e", &a, AnchorState::Recheck, "1").is_none(),
2013 "{class:?} must not produce a queued finding"
2014 );
2015 }
2016 }
2017
2018 #[test]
2021 fn hash_bearing_drifts_and_orphan_is_class_independent() {
2022 let k = key("h", "s");
2023 let anchored = anchor(AnchorProvenanceClass::Anchored);
2024 let drifted =
2025 adjudicate_anchor(&k, "f", "engine--e", &anchored, AnchorState::Drifted, "1").unwrap();
2026 assert_eq!(drifted.class, FindingClass::Drifted);
2027 assert_eq!(drifted.key, k, "the finding carries its recording key (A2)");
2028
2029 let queued =
2030 adjudicate_anchor(&k, "f", "engine--e", &anchored, AnchorState::Recheck, "1").unwrap();
2031 assert_eq!(queued.class, FindingClass::QueuedForAdjudication);
2032
2033 let informed = anchor(AnchorProvenanceClass::InformedBy);
2035 let orphan =
2036 adjudicate_anchor(&k, "f", "engine--e", &informed, AnchorState::Orphaned, "1").unwrap();
2037 assert_eq!(orphan.class, FindingClass::UnresolvableAnchor);
2038
2039 assert!(
2041 adjudicate_anchor(&k, "f", "engine--e", &anchored, AnchorState::Resolves, "1")
2042 .is_none()
2043 );
2044 }
2045
2046 #[test]
2048 fn finding_class_wire_round_trips() {
2049 for w in FindingClass::WIRE_VALUES {
2050 let c = FindingClass::from_wire(w).expect("known wire value");
2051 assert_eq!(c.as_wire(), *w);
2052 }
2053 assert!(FindingClass::from_wire("nonsense").is_none());
2054 }
2055
2056 #[test]
2058 fn malformed_binding_id_refuses() {
2059 assert!(matches!(
2060 split_binding_id("../escape"),
2061 Err(FindingsError::MalformedId(_))
2062 ));
2063 assert!(matches!(
2064 split_binding_id("no-slash"),
2065 Err(FindingsError::MalformedId(_))
2066 ));
2067 assert_eq!(
2068 split_binding_id("engine/graph").unwrap(),
2069 ("engine".to_string(), "graph".to_string())
2070 );
2071 }
2072
2073 use crate::anchor::AnchorSidecar;
2076 use crate::binding::{
2077 BINDING_VERSION, BuildMode, BuildOperation, CoverageSemantics, DEFAULT_ADJUDICATION_CAP,
2078 DEFAULT_FULL_RESYNC_EVERY, Operations, VerifyOperation,
2079 };
2080 use crate::ingest::resolve::resolve_binding_run;
2081 use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode};
2082 use crate::pipeline_store::{load_pipeline_configs, write_binding};
2083 use crate::workspace::{
2084 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
2085 };
2086 use crate::workspace_store::WorkspaceStoreAdapter;
2087
2088 #[test]
2096 fn verify_persists_findings_readable_fresh() {
2097 let tmp = tempfile::tempdir().unwrap();
2098 let root = tmp.path();
2099 let mem_dir = root.join("mem");
2100 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2101 std::fs::write(
2102 mem_dir.join(".memstead").join("config.json"),
2103 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2104 )
2105 .unwrap();
2106
2107 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2110 std::fs::write(
2111 root.join(".memstead").join("workspace.toml"),
2112 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2113 )
2114 .unwrap();
2115 let mount = Mount {
2116 mem: "engine".to_string(),
2117 schema: Some("default@1.0.0".parse().unwrap()),
2118 storage: MountStorage::Folder {
2119 path: mem_dir.clone(),
2120 },
2121 capability: MountCapability::Write,
2122 lifecycle: MountLifecycle::Eager,
2123 cross_linkable: false,
2124 migration_target: None,
2125 };
2126 crate::FileWorkspaceStore::new()
2127 .save_state(
2128 root,
2129 &Workspace {
2130 mounts: vec![mount],
2131 settings: WorkspaceSettings::default(),
2132 },
2133 )
2134 .unwrap();
2135
2136 let out = std::process::Command::new("git")
2140 .args(["init", "-q"])
2141 .current_dir(root)
2142 .output()
2143 .unwrap();
2144 assert!(out.status.success());
2145 std::fs::create_dir_all(root.join("src")).unwrap();
2146 std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
2147 std::fs::write(root.join("src").join("uncovered.rs"), "fn b() {}\n").unwrap();
2148
2149 let mk = |artifact: &str, class: AnchorProvenanceClass| Anchor {
2152 artifact: artifact.to_string(),
2153 grain: AnchorGrain::File,
2154 class,
2155 at_version: None,
2156 hash: class.is_hash_bearing().then(|| "recorded".to_string()),
2157 hash_stability: AnchorHashStability::Stable,
2158 derived_from: Vec::new(),
2159 binding: None,
2160 source: None,
2161 };
2162 let mut sidecar = AnchorSidecar::default();
2163 sidecar.set(
2164 "engine--e",
2165 vec![
2166 mk("src/present.rs", AnchorProvenanceClass::Anchored), mk("src/gone.rs", AnchorProvenanceClass::Anchored), mk("src/present.rs", AnchorProvenanceClass::InformedBy), ],
2170 );
2171 std::fs::write(
2172 mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
2173 sidecar.to_bytes(),
2174 )
2175 .unwrap();
2176
2177 write_binding(
2179 root,
2180 "engine",
2181 "graph",
2182 &Binding {
2183 version: BINDING_VERSION,
2184 intent: None,
2185 sources: vec![crate::pipeline::Source {
2186 name: "graph".to_string(),
2187 medium_type: MediumType::Codebase,
2188 pointer: String::new(),
2189 change_detection: Some("git".to_string()),
2190 scope: vec![PatternEntry {
2191 path: "src/**/*.rs".to_string(),
2192 mode: PatternMode::Allow,
2193 }],
2194 engagement: None,
2195 preparation: None,
2196 }],
2197 reference_mems: Vec::new(),
2198 destination_mem: "engine".to_string(),
2199 deny_paths: Vec::new(),
2200 coverage_semantics: None,
2201 rules: None,
2202 prune: None,
2203 operations: Operations {
2204 build: Some(BuildOperation {
2205 mode: BuildMode::Discovery,
2206 trigger: IngestTrigger::Loop,
2207 batch_size: 20,
2208 post_actions: None,
2209 }),
2210 sync: None,
2211 verify: Some(VerifyOperation {
2212 trigger: IngestTrigger::Manual,
2213 batch_size: 20,
2214 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2215 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2216 }),
2217 },
2218 },
2219 )
2220 .unwrap();
2221
2222 let engine = Engine::from_workspace_root(root).unwrap();
2223
2224 let configs = load_pipeline_configs(root).unwrap();
2225 let binding = &configs.bindings[0].config;
2226 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
2227
2228 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2230 assert!(
2231 outcome.recorded >= 3,
2232 "orphan + drifted + uncovered at least"
2233 );
2234 assert_eq!(outcome.superseded, 0, "no prior key yet");
2235 assert_eq!(
2236 outcome.backlog, 0,
2237 "the mismatching hash adjudicated deterministically — nothing queued"
2238 );
2239 assert!(
2240 outcome.hash_backfill.is_empty(),
2241 "every hash-bearing anchor already carries a recorded hash — nothing to backfill"
2242 );
2243
2244 let store = read_findings_store(root, "engine", "graph")
2246 .unwrap()
2247 .unwrap();
2248 let current = store.current(&outcome.key);
2249 assert_eq!(current.len(), outcome.recorded);
2250
2251 let has = |c: FindingClass, art: &str| {
2252 current.iter().any(|f| {
2253 f.class == c
2254 && match &f.target {
2255 FindingTarget::Anchor { artifact, .. } => artifact == art,
2256 FindingTarget::Artifact { artifact } => artifact == art,
2257 }
2258 })
2259 };
2260 assert!(has(FindingClass::UnresolvableAnchor, "src/gone.rs"));
2261 assert!(
2262 has(FindingClass::Drifted, "src/present.rs"),
2263 "recorded-hash mismatch on a stable medium adjudicates drifted deterministically"
2264 );
2265 assert!(has(FindingClass::Uncovered, "src/uncovered.rs"));
2266 assert!(
2270 !current
2271 .iter()
2272 .any(|f| f.class == FindingClass::QueuedForAdjudication
2273 || f.class == FindingClass::Wrong),
2274 "deterministic adjudication leaves nothing queued"
2275 );
2276 assert!(!has(FindingClass::Uncovered, "src/present.rs"));
2278 }
2279
2280 #[test]
2286 fn finding_recorded_at_old_head_presents_in_brief_at_new_head() {
2287 use crate::ingest::render::render_sync_brief_for;
2288
2289 let tmp = tempfile::tempdir().unwrap();
2290 let root = tmp.path();
2291 let mem_dir = root.join("mem");
2292 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2293 std::fs::write(
2294 mem_dir.join(".memstead").join("config.json"),
2295 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2296 )
2297 .unwrap();
2298 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2299 std::fs::write(
2300 root.join(".memstead").join("workspace.toml"),
2301 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2302 )
2303 .unwrap();
2304 let mount = Mount {
2305 mem: "engine".to_string(),
2306 schema: Some("default@1.0.0".parse().unwrap()),
2307 storage: MountStorage::Folder {
2308 path: mem_dir.clone(),
2309 },
2310 capability: MountCapability::Write,
2311 lifecycle: MountLifecycle::Eager,
2312 cross_linkable: false,
2313 migration_target: None,
2314 };
2315 crate::FileWorkspaceStore::new()
2316 .save_state(
2317 root,
2318 &Workspace {
2319 mounts: vec![mount],
2320 settings: WorkspaceSettings::default(),
2321 },
2322 )
2323 .unwrap();
2324
2325 let git = |args: &[&str]| {
2327 let out = std::process::Command::new("git")
2328 .args(args)
2329 .current_dir(root)
2330 .env("GIT_AUTHOR_NAME", "t")
2331 .env("GIT_AUTHOR_EMAIL", "t@t")
2332 .env("GIT_COMMITTER_NAME", "t")
2333 .env("GIT_COMMITTER_EMAIL", "t@t")
2334 .output()
2335 .unwrap();
2336 assert!(
2337 out.status.success(),
2338 "git {args:?}: {}",
2339 String::from_utf8_lossy(&out.stderr)
2340 );
2341 };
2342 git(&["init", "-q"]);
2343 std::fs::create_dir_all(root.join("src")).unwrap();
2344 std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
2345 git(&["add", "-A"]);
2346 git(&["commit", "-qm", "head-a"]);
2347
2348 let mk = |artifact: &str| Anchor {
2351 artifact: artifact.to_string(),
2352 grain: AnchorGrain::File,
2353 class: AnchorProvenanceClass::InformedBy,
2354 at_version: None,
2355 hash: None,
2356 hash_stability: AnchorHashStability::Stable,
2357 derived_from: Vec::new(),
2358 binding: None,
2359 source: None,
2360 };
2361 let mut sidecar = AnchorSidecar::default();
2362 sidecar.set("engine--e", vec![mk("src/present.rs"), mk("src/gone.rs")]);
2363 std::fs::write(
2364 mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
2365 sidecar.to_bytes(),
2366 )
2367 .unwrap();
2368
2369 write_binding(
2370 root,
2371 "engine",
2372 "graph",
2373 &Binding {
2374 version: BINDING_VERSION,
2375 intent: None,
2376 sources: vec![crate::pipeline::Source {
2377 name: "graph".to_string(),
2378 medium_type: MediumType::Codebase,
2379 pointer: String::new(),
2380 change_detection: Some("git".to_string()),
2381 scope: vec![PatternEntry {
2382 path: "src/**/*.rs".to_string(),
2383 mode: PatternMode::Allow,
2384 }],
2385 engagement: None,
2386 preparation: None,
2387 }],
2388 reference_mems: Vec::new(),
2389 destination_mem: "engine".to_string(),
2390 deny_paths: Vec::new(),
2391 coverage_semantics: None,
2392 rules: None,
2393 prune: None,
2394 operations: Operations {
2395 build: None,
2396 sync: Some(crate::binding::SyncOperation {
2397 trigger: IngestTrigger::Manual,
2398 batch_size: 20,
2399 }),
2400 verify: Some(VerifyOperation {
2401 trigger: IngestTrigger::Manual,
2402 batch_size: 20,
2403 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2404 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2405 }),
2406 },
2407 },
2408 )
2409 .unwrap();
2410
2411 let configs = load_pipeline_configs(root).unwrap();
2413 let binding = &configs.bindings[0].config;
2414 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
2415 let head_a_outcome = {
2416 let engine = Engine::from_workspace_root(root).unwrap();
2417 verify_binding(&engine, root, binding, &resolved).unwrap()
2418 };
2419 assert!(
2420 head_a_outcome.key.source_head.contains("graph="),
2421 "the run observed a facet head"
2422 );
2423
2424 std::fs::write(root.join("src").join("present.rs"), "fn a() {} // more\n").unwrap();
2426 git(&["add", "-A"]);
2427 git(&["commit", "-qm", "head-b"]);
2428
2429 {
2432 let engine = Engine::from_workspace_root(root).unwrap();
2433 let (key_b, findings) = current_findings(&engine, root, binding, &resolved).unwrap();
2434 assert_ne!(
2435 key_b.source_head, head_a_outcome.key.source_head,
2436 "the head really moved"
2437 );
2438 assert_eq!(findings.len(), 1);
2439 assert_eq!(findings[0].class, FindingClass::UnresolvableAnchor);
2440 assert_eq!(
2441 findings[0].key.source_head, head_a_outcome.key.source_head,
2442 "the finding still records the head it was observed at"
2443 );
2444
2445 let brief = render_sync_brief_for(&engine, root, "engine/graph").unwrap();
2446 assert!(brief.contains("## Open findings to repair"));
2447 assert!(brief.contains("src/gone.rs"));
2448 }
2449
2450 std::fs::write(root.join("src").join("gone.rs"), "fn g() {}\n").unwrap();
2453 git(&["add", "-A"]);
2454 git(&["commit", "-qm", "head-c"]);
2455 {
2456 let engine = Engine::from_workspace_root(root).unwrap();
2457 verify_binding(&engine, root, binding, &resolved).unwrap();
2458 }
2459 {
2461 let engine = Engine::from_workspace_root(root).unwrap();
2462 let (_key, findings) = current_findings(&engine, root, binding, &resolved).unwrap();
2463 assert!(
2464 findings
2465 .iter()
2466 .all(|f| f.class != FindingClass::UnresolvableAnchor),
2467 "the resolved orphan finding must not re-present: {findings:?}"
2468 );
2469 }
2470 }
2471
2472 #[test]
2487 fn hashless_anchor_backfills_once_then_drift_adjudicates_deterministically() {
2488 let tmp = tempfile::tempdir().unwrap();
2489 let root = tmp.path();
2490 let mem_dir = root.join("mem");
2491 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2492 std::fs::write(
2493 mem_dir.join(".memstead").join("config.json"),
2494 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2495 )
2496 .unwrap();
2497 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2498 std::fs::write(
2499 root.join(".memstead").join("workspace.toml"),
2500 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2501 )
2502 .unwrap();
2503 let mount = Mount {
2504 mem: "engine".to_string(),
2505 schema: Some("default@1.0.0".parse().unwrap()),
2506 storage: MountStorage::Folder {
2507 path: mem_dir.clone(),
2508 },
2509 capability: MountCapability::Write,
2510 lifecycle: MountLifecycle::Eager,
2511 cross_linkable: false,
2512 migration_target: None,
2513 };
2514 crate::FileWorkspaceStore::new()
2515 .save_state(
2516 root,
2517 &Workspace {
2518 mounts: vec![mount],
2519 settings: WorkspaceSettings::default(),
2520 },
2521 )
2522 .unwrap();
2523
2524 let git = |args: &[&str]| {
2526 let out = std::process::Command::new("git")
2527 .args(args)
2528 .current_dir(root)
2529 .env("GIT_AUTHOR_NAME", "t")
2530 .env("GIT_AUTHOR_EMAIL", "t@t")
2531 .env("GIT_COMMITTER_NAME", "t")
2532 .env("GIT_COMMITTER_EMAIL", "t@t")
2533 .output()
2534 .unwrap();
2535 assert!(
2536 out.status.success(),
2537 "git {args:?}: {}",
2538 String::from_utf8_lossy(&out.stderr)
2539 );
2540 };
2541 git(&["init", "-q"]);
2542 std::fs::create_dir_all(root.join("src")).unwrap();
2543 std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
2544 std::fs::write(root.join("src").join("other.rs"), "fn o() {}\n").unwrap();
2545 git(&["add", "-A"]);
2546 git(&["commit", "-qm", "head-a"]);
2547
2548 let mk = |artifact: &str, class: AnchorProvenanceClass, stab: AnchorHashStability| Anchor {
2552 artifact: artifact.to_string(),
2553 grain: AnchorGrain::File,
2554 class,
2555 at_version: None,
2556 hash: None,
2557 hash_stability: stab,
2558 derived_from: if class == AnchorProvenanceClass::Derived {
2559 vec!["src/present.rs".to_string()]
2560 } else {
2561 Vec::new()
2562 },
2563 binding: None,
2564 source: None,
2565 };
2566 use AnchorHashStability::{Stable, Unstable};
2567 let mut sidecar = AnchorSidecar::default();
2568 sidecar.set(
2569 "engine--e",
2570 vec![
2571 mk("src/present.rs", AnchorProvenanceClass::Anchored, Stable),
2572 mk("src/present.rs", AnchorProvenanceClass::Derived, Stable),
2573 mk("src/other.rs", AnchorProvenanceClass::Anchored, Unstable),
2574 mk("src/present.rs", AnchorProvenanceClass::Authored, Stable),
2575 mk("src/present.rs", AnchorProvenanceClass::InformedBy, Stable),
2576 ],
2577 );
2578 std::fs::write(
2579 mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
2580 sidecar.to_bytes(),
2581 )
2582 .unwrap();
2583
2584 write_binding(
2585 root,
2586 "engine",
2587 "graph",
2588 &Binding {
2589 version: BINDING_VERSION,
2590 intent: None,
2591 sources: vec![crate::pipeline::Source {
2592 name: "graph".to_string(),
2593 medium_type: MediumType::Codebase,
2594 pointer: String::new(),
2595 change_detection: Some("git".to_string()),
2596 scope: vec![PatternEntry {
2597 path: "src/**/*.rs".to_string(),
2598 mode: PatternMode::Allow,
2599 }],
2600 engagement: None,
2601 preparation: None,
2602 }],
2603 reference_mems: Vec::new(),
2604 destination_mem: "engine".to_string(),
2605 deny_paths: Vec::new(),
2606 coverage_semantics: None,
2607 rules: None,
2608 prune: None,
2609 operations: Operations {
2610 build: None,
2611 sync: None,
2612 verify: Some(VerifyOperation {
2613 trigger: IngestTrigger::Manual,
2614 batch_size: 20,
2615 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2616 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2617 }),
2618 },
2619 },
2620 )
2621 .unwrap();
2622
2623 let configs = load_pipeline_configs(root).unwrap();
2624 let binding = &configs.bindings[0].config;
2625 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
2626
2627 {
2629 let mut engine = Engine::from_workspace_root(root).unwrap();
2630 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2631 let mut backfilled: Vec<(&str, &str)> = outcome
2634 .hash_backfill
2635 .iter()
2636 .map(|b| (b.entity.as_str(), b.artifact.as_str()))
2637 .collect();
2638 backfilled.sort();
2639 backfilled.dedup();
2640 assert_eq!(
2641 backfilled,
2642 vec![
2643 ("engine--e", "src/other.rs"),
2644 ("engine--e", "src/present.rs"),
2645 ],
2646 "hash-bearing anchors backfill; authored/informed-by never appear"
2647 );
2648 assert_eq!(
2651 outcome.backlog, 0,
2652 "no recheck queue for backfilled anchors"
2653 );
2654 let store = read_findings_store(root, "engine", "graph")
2655 .unwrap()
2656 .unwrap();
2657 assert!(
2658 store
2659 .current(&outcome.key)
2660 .iter()
2661 .all(|f| !matches!(f.target, FindingTarget::Anchor { .. })),
2662 "no anchor finding on the backfill pass: {:?}",
2663 store.current(&outcome.key)
2664 );
2665
2666 let written =
2668 record_anchor_hash_backfill(&mut engine, "engine", &outcome, None).unwrap();
2669 assert_eq!(
2670 written, 3,
2671 "anchored + derived + unstable-anchored gain hashes"
2672 );
2673 }
2674
2675 let expected_present = crate::anchor::prepared_content_hash(
2678 &std::fs::read(root.join("src").join("present.rs")).unwrap(),
2679 );
2680 {
2681 let sc = AnchorSidecar::from_bytes(
2682 &std::fs::read(mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH)).unwrap(),
2683 )
2684 .unwrap();
2685 for a in sc.get("engine--e") {
2686 if a.class.is_hash_bearing() {
2687 assert!(a.hash.is_some(), "hash-bearing anchor backfilled: {a:?}");
2688 } else {
2689 assert!(a.hash.is_none(), "non-hash class never gains a hash: {a:?}");
2690 }
2691 if a.artifact == "src/present.rs" && a.class.is_hash_bearing() {
2692 assert_eq!(a.hash.as_deref(), Some(expected_present.as_str()));
2693 }
2694 }
2695 }
2696
2697 {
2699 let mut engine = Engine::from_workspace_root(root).unwrap();
2700 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2701 assert!(
2702 outcome.hash_backfill.is_empty(),
2703 "backfill happens once — a re-verify observes an empty worklist"
2704 );
2705 assert_eq!(outcome.backlog, 0, "steady state: nothing re-queues");
2706 let store = read_findings_store(root, "engine", "graph")
2707 .unwrap()
2708 .unwrap();
2709 assert!(
2710 store
2711 .current(&outcome.key)
2712 .iter()
2713 .all(|f| !matches!(f.target, FindingTarget::Anchor { .. })),
2714 "recorded hashes match the source — no anchor finding"
2715 );
2716 let written =
2717 record_anchor_hash_backfill(&mut engine, "engine", &outcome, None).unwrap();
2718 assert_eq!(written, 0, "no write, no commit on the idempotent pass");
2719 }
2720
2721 std::fs::write(
2723 root.join("src").join("present.rs"),
2724 "fn a() { /* changed */ }\n",
2725 )
2726 .unwrap();
2727 std::fs::write(
2728 root.join("src").join("other.rs"),
2729 "fn o() { /* changed */ }\n",
2730 )
2731 .unwrap();
2732 git(&["add", "-A"]);
2733 git(&["commit", "-qm", "head-b"]);
2734
2735 {
2738 let engine = Engine::from_workspace_root(root).unwrap();
2739 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2740 assert!(
2741 outcome.hash_backfill.is_empty(),
2742 "recorded hashes are never overwritten by observation"
2743 );
2744 let store = read_findings_store(root, "engine", "graph")
2745 .unwrap()
2746 .unwrap();
2747 let current = store.current(&outcome.key);
2748 let drifted: Vec<&Finding> = current
2749 .iter()
2750 .filter(|f| f.class == FindingClass::Drifted)
2751 .collect();
2752 assert_eq!(
2755 drifted.len(),
2756 2,
2757 "stable-medium mismatch → drifted: {current:?}"
2758 );
2759 assert!(drifted.iter().all(|f| matches!(
2760 &f.target,
2761 FindingTarget::Anchor { artifact, .. } if artifact == "src/present.rs"
2762 )));
2763 assert!(
2766 current
2767 .iter()
2768 .any(|f| f.class == FindingClass::QueuedForAdjudication
2769 && matches!(
2770 &f.target,
2771 FindingTarget::Anchor { artifact, .. } if artifact == "src/other.rs"
2772 )),
2773 "unstable medium resolves recheck (queued), not drifted: {current:?}"
2774 );
2775 assert!(
2776 !current.iter().any(|f| f.class == FindingClass::Drifted
2777 && matches!(
2778 &f.target,
2779 FindingTarget::Anchor { artifact, .. } if artifact == "src/other.rs"
2780 )),
2781 "an unstable hash break must never assert drift"
2782 );
2783 }
2784 }
2785
2786 #[test]
2791 fn backfill_writer_refuses_non_hash_classes_and_never_overwrites() {
2792 let tmp = tempfile::tempdir().unwrap();
2793 let root = tmp.path();
2794 let mem_dir = root.join("mem");
2795 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2796 std::fs::write(
2797 mem_dir.join(".memstead").join("config.json"),
2798 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2799 )
2800 .unwrap();
2801 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2802 std::fs::write(
2803 root.join(".memstead").join("workspace.toml"),
2804 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2805 )
2806 .unwrap();
2807 crate::FileWorkspaceStore::new()
2808 .save_state(
2809 root,
2810 &Workspace {
2811 mounts: vec![Mount {
2812 mem: "engine".to_string(),
2813 schema: Some("default@1.0.0".parse().unwrap()),
2814 storage: MountStorage::Folder {
2815 path: mem_dir.clone(),
2816 },
2817 capability: MountCapability::Write,
2818 lifecycle: MountLifecycle::Eager,
2819 cross_linkable: false,
2820 migration_target: None,
2821 }],
2822 settings: WorkspaceSettings::default(),
2823 },
2824 )
2825 .unwrap();
2826
2827 let anchor = |class: AnchorProvenanceClass, hash: Option<&str>| Anchor {
2828 artifact: "src/a.rs".to_string(),
2829 grain: AnchorGrain::File,
2830 class,
2831 at_version: None,
2832 hash: hash.map(str::to_string),
2833 hash_stability: AnchorHashStability::Stable,
2834 derived_from: Vec::new(),
2835 binding: None,
2836 source: None,
2837 };
2838 let mut sidecar = AnchorSidecar::default();
2839 sidecar.set(
2840 "engine--e",
2841 vec![
2842 anchor(AnchorProvenanceClass::Authored, None),
2843 anchor(AnchorProvenanceClass::InformedBy, None),
2844 anchor(AnchorProvenanceClass::Anchored, Some("recorded")),
2845 ],
2846 );
2847 std::fs::write(
2848 mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
2849 sidecar.to_bytes(),
2850 )
2851 .unwrap();
2852
2853 let mut engine = Engine::from_workspace_root(root).unwrap();
2854 let written = engine
2855 .record_anchor_observed_hashes(
2856 "engine",
2857 &[crate::anchor::ObservedArtifactHash {
2858 entity: "engine--e".to_string(),
2859 artifact: "src/a.rs".to_string(),
2860 hash: "observed".to_string(),
2861 }],
2862 None,
2863 )
2864 .unwrap();
2865 assert_eq!(
2866 written, 0,
2867 "non-hash classes refuse the hash; a recorded hash is never overwritten"
2868 );
2869 let sc = AnchorSidecar::from_bytes(
2870 &std::fs::read(mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH)).unwrap(),
2871 )
2872 .unwrap();
2873 for a in sc.get("engine--e") {
2874 match a.class {
2875 AnchorProvenanceClass::Anchored => {
2876 assert_eq!(a.hash.as_deref(), Some("recorded"), "baseline stands")
2877 }
2878 _ => assert!(a.hash.is_none(), "non-hash class stays hash-less: {a:?}"),
2879 }
2880 }
2881 }
2882
2883 #[test]
2899 fn verify_refuses_unreachable_source_with_typed_error() {
2900 let tmp = tempfile::tempdir().unwrap();
2901 let root = tmp.path();
2902 let mem_dir = root.join("mem");
2903 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2904 std::fs::write(
2905 mem_dir.join(".memstead").join("config.json"),
2906 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2907 )
2908 .unwrap();
2909 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2910 std::fs::write(
2911 root.join(".memstead").join("workspace.toml"),
2912 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2913 )
2914 .unwrap();
2915 let mount = Mount {
2916 mem: "engine".to_string(),
2917 schema: Some("default@1.0.0".parse().unwrap()),
2918 storage: MountStorage::Folder {
2919 path: mem_dir.clone(),
2920 },
2921 capability: MountCapability::Write,
2922 lifecycle: MountLifecycle::Eager,
2923 cross_linkable: false,
2924 migration_target: None,
2925 };
2926 crate::FileWorkspaceStore::new()
2927 .save_state(
2928 root,
2929 &Workspace {
2930 mounts: vec![mount],
2931 settings: WorkspaceSettings::default(),
2932 },
2933 )
2934 .unwrap();
2935
2936 write_binding(
2940 root,
2941 "engine",
2942 "gone",
2943 &Binding {
2944 version: BINDING_VERSION,
2945 intent: None,
2946 sources: vec![crate::pipeline::Source {
2947 name: "gone".to_string(),
2948 medium_type: MediumType::Codebase,
2949 pointer: "vanished-src".to_string(),
2950 change_detection: Some("git".to_string()),
2951 scope: vec![PatternEntry {
2952 path: "**/*.rs".to_string(),
2953 mode: PatternMode::Allow,
2954 }],
2955 engagement: None,
2956 preparation: None,
2957 }],
2958 reference_mems: Vec::new(),
2959 destination_mem: "engine".to_string(),
2960 deny_paths: Vec::new(),
2961 coverage_semantics: None,
2962 rules: None,
2963 prune: None,
2964 operations: Operations {
2965 build: None,
2966 sync: None,
2967 verify: Some(VerifyOperation {
2968 trigger: IngestTrigger::Manual,
2969 batch_size: 20,
2970 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2971 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2972 }),
2973 },
2974 },
2975 )
2976 .unwrap();
2977
2978 let engine = Engine::from_workspace_root(root).unwrap();
2979 let configs = load_pipeline_configs(root).unwrap();
2980 let binding = &configs.bindings[0].config;
2981 let resolved = resolve_binding_run("engine/gone", binding).unwrap();
2982
2983 match verify_binding(&engine, root, binding, &resolved) {
2984 Err(FindingsError::SourceUnreachable { source_name, path }) => {
2985 assert_eq!(source_name, "gone");
2986 assert!(
2987 path.ends_with("vanished-src"),
2988 "refusal must name the resolved missing path, got `{path}`",
2989 );
2990 }
2991 other => panic!("expected SourceUnreachable refusal, got {other:?}"),
2992 }
2993
2994 assert!(
2997 !engine
2998 .mem_config_for("engine")
2999 .unwrap()
3000 .sync_state
3001 .keys()
3002 .any(|k| k.ends_with("#verified")),
3003 "a refused verify must not leave any #verified token",
3004 );
3005 }
3006
3007 #[test]
3008 fn completed_verify_records_the_verified_baseline() {
3009 let tmp = tempfile::tempdir().unwrap();
3010 let root = tmp.path();
3011 let mem_dir = root.join("mem");
3012 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3013 std::fs::write(
3014 mem_dir.join(".memstead").join("config.json"),
3015 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3016 )
3017 .unwrap();
3018 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3019 std::fs::write(
3020 root.join(".memstead").join("workspace.toml"),
3021 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3022 )
3023 .unwrap();
3024 let mount = Mount {
3025 mem: "engine".to_string(),
3026 schema: Some("default@1.0.0".parse().unwrap()),
3027 storage: MountStorage::Folder {
3028 path: mem_dir.clone(),
3029 },
3030 capability: MountCapability::Write,
3031 lifecycle: MountLifecycle::Eager,
3032 cross_linkable: false,
3033 migration_target: None,
3034 };
3035 crate::FileWorkspaceStore::new()
3036 .save_state(
3037 root,
3038 &Workspace {
3039 mounts: vec![mount],
3040 settings: WorkspaceSettings::default(),
3041 },
3042 )
3043 .unwrap();
3044 let out = std::process::Command::new("git")
3045 .args(["init", "-q"])
3046 .current_dir(root)
3047 .output()
3048 .unwrap();
3049 assert!(out.status.success());
3050
3051 write_binding(
3052 root,
3053 "engine",
3054 "graph",
3055 &Binding {
3056 version: BINDING_VERSION,
3057 intent: None,
3058 sources: vec![crate::pipeline::Source {
3059 name: "graph".to_string(),
3060 medium_type: MediumType::Codebase,
3061 pointer: String::new(),
3062 change_detection: Some("git".to_string()),
3063 scope: vec![PatternEntry {
3064 path: "src/**/*.rs".to_string(),
3065 mode: PatternMode::Allow,
3066 }],
3067 engagement: None,
3068 preparation: None,
3069 }],
3070 reference_mems: Vec::new(),
3071 destination_mem: "engine".to_string(),
3072 deny_paths: Vec::new(),
3073 coverage_semantics: None,
3074 rules: None,
3075 prune: None,
3076 operations: Operations {
3077 build: Some(BuildOperation {
3078 mode: BuildMode::Discovery,
3079 trigger: IngestTrigger::Loop,
3080 batch_size: 20,
3081 post_actions: None,
3082 }),
3083 sync: None,
3084 verify: Some(VerifyOperation {
3085 trigger: IngestTrigger::Manual,
3086 batch_size: 20,
3087 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
3088 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
3089 }),
3090 },
3091 },
3092 )
3093 .unwrap();
3094
3095 let mut engine = Engine::from_workspace_root(root).unwrap();
3096 engine
3099 .set_mem_sync_state("engine", "engine/graph/graph#synced", "deadbeef", None)
3100 .unwrap();
3101
3102 let configs = load_pipeline_configs(root).unwrap();
3103 let binding = &configs.bindings[0].config;
3104 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
3105
3106 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
3107 assert_eq!(
3109 outcome.facet_heads.get("graph").map(String::as_str),
3110 Some("deadbeef")
3111 );
3112 assert_eq!(outcome.key.source_head, "graph=deadbeef");
3113 assert_eq!(
3114 join_facet_heads(&outcome.facet_heads),
3115 outcome.key.source_head
3116 );
3117
3118 assert!(
3120 !engine
3121 .mem_config_for("engine")
3122 .unwrap()
3123 .sync_state
3124 .contains_key("engine/graph/graph#verified")
3125 );
3126
3127 let written = record_verified_baseline(&mut engine, "engine", &outcome, None).unwrap();
3128 assert_eq!(written, vec!["engine/graph/graph#verified".to_string()]);
3129
3130 assert_eq!(
3132 engine
3133 .mem_config_for("engine")
3134 .unwrap()
3135 .sync_state
3136 .get("engine/graph/graph#verified")
3137 .map(String::as_str),
3138 Some("deadbeef")
3139 );
3140 let disk: serde_json::Value = serde_json::from_slice(
3142 &std::fs::read(mem_dir.join(".memstead").join("config.json")).unwrap(),
3143 )
3144 .unwrap();
3145 assert_eq!(
3146 disk["syncState"]["engine/graph/graph#verified"],
3147 serde_json::json!("deadbeef")
3148 );
3149 }
3150
3151 #[test]
3158 fn adjudication_cap_queues_the_remainder() {
3159 let k = key("h", "s");
3160 let mk = |art: &str| {
3161 let mut a = anchor(AnchorProvenanceClass::Anchored);
3162 a.artifact = art.to_string();
3163 a
3164 };
3165 let candidates = vec![
3166 (
3167 "engine--a".to_string(),
3168 mk("src/a.rs"),
3169 AnchorState::Drifted,
3170 ),
3171 (
3172 "engine--b".to_string(),
3173 mk("src/b.rs"),
3174 AnchorState::Drifted,
3175 ),
3176 (
3177 "engine--c".to_string(),
3178 mk("src/c.rs"),
3179 AnchorState::Drifted,
3180 ),
3181 ];
3182 let window: BTreeSet<String> = [candidate_key("engine--a", &mk("src/a.rs"))]
3184 .into_iter()
3185 .collect();
3186 let out = adjudicate_candidates(&k, "f", &candidates, Some(&window), "1");
3187 let drifted = out
3188 .iter()
3189 .filter(|f| f.class == FindingClass::Drifted)
3190 .count();
3191 let queued = out
3192 .iter()
3193 .filter(|f| f.class == FindingClass::QueuedForAdjudication)
3194 .count();
3195 assert_eq!(drifted, 1, "only the in-window candidate is adjudicated");
3196 assert_eq!(queued, 2, "the remainder is queued as the tier-3 backlog");
3197 assert!(
3199 out.iter()
3200 .any(|f| f.class == FindingClass::QueuedForAdjudication
3201 && f.detail.contains("cap reached")),
3202 "capped remainder states it was deferred by the cap"
3203 );
3204
3205 let uncapped = adjudicate_candidates(&k, "f", &candidates, None, "1");
3207 assert_eq!(
3208 uncapped
3209 .iter()
3210 .filter(|f| f.class == FindingClass::Drifted)
3211 .count(),
3212 3,
3213 "uncapped adjudicates every candidate"
3214 );
3215 assert_eq!(
3216 uncapped
3217 .iter()
3218 .filter(|f| f.class == FindingClass::QueuedForAdjudication)
3219 .count(),
3220 0
3221 );
3222 }
3223
3224 #[test]
3230 fn full_resync_schedule_disabled_notdue_due() {
3231 let codebase = FacetEnumerability {
3232 facet: "src".to_string(),
3233 medium_type: "codebase".to_string(),
3234 enumerable: true,
3235 };
3236 assert_eq!(
3237 schedule_full_resync(0, 5, std::slice::from_ref(&codebase)),
3238 FullResyncDecision::Disabled
3239 );
3240 match schedule_full_resync(3, 2, std::slice::from_ref(&codebase)) {
3241 FullResyncDecision::NotDue { runs_until_due, .. } => assert_eq!(runs_until_due, 1),
3242 other => panic!("expected NotDue, got {other:?}"),
3243 }
3244 match schedule_full_resync(3, 3, std::slice::from_ref(&codebase)) {
3245 FullResyncDecision::Due {
3246 walked_facets,
3247 refused,
3248 ..
3249 } => {
3250 assert_eq!(walked_facets, vec!["src".to_string()]);
3251 assert!(refused.is_empty(), "enumerable facet is not refused");
3252 }
3253 other => panic!("expected Due, got {other:?}"),
3254 }
3255 }
3256
3257 #[test]
3260 fn full_resync_refuses_non_enumerable_medium() {
3261 let web = FacetEnumerability {
3262 facet: "manual".to_string(),
3263 medium_type: "web".to_string(),
3264 enumerable: false,
3265 };
3266 let d = schedule_full_resync(1, 1, &[web]);
3267 assert!(
3268 d.is_full_walk(),
3269 "a due sweep is a full walk even when refused"
3270 );
3271 match d {
3272 FullResyncDecision::Due {
3273 walked_facets,
3274 refused,
3275 ..
3276 } => {
3277 assert!(walked_facets.is_empty(), "nothing enumerable to walk");
3278 assert_eq!(refused.len(), 1, "the non-enumerable facet is refused");
3279 assert_eq!(refused[0].facet, "manual");
3280 assert_eq!(refused[0].medium_type, "web");
3281 assert!(
3282 refused[0].reason.contains("non-enumerable"),
3283 "the refusal is typed and states why"
3284 );
3285 }
3286 other => panic!("expected Due with a refusal, got {other:?}"),
3287 }
3288 }
3289
3290 #[test]
3295 fn full_resync_full_walk_covers_whole_source() {
3296 let tmp = tempfile::tempdir().unwrap();
3297 let root = tmp.path();
3298 let mem_dir = root.join("mem");
3299 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3300 std::fs::write(
3301 mem_dir.join(".memstead").join("config.json"),
3302 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3303 )
3304 .unwrap();
3305 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3306 std::fs::write(
3307 root.join(".memstead").join("workspace.toml"),
3308 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3309 )
3310 .unwrap();
3311 let mount = Mount {
3312 mem: "engine".to_string(),
3313 schema: Some("default@1.0.0".parse().unwrap()),
3314 storage: MountStorage::Folder {
3315 path: mem_dir.clone(),
3316 },
3317 capability: MountCapability::Write,
3318 lifecycle: MountLifecycle::Eager,
3319 cross_linkable: false,
3320 migration_target: None,
3321 };
3322 crate::FileWorkspaceStore::new()
3323 .save_state(
3324 root,
3325 &Workspace {
3326 mounts: vec![mount],
3327 settings: WorkspaceSettings::default(),
3328 },
3329 )
3330 .unwrap();
3331 let out = std::process::Command::new("git")
3332 .args(["init", "-q"])
3333 .current_dir(root)
3334 .output()
3335 .unwrap();
3336 assert!(out.status.success());
3337 std::fs::create_dir_all(root.join("src")).unwrap();
3338 for f in ["a.rs", "b.rs", "c.rs"] {
3339 std::fs::write(root.join("src").join(f), "fn x() {}\n").unwrap();
3340 }
3341
3342 write_binding(
3343 root,
3344 "engine",
3345 "graph",
3346 &Binding {
3347 version: BINDING_VERSION,
3348 intent: None,
3349 sources: vec![crate::pipeline::Source {
3350 name: "graph".to_string(),
3351 medium_type: MediumType::Codebase,
3352 pointer: String::new(),
3353 change_detection: Some("git".to_string()),
3354 scope: vec![PatternEntry {
3355 path: "src/**/*.rs".to_string(),
3356 mode: PatternMode::Allow,
3357 }],
3358 engagement: None,
3359 preparation: None,
3360 }],
3361 reference_mems: Vec::new(),
3362 destination_mem: "engine".to_string(),
3363 deny_paths: Vec::new(),
3364 coverage_semantics: None,
3365 rules: None,
3366 prune: None,
3367 operations: Operations {
3368 build: Some(BuildOperation {
3369 mode: BuildMode::Discovery,
3370 trigger: IngestTrigger::Loop,
3371 batch_size: 20,
3372 post_actions: None,
3373 }),
3374 sync: None,
3375 verify: Some(VerifyOperation {
3376 trigger: IngestTrigger::Manual,
3377 batch_size: 1, adjudication_cap: DEFAULT_ADJUDICATION_CAP,
3379 full_resync_every: 1, }),
3381 },
3382 },
3383 )
3384 .unwrap();
3385
3386 let engine = Engine::from_workspace_root(root).unwrap();
3387 let configs = load_pipeline_configs(root).unwrap();
3388 let binding = &configs.bindings[0].config;
3389 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
3390
3391 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
3392 match &outcome.full_resync {
3394 FullResyncDecision::Due {
3395 walked_facets,
3396 refused,
3397 run_count,
3398 ..
3399 } => {
3400 assert_eq!(*run_count, 1);
3401 assert_eq!(walked_facets, &vec!["graph".to_string()]);
3402 assert!(refused.is_empty());
3403 }
3404 other => panic!("expected a due full walk, got {other:?}"),
3405 }
3406 let store = read_findings_store(root, "engine", "graph")
3408 .unwrap()
3409 .unwrap();
3410 let uncovered = store
3411 .current(&outcome.key)
3412 .iter()
3413 .filter(|f| f.class == FindingClass::Uncovered)
3414 .count();
3415 assert_eq!(
3416 uncovered, 3,
3417 "the scheduled full walk covers the whole source, not a batch of one"
3418 );
3419 }
3420
3421 #[test]
3432 fn full_verify_uncaps_adjudication_and_walks_whole_source() {
3433 let tmp = tempfile::tempdir().unwrap();
3434 let root = tmp.path();
3435 let mem_dir = root.join("mem");
3436 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3437 std::fs::write(
3438 mem_dir.join(".memstead").join("config.json"),
3439 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3440 )
3441 .unwrap();
3442 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3443 std::fs::write(
3444 root.join(".memstead").join("workspace.toml"),
3445 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3446 )
3447 .unwrap();
3448 crate::FileWorkspaceStore::new()
3449 .save_state(
3450 root,
3451 &Workspace {
3452 mounts: vec![Mount {
3453 mem: "engine".to_string(),
3454 schema: Some("default@1.0.0".parse().unwrap()),
3455 storage: MountStorage::Folder {
3456 path: mem_dir.clone(),
3457 },
3458 capability: MountCapability::Write,
3459 lifecycle: MountLifecycle::Eager,
3460 cross_linkable: false,
3461 migration_target: None,
3462 }],
3463 settings: WorkspaceSettings::default(),
3464 },
3465 )
3466 .unwrap();
3467 let out = std::process::Command::new("git")
3468 .args(["init", "-q"])
3469 .current_dir(root)
3470 .output()
3471 .unwrap();
3472 assert!(out.status.success());
3473 std::fs::create_dir_all(root.join("src")).unwrap();
3474 for f in ["a.rs", "b.rs", "c.rs", "d.rs", "e.rs", "f.rs"] {
3476 std::fs::write(root.join("src").join(f), "fn x() {}\n").unwrap();
3477 }
3478 let mk = |art: &str| Anchor {
3479 artifact: art.to_string(),
3480 grain: AnchorGrain::File,
3481 class: AnchorProvenanceClass::Anchored,
3482 at_version: None,
3483 hash: Some("stale-recorded-hash".to_string()), hash_stability: AnchorHashStability::Stable,
3485 derived_from: Vec::new(),
3486 binding: None,
3487 source: None,
3488 };
3489 let mut sidecar = AnchorSidecar::default();
3490 sidecar.set(
3491 "engine--e",
3492 vec![mk("src/a.rs"), mk("src/b.rs"), mk("src/c.rs")],
3493 );
3494 std::fs::write(
3495 mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
3496 sidecar.to_bytes(),
3497 )
3498 .unwrap();
3499
3500 write_binding(
3501 root,
3502 "engine",
3503 "graph",
3504 &Binding {
3505 version: BINDING_VERSION,
3506 intent: None,
3507 sources: vec![crate::pipeline::Source {
3508 name: "graph".to_string(),
3509 medium_type: MediumType::Codebase,
3510 pointer: String::new(),
3511 change_detection: Some("git".to_string()),
3512 scope: vec![PatternEntry {
3513 path: "src/**/*.rs".to_string(),
3514 mode: PatternMode::Allow,
3515 }],
3516 engagement: None,
3517 preparation: None,
3518 }],
3519 reference_mems: Vec::new(),
3520 destination_mem: "engine".to_string(),
3521 deny_paths: Vec::new(),
3522 coverage_semantics: None,
3523 rules: None,
3524 prune: None,
3525 operations: Operations {
3526 build: None,
3527 sync: None,
3528 verify: Some(VerifyOperation {
3529 trigger: IngestTrigger::Manual,
3530 batch_size: 1, adjudication_cap: 1, full_resync_every: 0, }),
3534 },
3535 },
3536 )
3537 .unwrap();
3538
3539 let engine = Engine::from_workspace_root(root).unwrap();
3540 let configs = load_pipeline_configs(root).unwrap();
3541 let binding = &configs.bindings[0].config;
3542 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
3543
3544 let sampled = verify_binding(&engine, root, binding, &resolved).unwrap();
3548 assert_eq!(sampled.full_resync, FullResyncDecision::Disabled);
3549 let store = read_findings_store(root, "engine", "graph")
3550 .unwrap()
3551 .unwrap();
3552 let current = store.current(&sampled.key);
3553 let count = |c: FindingClass| current.iter().filter(|f| f.class == c).count();
3554 assert_eq!(count(FindingClass::Drifted), 1, "cap-1 adjudicates one");
3555 assert_eq!(
3556 count(FindingClass::QueuedForAdjudication),
3557 2,
3558 "the remainder queues"
3559 );
3560 assert!(
3561 current
3562 .iter()
3563 .any(|f| f.class == FindingClass::QueuedForAdjudication
3564 && f.detail.contains("cap reached")),
3565 "the sampled deferral states the cap"
3566 );
3567 assert!(
3568 count(FindingClass::Uncovered) <= 1,
3569 "batch-1 sample looks at one artifact"
3570 );
3571
3572 let full = verify_binding_full(&engine, root, binding, &resolved).unwrap();
3575 assert_eq!(
3576 full.full_resync,
3577 FullResyncDecision::Forced {
3578 walked_facets: vec!["graph".to_string()]
3579 }
3580 );
3581 assert_eq!(full.backlog, 0, "cap treated as unlimited — no backlog");
3582 let store = read_findings_store(root, "engine", "graph")
3583 .unwrap()
3584 .unwrap();
3585 let current = store.current(&full.key);
3586 let count = |c: FindingClass| current.iter().filter(|f| f.class == c).count();
3587 assert_eq!(
3588 count(FindingClass::Drifted),
3589 3,
3590 "every candidate adjudicated"
3591 );
3592 assert_eq!(count(FindingClass::QueuedForAdjudication), 0);
3593 assert_eq!(
3594 count(FindingClass::Uncovered),
3595 3,
3596 "the whole S(D) walked — every uncovered file flagged"
3597 );
3598 assert!(
3599 current.iter().all(|f| !f.detail.contains("cap reached")),
3600 "a full run's findings carry no cap-deferral caveat"
3601 );
3602 }
3603
3604 #[test]
3609 fn full_verify_refuses_non_enumerable_medium_typed() {
3610 let tmp = tempfile::tempdir().unwrap();
3611 let root = tmp.path();
3612 let mem_dir = root.join("mem");
3613 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3614 std::fs::write(
3615 mem_dir.join(".memstead").join("config.json"),
3616 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3617 )
3618 .unwrap();
3619 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3620 std::fs::write(
3621 root.join(".memstead").join("workspace.toml"),
3622 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3623 )
3624 .unwrap();
3625 crate::FileWorkspaceStore::new()
3626 .save_state(
3627 root,
3628 &Workspace {
3629 mounts: vec![Mount {
3630 mem: "engine".to_string(),
3631 schema: Some("default@1.0.0".parse().unwrap()),
3632 storage: MountStorage::Folder {
3633 path: mem_dir.clone(),
3634 },
3635 capability: MountCapability::Write,
3636 lifecycle: MountLifecycle::Eager,
3637 cross_linkable: false,
3638 migration_target: None,
3639 }],
3640 settings: WorkspaceSettings::default(),
3641 },
3642 )
3643 .unwrap();
3644
3645 write_binding(
3647 root,
3648 "engine",
3649 "manual",
3650 &Binding {
3651 version: BINDING_VERSION,
3652 intent: None,
3653 sources: vec![crate::pipeline::Source {
3654 name: "manual".to_string(),
3655 medium_type: MediumType::Web,
3656 pointer: "https://example.com/docs".to_string(),
3657 change_detection: None,
3658 scope: Vec::new(),
3659 engagement: None,
3660 preparation: None,
3661 }],
3662 reference_mems: Vec::new(),
3663 destination_mem: "engine".to_string(),
3664 deny_paths: Vec::new(),
3665 coverage_semantics: Some(CoverageSemantics::Curated),
3666 rules: None,
3667 prune: None,
3668 operations: Operations {
3669 build: None,
3670 sync: None,
3671 verify: Some(VerifyOperation {
3672 trigger: IngestTrigger::Manual,
3673 batch_size: 20,
3674 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
3675 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
3676 }),
3677 },
3678 },
3679 )
3680 .unwrap();
3681
3682 let engine = Engine::from_workspace_root(root).unwrap();
3683 let configs = load_pipeline_configs(root).unwrap();
3684 let binding = &configs.bindings[0].config;
3685 let resolved = resolve_binding_run("engine/manual", binding).unwrap();
3686
3687 let err = verify_binding_full(&engine, root, binding, &resolved).unwrap_err();
3689 match &err {
3690 FindingsError::FullWalkNonEnumerable(refusal) => {
3691 assert_eq!(refusal.facet, "manual");
3692 assert_eq!(refusal.medium_type, "web");
3693 assert!(refusal.reason.contains("non-enumerable"));
3694 }
3695 other => panic!("expected FullWalkNonEnumerable, got {other:?}"),
3696 }
3697 assert!(
3698 read_findings_store(root, "engine", "manual")
3699 .unwrap()
3700 .is_none(),
3701 "a refused full run records nothing"
3702 );
3703
3704 let sampled = verify_binding(&engine, root, binding, &resolved).unwrap();
3706 assert_eq!(sampled.binding, "engine/manual");
3707 }
3708}