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]
1698 fn moved_source_head_keeps_findings_current_until_superseded() {
1699 let mut store = FindingsStore::default();
1700 let before = key("hashA", "head1");
1701 let after = key("hashA", "head2");
1702 let f = Finding {
1703 key: before.clone(),
1704 facet: "src".to_string(),
1705 target: FindingTarget::Anchor {
1706 entity: "engine--e".to_string(),
1707 artifact: "src/x.rs".to_string(),
1708 },
1709 class: FindingClass::UnresolvableAnchor,
1710 detail: "gone".to_string(),
1711 created_at: "1".to_string(),
1712 };
1713 store.record(before.clone(), "1".to_string(), vec![f.clone()]);
1714
1715 assert_eq!(store.current(&after), std::slice::from_ref(&f));
1718 assert_eq!(store.current(&after)[0].key.source_head, "head1");
1719 assert!(store.superseded(&after).is_empty());
1720
1721 store.record(after.clone(), "2".to_string(), Vec::new());
1724 assert!(store.current(&after).is_empty());
1725 assert!(store.current(&before).is_empty(), "at the old head too");
1726 assert_eq!(store.batches.len(), 1, "one batch per hash(D)");
1727 }
1728
1729 #[test]
1737 fn legacy_per_head_store_loads_and_presents_head_agnostically() {
1738 let tmp = tempfile::tempdir().unwrap();
1739 let root = tmp.path();
1740 let path = findings_store_path(root, "engine", "graph");
1741 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1742 std::fs::write(
1747 &path,
1748 r#"{
1749 "binding": "engine/graph",
1750 "batches": [
1751 {
1752 "key": { "binding_hash": "hashOLD", "source_head": "src=aaa" },
1753 "recorded_at": "100",
1754 "findings": [
1755 {
1756 "key": { "binding_hash": "hashOLD", "source_head": "src=aaa" },
1757 "facet": "src",
1758 "target": { "kind": "artifact", "artifact": "src/old.rs" },
1759 "class": "uncovered",
1760 "detail": "old declaration",
1761 "created_at": "100"
1762 }
1763 ]
1764 },
1765 {
1766 "key": { "binding_hash": "hashCUR", "source_head": "src=bbb" },
1767 "recorded_at": "200",
1768 "findings": [
1769 {
1770 "key": { "binding_hash": "hashCUR", "source_head": "src=bbb" },
1771 "facet": "src",
1772 "target": { "kind": "artifact", "artifact": "src/resolved-at-ccc.rs" },
1773 "class": "uncovered",
1774 "detail": "was open at bbb, absent from the ccc batch",
1775 "created_at": "200"
1776 }
1777 ]
1778 },
1779 {
1780 "key": { "binding_hash": "hashCUR", "source_head": "src=ccc" },
1781 "recorded_at": "300",
1782 "findings": [
1783 {
1784 "key": { "binding_hash": "hashCUR", "source_head": "src=ccc" },
1785 "facet": "src",
1786 "target": { "kind": "anchor", "entity": "engine--e", "artifact": "src/x.rs" },
1787 "class": "unresolvable-anchor",
1788 "detail": "gone",
1789 "created_at": "300"
1790 }
1791 ]
1792 }
1793 ]
1794 }"#,
1795 )
1796 .unwrap();
1797
1798 let mut store = read_findings_store(root, "engine", "graph")
1799 .unwrap()
1800 .expect("the legacy on-disk format loads as-is");
1801 assert_eq!(store.binding, "engine/graph");
1802 assert_eq!(store.batches.len(), 3, "loaded without loss");
1803
1804 let now = key("hashCUR", "src=ddd");
1807 let current = store.current(&now);
1808 assert_eq!(current.len(), 1);
1809 assert_eq!(current[0].detail, "gone");
1810 assert_eq!(
1811 current[0].key.source_head, "src=ccc",
1812 "the finding keeps the head it was observed at"
1813 );
1814 let superseded = store.superseded(&now);
1817 assert_eq!(superseded.len(), 2);
1818 assert!(
1819 !current.iter().any(|f| f.detail.contains("was open at bbb")),
1820 "the older same-hash batch was superseded at write time and is not resurrected"
1821 );
1822
1823 store.record(now.clone(), "400".to_string(), Vec::new());
1826 assert_eq!(store.batches.len(), 2, "hashCUR collapsed, hashOLD kept");
1827 assert_eq!(store.superseded(&now).len(), 1);
1828 }
1829
1830 #[test]
1835 fn merge_carries_unobserved_open_findings_and_closes_departed() {
1836 let k_old = key("h", "head1");
1837 let mk_artifact = |artifact: &str, detail: &str| Finding {
1838 key: k_old.clone(),
1839 facet: "src".to_string(),
1840 target: FindingTarget::Artifact {
1841 artifact: artifact.to_string(),
1842 },
1843 class: FindingClass::Uncovered,
1844 detail: detail.to_string(),
1845 created_at: "1".to_string(),
1846 };
1847 let anchor_finding = Finding {
1848 key: k_old.clone(),
1849 facet: "src".to_string(),
1850 target: FindingTarget::Anchor {
1851 entity: "engine--gone".to_string(),
1852 artifact: "src/gone.rs".to_string(),
1853 },
1854 class: FindingClass::UnresolvableAnchor,
1855 detail: "anchor since removed from the mem".to_string(),
1856 created_at: "1".to_string(),
1857 };
1858 let prior = vec![
1859 mk_artifact("src/unsampled.rs", "still open, not in this window"),
1860 mk_artifact("src/departed.rs", "left S(D)"),
1861 mk_artifact("src/now-covered.rs", "gained an anchor since"),
1862 mk_artifact("src/observed-clean.rs", "re-sampled and now covered"),
1863 anchor_finding,
1864 ];
1865 let obs = PassObservation {
1866 anchors_observed: BTreeSet::new(),
1867 anchors_existing: BTreeSet::new(), files_observed: ["src/observed-clean.rs".to_string()].into(),
1869 s_d: [
1870 "src/unsampled.rs".to_string(),
1871 "src/now-covered.rs".to_string(),
1872 "src/observed-clean.rs".to_string(),
1873 ]
1874 .into(),
1875 };
1876 let merged = merge_with_prior(Vec::new(), &prior, &obs, |artifact| {
1877 artifact == "src/now-covered.rs" || artifact == "src/observed-clean.rs"
1878 });
1879 assert_eq!(merged.len(), 1, "only the still-open unsampled one carries");
1880 assert_eq!(
1881 merged[0].target,
1882 FindingTarget::Artifact {
1883 artifact: "src/unsampled.rs".to_string()
1884 }
1885 );
1886 assert_eq!(
1887 merged[0].key.source_head, "head1",
1888 "a carried finding keeps the head it was observed at"
1889 );
1890 }
1891
1892 #[test]
1897 fn merge_deferral_never_downgrades_prior_adjudication() {
1898 let k_old = key("h", "head1");
1899 let k_new = key("h", "head2");
1900 let target = FindingTarget::Anchor {
1901 entity: "engine--e".to_string(),
1902 artifact: "src/x.rs".to_string(),
1903 };
1904 let prior_drifted = Finding {
1905 key: k_old.clone(),
1906 facet: "src".to_string(),
1907 target: target.clone(),
1908 class: FindingClass::Drifted,
1909 detail: "adjudicated drifted at head1".to_string(),
1910 created_at: "1".to_string(),
1911 };
1912 let fresh_queued = Finding {
1913 key: k_new.clone(),
1914 facet: "src".to_string(),
1915 target: target.clone(),
1916 class: FindingClass::QueuedForAdjudication,
1917 detail: "deferred by the cap this run".to_string(),
1918 created_at: "2".to_string(),
1919 };
1920 let obs = PassObservation {
1921 anchors_observed: [target_key(&target)].into(),
1922 anchors_existing: [target_key(&target)].into(),
1923 files_observed: BTreeSet::new(),
1924 s_d: BTreeSet::new(),
1925 };
1926 let merged = merge_with_prior(
1927 vec![fresh_queued],
1928 std::slice::from_ref(&prior_drifted),
1929 &obs,
1930 |_| true,
1931 );
1932 assert_eq!(merged.len(), 1);
1933 assert_eq!(
1934 merged[0].class,
1935 FindingClass::Drifted,
1936 "the prior verdict stands over a deferral"
1937 );
1938 assert_eq!(merged[0].key.source_head, "head1");
1939 }
1940
1941 #[test]
1944 fn informed_by_anchor_never_drifts() {
1945 let k = key("h", "s");
1946 for class in [
1947 AnchorProvenanceClass::InformedBy,
1948 AnchorProvenanceClass::Authored,
1949 ] {
1950 let a = anchor(class);
1951 assert!(
1952 adjudicate_anchor(&k, "f", "engine--e", &a, AnchorState::Drifted, "1").is_none(),
1953 "{class:?} must not produce a drift finding"
1954 );
1955 assert!(
1956 adjudicate_anchor(&k, "f", "engine--e", &a, AnchorState::Recheck, "1").is_none(),
1957 "{class:?} must not produce a queued finding"
1958 );
1959 }
1960 }
1961
1962 #[test]
1965 fn hash_bearing_drifts_and_orphan_is_class_independent() {
1966 let k = key("h", "s");
1967 let anchored = anchor(AnchorProvenanceClass::Anchored);
1968 let drifted =
1969 adjudicate_anchor(&k, "f", "engine--e", &anchored, AnchorState::Drifted, "1").unwrap();
1970 assert_eq!(drifted.class, FindingClass::Drifted);
1971 assert_eq!(drifted.key, k, "the finding carries its recording key (A2)");
1972
1973 let queued =
1974 adjudicate_anchor(&k, "f", "engine--e", &anchored, AnchorState::Recheck, "1").unwrap();
1975 assert_eq!(queued.class, FindingClass::QueuedForAdjudication);
1976
1977 let informed = anchor(AnchorProvenanceClass::InformedBy);
1979 let orphan =
1980 adjudicate_anchor(&k, "f", "engine--e", &informed, AnchorState::Orphaned, "1").unwrap();
1981 assert_eq!(orphan.class, FindingClass::UnresolvableAnchor);
1982
1983 assert!(
1985 adjudicate_anchor(&k, "f", "engine--e", &anchored, AnchorState::Resolves, "1")
1986 .is_none()
1987 );
1988 }
1989
1990 #[test]
1992 fn finding_class_wire_round_trips() {
1993 for w in FindingClass::WIRE_VALUES {
1994 let c = FindingClass::from_wire(w).expect("known wire value");
1995 assert_eq!(c.as_wire(), *w);
1996 }
1997 assert!(FindingClass::from_wire("nonsense").is_none());
1998 }
1999
2000 #[test]
2002 fn malformed_binding_id_refuses() {
2003 assert!(matches!(
2004 split_binding_id("../escape"),
2005 Err(FindingsError::MalformedId(_))
2006 ));
2007 assert!(matches!(
2008 split_binding_id("no-slash"),
2009 Err(FindingsError::MalformedId(_))
2010 ));
2011 assert_eq!(
2012 split_binding_id("engine/graph").unwrap(),
2013 ("engine".to_string(), "graph".to_string())
2014 );
2015 }
2016
2017 use crate::anchor::AnchorSidecar;
2020 use crate::binding::{
2021 BINDING_VERSION, BuildMode, BuildOperation, CoverageSemantics, DEFAULT_ADJUDICATION_CAP,
2022 DEFAULT_FULL_RESYNC_EVERY, Operations, VerifyOperation,
2023 };
2024 use crate::ingest::resolve::resolve_binding_run;
2025 use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode};
2026 use crate::pipeline_store::{load_pipeline_configs, write_binding};
2027 use crate::workspace::{
2028 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
2029 };
2030 use crate::workspace_store::WorkspaceStoreAdapter;
2031
2032 #[test]
2040 fn verify_persists_findings_readable_fresh() {
2041 let tmp = tempfile::tempdir().unwrap();
2042 let root = tmp.path();
2043 let mem_dir = root.join("mem");
2044 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2045 std::fs::write(
2046 mem_dir.join(".memstead").join("config.json"),
2047 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2048 )
2049 .unwrap();
2050
2051 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2054 std::fs::write(
2055 root.join(".memstead").join("workspace.toml"),
2056 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2057 )
2058 .unwrap();
2059 let mount = Mount {
2060 mem: "engine".to_string(),
2061 schema: Some("default@1.0.0".parse().unwrap()),
2062 storage: MountStorage::Folder {
2063 path: mem_dir.clone(),
2064 },
2065 capability: MountCapability::Write,
2066 lifecycle: MountLifecycle::Eager,
2067 cross_linkable: false,
2068 migration_target: None,
2069 };
2070 crate::FileWorkspaceStore::new()
2071 .save_state(
2072 root,
2073 &Workspace {
2074 mounts: vec![mount],
2075 settings: WorkspaceSettings::default(),
2076 },
2077 )
2078 .unwrap();
2079
2080 let out = std::process::Command::new("git")
2084 .args(["init", "-q"])
2085 .current_dir(root)
2086 .output()
2087 .unwrap();
2088 assert!(out.status.success());
2089 std::fs::create_dir_all(root.join("src")).unwrap();
2090 std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
2091 std::fs::write(root.join("src").join("uncovered.rs"), "fn b() {}\n").unwrap();
2092
2093 let mk = |artifact: &str, class: AnchorProvenanceClass| Anchor {
2096 artifact: artifact.to_string(),
2097 grain: AnchorGrain::File,
2098 class,
2099 at_version: None,
2100 hash: class.is_hash_bearing().then(|| "recorded".to_string()),
2101 hash_stability: AnchorHashStability::Stable,
2102 derived_from: Vec::new(),
2103 binding: None,
2104 source: None,
2105 };
2106 let mut sidecar = AnchorSidecar::default();
2107 sidecar.set(
2108 "engine--e",
2109 vec![
2110 mk("src/present.rs", AnchorProvenanceClass::Anchored), mk("src/gone.rs", AnchorProvenanceClass::Anchored), mk("src/present.rs", AnchorProvenanceClass::InformedBy), ],
2114 );
2115 std::fs::write(
2116 mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
2117 sidecar.to_bytes(),
2118 )
2119 .unwrap();
2120
2121 write_binding(
2123 root,
2124 "engine",
2125 "graph",
2126 &Binding {
2127 version: BINDING_VERSION,
2128 intent: None,
2129 sources: vec![crate::pipeline::Source {
2130 name: "graph".to_string(),
2131 medium_type: MediumType::Codebase,
2132 pointer: String::new(),
2133 change_detection: Some("git".to_string()),
2134 scope: vec![PatternEntry {
2135 path: "src/**/*.rs".to_string(),
2136 mode: PatternMode::Allow,
2137 }],
2138 engagement: None,
2139 preparation: None,
2140 }],
2141 reference_mems: Vec::new(),
2142 destination_mem: "engine".to_string(),
2143 deny_paths: Vec::new(),
2144 coverage_semantics: None,
2145 rules: None,
2146 prune: None,
2147 operations: Operations {
2148 build: Some(BuildOperation {
2149 mode: BuildMode::Discovery,
2150 trigger: IngestTrigger::Loop,
2151 batch_size: 20,
2152 post_actions: None,
2153 }),
2154 sync: None,
2155 verify: Some(VerifyOperation {
2156 trigger: IngestTrigger::Manual,
2157 batch_size: 20,
2158 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2159 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2160 }),
2161 },
2162 },
2163 )
2164 .unwrap();
2165
2166 let engine = Engine::from_workspace_root(root).unwrap();
2167
2168 let configs = load_pipeline_configs(root).unwrap();
2169 let binding = &configs.bindings[0].config;
2170 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
2171
2172 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2174 assert!(
2175 outcome.recorded >= 3,
2176 "orphan + drifted + uncovered at least"
2177 );
2178 assert_eq!(outcome.superseded, 0, "no prior key yet");
2179 assert_eq!(
2180 outcome.backlog, 0,
2181 "the mismatching hash adjudicated deterministically — nothing queued"
2182 );
2183 assert!(
2184 outcome.hash_backfill.is_empty(),
2185 "every hash-bearing anchor already carries a recorded hash — nothing to backfill"
2186 );
2187
2188 let store = read_findings_store(root, "engine", "graph")
2190 .unwrap()
2191 .unwrap();
2192 let current = store.current(&outcome.key);
2193 assert_eq!(current.len(), outcome.recorded);
2194
2195 let has = |c: FindingClass, art: &str| {
2196 current.iter().any(|f| {
2197 f.class == c
2198 && match &f.target {
2199 FindingTarget::Anchor { artifact, .. } => artifact == art,
2200 FindingTarget::Artifact { artifact } => artifact == art,
2201 }
2202 })
2203 };
2204 assert!(has(FindingClass::UnresolvableAnchor, "src/gone.rs"));
2205 assert!(
2206 has(FindingClass::Drifted, "src/present.rs"),
2207 "recorded-hash mismatch on a stable medium adjudicates drifted deterministically"
2208 );
2209 assert!(has(FindingClass::Uncovered, "src/uncovered.rs"));
2210 assert!(
2214 !current
2215 .iter()
2216 .any(|f| f.class == FindingClass::QueuedForAdjudication
2217 || f.class == FindingClass::Wrong),
2218 "deterministic adjudication leaves nothing queued"
2219 );
2220 assert!(!has(FindingClass::Uncovered, "src/present.rs"));
2222 }
2223
2224 #[test]
2230 fn finding_recorded_at_old_head_presents_in_brief_at_new_head() {
2231 use crate::ingest::render::render_sync_brief_for;
2232
2233 let tmp = tempfile::tempdir().unwrap();
2234 let root = tmp.path();
2235 let mem_dir = root.join("mem");
2236 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2237 std::fs::write(
2238 mem_dir.join(".memstead").join("config.json"),
2239 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2240 )
2241 .unwrap();
2242 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2243 std::fs::write(
2244 root.join(".memstead").join("workspace.toml"),
2245 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2246 )
2247 .unwrap();
2248 let mount = Mount {
2249 mem: "engine".to_string(),
2250 schema: Some("default@1.0.0".parse().unwrap()),
2251 storage: MountStorage::Folder {
2252 path: mem_dir.clone(),
2253 },
2254 capability: MountCapability::Write,
2255 lifecycle: MountLifecycle::Eager,
2256 cross_linkable: false,
2257 migration_target: None,
2258 };
2259 crate::FileWorkspaceStore::new()
2260 .save_state(
2261 root,
2262 &Workspace {
2263 mounts: vec![mount],
2264 settings: WorkspaceSettings::default(),
2265 },
2266 )
2267 .unwrap();
2268
2269 let git = |args: &[&str]| {
2271 let out = std::process::Command::new("git")
2272 .args(args)
2273 .current_dir(root)
2274 .env("GIT_AUTHOR_NAME", "t")
2275 .env("GIT_AUTHOR_EMAIL", "t@t")
2276 .env("GIT_COMMITTER_NAME", "t")
2277 .env("GIT_COMMITTER_EMAIL", "t@t")
2278 .output()
2279 .unwrap();
2280 assert!(
2281 out.status.success(),
2282 "git {args:?}: {}",
2283 String::from_utf8_lossy(&out.stderr)
2284 );
2285 };
2286 git(&["init", "-q"]);
2287 std::fs::create_dir_all(root.join("src")).unwrap();
2288 std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
2289 git(&["add", "-A"]);
2290 git(&["commit", "-qm", "head-a"]);
2291
2292 let mk = |artifact: &str| Anchor {
2295 artifact: artifact.to_string(),
2296 grain: AnchorGrain::File,
2297 class: AnchorProvenanceClass::InformedBy,
2298 at_version: None,
2299 hash: None,
2300 hash_stability: AnchorHashStability::Stable,
2301 derived_from: Vec::new(),
2302 binding: None,
2303 source: None,
2304 };
2305 let mut sidecar = AnchorSidecar::default();
2306 sidecar.set("engine--e", vec![mk("src/present.rs"), mk("src/gone.rs")]);
2307 std::fs::write(
2308 mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
2309 sidecar.to_bytes(),
2310 )
2311 .unwrap();
2312
2313 write_binding(
2314 root,
2315 "engine",
2316 "graph",
2317 &Binding {
2318 version: BINDING_VERSION,
2319 intent: None,
2320 sources: vec![crate::pipeline::Source {
2321 name: "graph".to_string(),
2322 medium_type: MediumType::Codebase,
2323 pointer: String::new(),
2324 change_detection: Some("git".to_string()),
2325 scope: vec![PatternEntry {
2326 path: "src/**/*.rs".to_string(),
2327 mode: PatternMode::Allow,
2328 }],
2329 engagement: None,
2330 preparation: None,
2331 }],
2332 reference_mems: Vec::new(),
2333 destination_mem: "engine".to_string(),
2334 deny_paths: Vec::new(),
2335 coverage_semantics: None,
2336 rules: None,
2337 prune: None,
2338 operations: Operations {
2339 build: None,
2340 sync: Some(crate::binding::SyncOperation {
2341 trigger: IngestTrigger::Manual,
2342 batch_size: 20,
2343 }),
2344 verify: Some(VerifyOperation {
2345 trigger: IngestTrigger::Manual,
2346 batch_size: 20,
2347 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2348 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2349 }),
2350 },
2351 },
2352 )
2353 .unwrap();
2354
2355 let configs = load_pipeline_configs(root).unwrap();
2357 let binding = &configs.bindings[0].config;
2358 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
2359 let head_a_outcome = {
2360 let engine = Engine::from_workspace_root(root).unwrap();
2361 verify_binding(&engine, root, binding, &resolved).unwrap()
2362 };
2363 assert!(
2364 head_a_outcome.key.source_head.contains("graph="),
2365 "the run observed a facet head"
2366 );
2367
2368 std::fs::write(root.join("src").join("present.rs"), "fn a() {} // more\n").unwrap();
2370 git(&["add", "-A"]);
2371 git(&["commit", "-qm", "head-b"]);
2372
2373 {
2376 let engine = Engine::from_workspace_root(root).unwrap();
2377 let (key_b, findings) = current_findings(&engine, root, binding, &resolved).unwrap();
2378 assert_ne!(
2379 key_b.source_head, head_a_outcome.key.source_head,
2380 "the head really moved"
2381 );
2382 assert_eq!(findings.len(), 1);
2383 assert_eq!(findings[0].class, FindingClass::UnresolvableAnchor);
2384 assert_eq!(
2385 findings[0].key.source_head, head_a_outcome.key.source_head,
2386 "the finding still records the head it was observed at"
2387 );
2388
2389 let brief = render_sync_brief_for(&engine, root, "engine/graph").unwrap();
2390 assert!(brief.contains("## Open findings to repair"));
2391 assert!(brief.contains("src/gone.rs"));
2392 }
2393
2394 std::fs::write(root.join("src").join("gone.rs"), "fn g() {}\n").unwrap();
2397 git(&["add", "-A"]);
2398 git(&["commit", "-qm", "head-c"]);
2399 {
2400 let engine = Engine::from_workspace_root(root).unwrap();
2401 verify_binding(&engine, root, binding, &resolved).unwrap();
2402 }
2403 {
2405 let engine = Engine::from_workspace_root(root).unwrap();
2406 let (_key, findings) = current_findings(&engine, root, binding, &resolved).unwrap();
2407 assert!(
2408 findings
2409 .iter()
2410 .all(|f| f.class != FindingClass::UnresolvableAnchor),
2411 "the resolved orphan finding must not re-present: {findings:?}"
2412 );
2413 }
2414 }
2415
2416 #[test]
2431 fn hashless_anchor_backfills_once_then_drift_adjudicates_deterministically() {
2432 let tmp = tempfile::tempdir().unwrap();
2433 let root = tmp.path();
2434 let mem_dir = root.join("mem");
2435 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2436 std::fs::write(
2437 mem_dir.join(".memstead").join("config.json"),
2438 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2439 )
2440 .unwrap();
2441 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2442 std::fs::write(
2443 root.join(".memstead").join("workspace.toml"),
2444 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2445 )
2446 .unwrap();
2447 let mount = Mount {
2448 mem: "engine".to_string(),
2449 schema: Some("default@1.0.0".parse().unwrap()),
2450 storage: MountStorage::Folder {
2451 path: mem_dir.clone(),
2452 },
2453 capability: MountCapability::Write,
2454 lifecycle: MountLifecycle::Eager,
2455 cross_linkable: false,
2456 migration_target: None,
2457 };
2458 crate::FileWorkspaceStore::new()
2459 .save_state(
2460 root,
2461 &Workspace {
2462 mounts: vec![mount],
2463 settings: WorkspaceSettings::default(),
2464 },
2465 )
2466 .unwrap();
2467
2468 let git = |args: &[&str]| {
2470 let out = std::process::Command::new("git")
2471 .args(args)
2472 .current_dir(root)
2473 .env("GIT_AUTHOR_NAME", "t")
2474 .env("GIT_AUTHOR_EMAIL", "t@t")
2475 .env("GIT_COMMITTER_NAME", "t")
2476 .env("GIT_COMMITTER_EMAIL", "t@t")
2477 .output()
2478 .unwrap();
2479 assert!(
2480 out.status.success(),
2481 "git {args:?}: {}",
2482 String::from_utf8_lossy(&out.stderr)
2483 );
2484 };
2485 git(&["init", "-q"]);
2486 std::fs::create_dir_all(root.join("src")).unwrap();
2487 std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
2488 std::fs::write(root.join("src").join("other.rs"), "fn o() {}\n").unwrap();
2489 git(&["add", "-A"]);
2490 git(&["commit", "-qm", "head-a"]);
2491
2492 let mk = |artifact: &str, class: AnchorProvenanceClass, stab: AnchorHashStability| Anchor {
2496 artifact: artifact.to_string(),
2497 grain: AnchorGrain::File,
2498 class,
2499 at_version: None,
2500 hash: None,
2501 hash_stability: stab,
2502 derived_from: if class == AnchorProvenanceClass::Derived {
2503 vec!["src/present.rs".to_string()]
2504 } else {
2505 Vec::new()
2506 },
2507 binding: None,
2508 source: None,
2509 };
2510 use AnchorHashStability::{Stable, Unstable};
2511 let mut sidecar = AnchorSidecar::default();
2512 sidecar.set(
2513 "engine--e",
2514 vec![
2515 mk("src/present.rs", AnchorProvenanceClass::Anchored, Stable),
2516 mk("src/present.rs", AnchorProvenanceClass::Derived, Stable),
2517 mk("src/other.rs", AnchorProvenanceClass::Anchored, Unstable),
2518 mk("src/present.rs", AnchorProvenanceClass::Authored, Stable),
2519 mk("src/present.rs", AnchorProvenanceClass::InformedBy, Stable),
2520 ],
2521 );
2522 std::fs::write(
2523 mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
2524 sidecar.to_bytes(),
2525 )
2526 .unwrap();
2527
2528 write_binding(
2529 root,
2530 "engine",
2531 "graph",
2532 &Binding {
2533 version: BINDING_VERSION,
2534 intent: None,
2535 sources: vec![crate::pipeline::Source {
2536 name: "graph".to_string(),
2537 medium_type: MediumType::Codebase,
2538 pointer: String::new(),
2539 change_detection: Some("git".to_string()),
2540 scope: vec![PatternEntry {
2541 path: "src/**/*.rs".to_string(),
2542 mode: PatternMode::Allow,
2543 }],
2544 engagement: None,
2545 preparation: None,
2546 }],
2547 reference_mems: Vec::new(),
2548 destination_mem: "engine".to_string(),
2549 deny_paths: Vec::new(),
2550 coverage_semantics: None,
2551 rules: None,
2552 prune: None,
2553 operations: Operations {
2554 build: None,
2555 sync: None,
2556 verify: Some(VerifyOperation {
2557 trigger: IngestTrigger::Manual,
2558 batch_size: 20,
2559 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2560 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2561 }),
2562 },
2563 },
2564 )
2565 .unwrap();
2566
2567 let configs = load_pipeline_configs(root).unwrap();
2568 let binding = &configs.bindings[0].config;
2569 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
2570
2571 {
2573 let mut engine = Engine::from_workspace_root(root).unwrap();
2574 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2575 let mut backfilled: Vec<(&str, &str)> = outcome
2578 .hash_backfill
2579 .iter()
2580 .map(|b| (b.entity.as_str(), b.artifact.as_str()))
2581 .collect();
2582 backfilled.sort();
2583 backfilled.dedup();
2584 assert_eq!(
2585 backfilled,
2586 vec![
2587 ("engine--e", "src/other.rs"),
2588 ("engine--e", "src/present.rs"),
2589 ],
2590 "hash-bearing anchors backfill; authored/informed-by never appear"
2591 );
2592 assert_eq!(
2595 outcome.backlog, 0,
2596 "no recheck queue for backfilled anchors"
2597 );
2598 let store = read_findings_store(root, "engine", "graph")
2599 .unwrap()
2600 .unwrap();
2601 assert!(
2602 store
2603 .current(&outcome.key)
2604 .iter()
2605 .all(|f| !matches!(f.target, FindingTarget::Anchor { .. })),
2606 "no anchor finding on the backfill pass: {:?}",
2607 store.current(&outcome.key)
2608 );
2609
2610 let written =
2612 record_anchor_hash_backfill(&mut engine, "engine", &outcome, None).unwrap();
2613 assert_eq!(
2614 written, 3,
2615 "anchored + derived + unstable-anchored gain hashes"
2616 );
2617 }
2618
2619 let expected_present = crate::anchor::prepared_content_hash(
2622 &std::fs::read(root.join("src").join("present.rs")).unwrap(),
2623 );
2624 {
2625 let sc = AnchorSidecar::from_bytes(
2626 &std::fs::read(mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH)).unwrap(),
2627 )
2628 .unwrap();
2629 for a in sc.get("engine--e") {
2630 if a.class.is_hash_bearing() {
2631 assert!(a.hash.is_some(), "hash-bearing anchor backfilled: {a:?}");
2632 } else {
2633 assert!(a.hash.is_none(), "non-hash class never gains a hash: {a:?}");
2634 }
2635 if a.artifact == "src/present.rs" && a.class.is_hash_bearing() {
2636 assert_eq!(a.hash.as_deref(), Some(expected_present.as_str()));
2637 }
2638 }
2639 }
2640
2641 {
2643 let mut engine = Engine::from_workspace_root(root).unwrap();
2644 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2645 assert!(
2646 outcome.hash_backfill.is_empty(),
2647 "backfill happens once — a re-verify observes an empty worklist"
2648 );
2649 assert_eq!(outcome.backlog, 0, "steady state: nothing re-queues");
2650 let store = read_findings_store(root, "engine", "graph")
2651 .unwrap()
2652 .unwrap();
2653 assert!(
2654 store
2655 .current(&outcome.key)
2656 .iter()
2657 .all(|f| !matches!(f.target, FindingTarget::Anchor { .. })),
2658 "recorded hashes match the source — no anchor finding"
2659 );
2660 let written =
2661 record_anchor_hash_backfill(&mut engine, "engine", &outcome, None).unwrap();
2662 assert_eq!(written, 0, "no write, no commit on the idempotent pass");
2663 }
2664
2665 std::fs::write(
2667 root.join("src").join("present.rs"),
2668 "fn a() { /* changed */ }\n",
2669 )
2670 .unwrap();
2671 std::fs::write(
2672 root.join("src").join("other.rs"),
2673 "fn o() { /* changed */ }\n",
2674 )
2675 .unwrap();
2676 git(&["add", "-A"]);
2677 git(&["commit", "-qm", "head-b"]);
2678
2679 {
2682 let engine = Engine::from_workspace_root(root).unwrap();
2683 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2684 assert!(
2685 outcome.hash_backfill.is_empty(),
2686 "recorded hashes are never overwritten by observation"
2687 );
2688 let store = read_findings_store(root, "engine", "graph")
2689 .unwrap()
2690 .unwrap();
2691 let current = store.current(&outcome.key);
2692 let drifted: Vec<&Finding> = current
2693 .iter()
2694 .filter(|f| f.class == FindingClass::Drifted)
2695 .collect();
2696 assert_eq!(
2699 drifted.len(),
2700 2,
2701 "stable-medium mismatch → drifted: {current:?}"
2702 );
2703 assert!(drifted.iter().all(|f| matches!(
2704 &f.target,
2705 FindingTarget::Anchor { artifact, .. } if artifact == "src/present.rs"
2706 )));
2707 assert!(
2710 current
2711 .iter()
2712 .any(|f| f.class == FindingClass::QueuedForAdjudication
2713 && matches!(
2714 &f.target,
2715 FindingTarget::Anchor { artifact, .. } if artifact == "src/other.rs"
2716 )),
2717 "unstable medium resolves recheck (queued), not drifted: {current:?}"
2718 );
2719 assert!(
2720 !current.iter().any(|f| f.class == FindingClass::Drifted
2721 && matches!(
2722 &f.target,
2723 FindingTarget::Anchor { artifact, .. } if artifact == "src/other.rs"
2724 )),
2725 "an unstable hash break must never assert drift"
2726 );
2727 }
2728 }
2729
2730 #[test]
2735 fn backfill_writer_refuses_non_hash_classes_and_never_overwrites() {
2736 let tmp = tempfile::tempdir().unwrap();
2737 let root = tmp.path();
2738 let mem_dir = root.join("mem");
2739 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2740 std::fs::write(
2741 mem_dir.join(".memstead").join("config.json"),
2742 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2743 )
2744 .unwrap();
2745 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2746 std::fs::write(
2747 root.join(".memstead").join("workspace.toml"),
2748 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2749 )
2750 .unwrap();
2751 crate::FileWorkspaceStore::new()
2752 .save_state(
2753 root,
2754 &Workspace {
2755 mounts: vec![Mount {
2756 mem: "engine".to_string(),
2757 schema: Some("default@1.0.0".parse().unwrap()),
2758 storage: MountStorage::Folder {
2759 path: mem_dir.clone(),
2760 },
2761 capability: MountCapability::Write,
2762 lifecycle: MountLifecycle::Eager,
2763 cross_linkable: false,
2764 migration_target: None,
2765 }],
2766 settings: WorkspaceSettings::default(),
2767 },
2768 )
2769 .unwrap();
2770
2771 let anchor = |class: AnchorProvenanceClass, hash: Option<&str>| Anchor {
2772 artifact: "src/a.rs".to_string(),
2773 grain: AnchorGrain::File,
2774 class,
2775 at_version: None,
2776 hash: hash.map(str::to_string),
2777 hash_stability: AnchorHashStability::Stable,
2778 derived_from: Vec::new(),
2779 binding: None,
2780 source: None,
2781 };
2782 let mut sidecar = AnchorSidecar::default();
2783 sidecar.set(
2784 "engine--e",
2785 vec![
2786 anchor(AnchorProvenanceClass::Authored, None),
2787 anchor(AnchorProvenanceClass::InformedBy, None),
2788 anchor(AnchorProvenanceClass::Anchored, Some("recorded")),
2789 ],
2790 );
2791 std::fs::write(
2792 mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
2793 sidecar.to_bytes(),
2794 )
2795 .unwrap();
2796
2797 let mut engine = Engine::from_workspace_root(root).unwrap();
2798 let written = engine
2799 .record_anchor_observed_hashes(
2800 "engine",
2801 &[crate::anchor::ObservedArtifactHash {
2802 entity: "engine--e".to_string(),
2803 artifact: "src/a.rs".to_string(),
2804 hash: "observed".to_string(),
2805 }],
2806 None,
2807 )
2808 .unwrap();
2809 assert_eq!(
2810 written, 0,
2811 "non-hash classes refuse the hash; a recorded hash is never overwritten"
2812 );
2813 let sc = AnchorSidecar::from_bytes(
2814 &std::fs::read(mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH)).unwrap(),
2815 )
2816 .unwrap();
2817 for a in sc.get("engine--e") {
2818 match a.class {
2819 AnchorProvenanceClass::Anchored => {
2820 assert_eq!(a.hash.as_deref(), Some("recorded"), "baseline stands")
2821 }
2822 _ => assert!(a.hash.is_none(), "non-hash class stays hash-less: {a:?}"),
2823 }
2824 }
2825 }
2826
2827 #[test]
2843 fn verify_refuses_unreachable_source_with_typed_error() {
2844 let tmp = tempfile::tempdir().unwrap();
2845 let root = tmp.path();
2846 let mem_dir = root.join("mem");
2847 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2848 std::fs::write(
2849 mem_dir.join(".memstead").join("config.json"),
2850 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2851 )
2852 .unwrap();
2853 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2854 std::fs::write(
2855 root.join(".memstead").join("workspace.toml"),
2856 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2857 )
2858 .unwrap();
2859 let mount = Mount {
2860 mem: "engine".to_string(),
2861 schema: Some("default@1.0.0".parse().unwrap()),
2862 storage: MountStorage::Folder {
2863 path: mem_dir.clone(),
2864 },
2865 capability: MountCapability::Write,
2866 lifecycle: MountLifecycle::Eager,
2867 cross_linkable: false,
2868 migration_target: None,
2869 };
2870 crate::FileWorkspaceStore::new()
2871 .save_state(
2872 root,
2873 &Workspace {
2874 mounts: vec![mount],
2875 settings: WorkspaceSettings::default(),
2876 },
2877 )
2878 .unwrap();
2879
2880 write_binding(
2884 root,
2885 "engine",
2886 "gone",
2887 &Binding {
2888 version: BINDING_VERSION,
2889 intent: None,
2890 sources: vec![crate::pipeline::Source {
2891 name: "gone".to_string(),
2892 medium_type: MediumType::Codebase,
2893 pointer: "vanished-src".to_string(),
2894 change_detection: Some("git".to_string()),
2895 scope: vec![PatternEntry {
2896 path: "**/*.rs".to_string(),
2897 mode: PatternMode::Allow,
2898 }],
2899 engagement: None,
2900 preparation: None,
2901 }],
2902 reference_mems: Vec::new(),
2903 destination_mem: "engine".to_string(),
2904 deny_paths: Vec::new(),
2905 coverage_semantics: None,
2906 rules: None,
2907 prune: None,
2908 operations: Operations {
2909 build: None,
2910 sync: None,
2911 verify: Some(VerifyOperation {
2912 trigger: IngestTrigger::Manual,
2913 batch_size: 20,
2914 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2915 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2916 }),
2917 },
2918 },
2919 )
2920 .unwrap();
2921
2922 let engine = Engine::from_workspace_root(root).unwrap();
2923 let configs = load_pipeline_configs(root).unwrap();
2924 let binding = &configs.bindings[0].config;
2925 let resolved = resolve_binding_run("engine/gone", binding).unwrap();
2926
2927 match verify_binding(&engine, root, binding, &resolved) {
2928 Err(FindingsError::SourceUnreachable { source_name, path }) => {
2929 assert_eq!(source_name, "gone");
2930 assert!(
2931 path.ends_with("vanished-src"),
2932 "refusal must name the resolved missing path, got `{path}`",
2933 );
2934 }
2935 other => panic!("expected SourceUnreachable refusal, got {other:?}"),
2936 }
2937
2938 assert!(
2941 !engine
2942 .mem_config_for("engine")
2943 .unwrap()
2944 .sync_state
2945 .keys()
2946 .any(|k| k.ends_with("#verified")),
2947 "a refused verify must not leave any #verified token",
2948 );
2949 }
2950
2951 #[test]
2952 fn completed_verify_records_the_verified_baseline() {
2953 let tmp = tempfile::tempdir().unwrap();
2954 let root = tmp.path();
2955 let mem_dir = root.join("mem");
2956 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2957 std::fs::write(
2958 mem_dir.join(".memstead").join("config.json"),
2959 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2960 )
2961 .unwrap();
2962 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2963 std::fs::write(
2964 root.join(".memstead").join("workspace.toml"),
2965 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2966 )
2967 .unwrap();
2968 let mount = Mount {
2969 mem: "engine".to_string(),
2970 schema: Some("default@1.0.0".parse().unwrap()),
2971 storage: MountStorage::Folder {
2972 path: mem_dir.clone(),
2973 },
2974 capability: MountCapability::Write,
2975 lifecycle: MountLifecycle::Eager,
2976 cross_linkable: false,
2977 migration_target: None,
2978 };
2979 crate::FileWorkspaceStore::new()
2980 .save_state(
2981 root,
2982 &Workspace {
2983 mounts: vec![mount],
2984 settings: WorkspaceSettings::default(),
2985 },
2986 )
2987 .unwrap();
2988 let out = std::process::Command::new("git")
2989 .args(["init", "-q"])
2990 .current_dir(root)
2991 .output()
2992 .unwrap();
2993 assert!(out.status.success());
2994
2995 write_binding(
2996 root,
2997 "engine",
2998 "graph",
2999 &Binding {
3000 version: BINDING_VERSION,
3001 intent: None,
3002 sources: vec![crate::pipeline::Source {
3003 name: "graph".to_string(),
3004 medium_type: MediumType::Codebase,
3005 pointer: String::new(),
3006 change_detection: Some("git".to_string()),
3007 scope: vec![PatternEntry {
3008 path: "src/**/*.rs".to_string(),
3009 mode: PatternMode::Allow,
3010 }],
3011 engagement: None,
3012 preparation: None,
3013 }],
3014 reference_mems: Vec::new(),
3015 destination_mem: "engine".to_string(),
3016 deny_paths: Vec::new(),
3017 coverage_semantics: None,
3018 rules: None,
3019 prune: None,
3020 operations: Operations {
3021 build: Some(BuildOperation {
3022 mode: BuildMode::Discovery,
3023 trigger: IngestTrigger::Loop,
3024 batch_size: 20,
3025 post_actions: None,
3026 }),
3027 sync: None,
3028 verify: Some(VerifyOperation {
3029 trigger: IngestTrigger::Manual,
3030 batch_size: 20,
3031 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
3032 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
3033 }),
3034 },
3035 },
3036 )
3037 .unwrap();
3038
3039 let mut engine = Engine::from_workspace_root(root).unwrap();
3040 engine
3043 .set_mem_sync_state("engine", "engine/graph/graph#synced", "deadbeef", None)
3044 .unwrap();
3045
3046 let configs = load_pipeline_configs(root).unwrap();
3047 let binding = &configs.bindings[0].config;
3048 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
3049
3050 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
3051 assert_eq!(
3053 outcome.facet_heads.get("graph").map(String::as_str),
3054 Some("deadbeef")
3055 );
3056 assert_eq!(outcome.key.source_head, "graph=deadbeef");
3057 assert_eq!(
3058 join_facet_heads(&outcome.facet_heads),
3059 outcome.key.source_head
3060 );
3061
3062 assert!(
3064 !engine
3065 .mem_config_for("engine")
3066 .unwrap()
3067 .sync_state
3068 .contains_key("engine/graph/graph#verified")
3069 );
3070
3071 let written = record_verified_baseline(&mut engine, "engine", &outcome, None).unwrap();
3072 assert_eq!(written, vec!["engine/graph/graph#verified".to_string()]);
3073
3074 assert_eq!(
3076 engine
3077 .mem_config_for("engine")
3078 .unwrap()
3079 .sync_state
3080 .get("engine/graph/graph#verified")
3081 .map(String::as_str),
3082 Some("deadbeef")
3083 );
3084 let disk: serde_json::Value = serde_json::from_slice(
3086 &std::fs::read(mem_dir.join(".memstead").join("config.json")).unwrap(),
3087 )
3088 .unwrap();
3089 assert_eq!(
3090 disk["syncState"]["engine/graph/graph#verified"],
3091 serde_json::json!("deadbeef")
3092 );
3093 }
3094
3095 #[test]
3102 fn adjudication_cap_queues_the_remainder() {
3103 let k = key("h", "s");
3104 let mk = |art: &str| {
3105 let mut a = anchor(AnchorProvenanceClass::Anchored);
3106 a.artifact = art.to_string();
3107 a
3108 };
3109 let candidates = vec![
3110 (
3111 "engine--a".to_string(),
3112 mk("src/a.rs"),
3113 AnchorState::Drifted,
3114 ),
3115 (
3116 "engine--b".to_string(),
3117 mk("src/b.rs"),
3118 AnchorState::Drifted,
3119 ),
3120 (
3121 "engine--c".to_string(),
3122 mk("src/c.rs"),
3123 AnchorState::Drifted,
3124 ),
3125 ];
3126 let window: BTreeSet<String> = [candidate_key("engine--a", &mk("src/a.rs"))]
3128 .into_iter()
3129 .collect();
3130 let out = adjudicate_candidates(&k, "f", &candidates, Some(&window), "1");
3131 let drifted = out
3132 .iter()
3133 .filter(|f| f.class == FindingClass::Drifted)
3134 .count();
3135 let queued = out
3136 .iter()
3137 .filter(|f| f.class == FindingClass::QueuedForAdjudication)
3138 .count();
3139 assert_eq!(drifted, 1, "only the in-window candidate is adjudicated");
3140 assert_eq!(queued, 2, "the remainder is queued as the tier-3 backlog");
3141 assert!(
3143 out.iter()
3144 .any(|f| f.class == FindingClass::QueuedForAdjudication
3145 && f.detail.contains("cap reached")),
3146 "capped remainder states it was deferred by the cap"
3147 );
3148
3149 let uncapped = adjudicate_candidates(&k, "f", &candidates, None, "1");
3151 assert_eq!(
3152 uncapped
3153 .iter()
3154 .filter(|f| f.class == FindingClass::Drifted)
3155 .count(),
3156 3,
3157 "uncapped adjudicates every candidate"
3158 );
3159 assert_eq!(
3160 uncapped
3161 .iter()
3162 .filter(|f| f.class == FindingClass::QueuedForAdjudication)
3163 .count(),
3164 0
3165 );
3166 }
3167
3168 #[test]
3174 fn full_resync_schedule_disabled_notdue_due() {
3175 let codebase = FacetEnumerability {
3176 facet: "src".to_string(),
3177 medium_type: "codebase".to_string(),
3178 enumerable: true,
3179 };
3180 assert_eq!(
3181 schedule_full_resync(0, 5, std::slice::from_ref(&codebase)),
3182 FullResyncDecision::Disabled
3183 );
3184 match schedule_full_resync(3, 2, std::slice::from_ref(&codebase)) {
3185 FullResyncDecision::NotDue { runs_until_due, .. } => assert_eq!(runs_until_due, 1),
3186 other => panic!("expected NotDue, got {other:?}"),
3187 }
3188 match schedule_full_resync(3, 3, std::slice::from_ref(&codebase)) {
3189 FullResyncDecision::Due {
3190 walked_facets,
3191 refused,
3192 ..
3193 } => {
3194 assert_eq!(walked_facets, vec!["src".to_string()]);
3195 assert!(refused.is_empty(), "enumerable facet is not refused");
3196 }
3197 other => panic!("expected Due, got {other:?}"),
3198 }
3199 }
3200
3201 #[test]
3204 fn full_resync_refuses_non_enumerable_medium() {
3205 let web = FacetEnumerability {
3206 facet: "manual".to_string(),
3207 medium_type: "web".to_string(),
3208 enumerable: false,
3209 };
3210 let d = schedule_full_resync(1, 1, &[web]);
3211 assert!(
3212 d.is_full_walk(),
3213 "a due sweep is a full walk even when refused"
3214 );
3215 match d {
3216 FullResyncDecision::Due {
3217 walked_facets,
3218 refused,
3219 ..
3220 } => {
3221 assert!(walked_facets.is_empty(), "nothing enumerable to walk");
3222 assert_eq!(refused.len(), 1, "the non-enumerable facet is refused");
3223 assert_eq!(refused[0].facet, "manual");
3224 assert_eq!(refused[0].medium_type, "web");
3225 assert!(
3226 refused[0].reason.contains("non-enumerable"),
3227 "the refusal is typed and states why"
3228 );
3229 }
3230 other => panic!("expected Due with a refusal, got {other:?}"),
3231 }
3232 }
3233
3234 #[test]
3239 fn full_resync_full_walk_covers_whole_source() {
3240 let tmp = tempfile::tempdir().unwrap();
3241 let root = tmp.path();
3242 let mem_dir = root.join("mem");
3243 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3244 std::fs::write(
3245 mem_dir.join(".memstead").join("config.json"),
3246 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3247 )
3248 .unwrap();
3249 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3250 std::fs::write(
3251 root.join(".memstead").join("workspace.toml"),
3252 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3253 )
3254 .unwrap();
3255 let mount = Mount {
3256 mem: "engine".to_string(),
3257 schema: Some("default@1.0.0".parse().unwrap()),
3258 storage: MountStorage::Folder {
3259 path: mem_dir.clone(),
3260 },
3261 capability: MountCapability::Write,
3262 lifecycle: MountLifecycle::Eager,
3263 cross_linkable: false,
3264 migration_target: None,
3265 };
3266 crate::FileWorkspaceStore::new()
3267 .save_state(
3268 root,
3269 &Workspace {
3270 mounts: vec![mount],
3271 settings: WorkspaceSettings::default(),
3272 },
3273 )
3274 .unwrap();
3275 let out = std::process::Command::new("git")
3276 .args(["init", "-q"])
3277 .current_dir(root)
3278 .output()
3279 .unwrap();
3280 assert!(out.status.success());
3281 std::fs::create_dir_all(root.join("src")).unwrap();
3282 for f in ["a.rs", "b.rs", "c.rs"] {
3283 std::fs::write(root.join("src").join(f), "fn x() {}\n").unwrap();
3284 }
3285
3286 write_binding(
3287 root,
3288 "engine",
3289 "graph",
3290 &Binding {
3291 version: BINDING_VERSION,
3292 intent: None,
3293 sources: vec![crate::pipeline::Source {
3294 name: "graph".to_string(),
3295 medium_type: MediumType::Codebase,
3296 pointer: String::new(),
3297 change_detection: Some("git".to_string()),
3298 scope: vec![PatternEntry {
3299 path: "src/**/*.rs".to_string(),
3300 mode: PatternMode::Allow,
3301 }],
3302 engagement: None,
3303 preparation: None,
3304 }],
3305 reference_mems: Vec::new(),
3306 destination_mem: "engine".to_string(),
3307 deny_paths: Vec::new(),
3308 coverage_semantics: None,
3309 rules: None,
3310 prune: None,
3311 operations: Operations {
3312 build: Some(BuildOperation {
3313 mode: BuildMode::Discovery,
3314 trigger: IngestTrigger::Loop,
3315 batch_size: 20,
3316 post_actions: None,
3317 }),
3318 sync: None,
3319 verify: Some(VerifyOperation {
3320 trigger: IngestTrigger::Manual,
3321 batch_size: 1, adjudication_cap: DEFAULT_ADJUDICATION_CAP,
3323 full_resync_every: 1, }),
3325 },
3326 },
3327 )
3328 .unwrap();
3329
3330 let engine = Engine::from_workspace_root(root).unwrap();
3331 let configs = load_pipeline_configs(root).unwrap();
3332 let binding = &configs.bindings[0].config;
3333 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
3334
3335 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
3336 match &outcome.full_resync {
3338 FullResyncDecision::Due {
3339 walked_facets,
3340 refused,
3341 run_count,
3342 ..
3343 } => {
3344 assert_eq!(*run_count, 1);
3345 assert_eq!(walked_facets, &vec!["graph".to_string()]);
3346 assert!(refused.is_empty());
3347 }
3348 other => panic!("expected a due full walk, got {other:?}"),
3349 }
3350 let store = read_findings_store(root, "engine", "graph")
3352 .unwrap()
3353 .unwrap();
3354 let uncovered = store
3355 .current(&outcome.key)
3356 .iter()
3357 .filter(|f| f.class == FindingClass::Uncovered)
3358 .count();
3359 assert_eq!(
3360 uncovered, 3,
3361 "the scheduled full walk covers the whole source, not a batch of one"
3362 );
3363 }
3364
3365 #[test]
3376 fn full_verify_uncaps_adjudication_and_walks_whole_source() {
3377 let tmp = tempfile::tempdir().unwrap();
3378 let root = tmp.path();
3379 let mem_dir = root.join("mem");
3380 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3381 std::fs::write(
3382 mem_dir.join(".memstead").join("config.json"),
3383 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3384 )
3385 .unwrap();
3386 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3387 std::fs::write(
3388 root.join(".memstead").join("workspace.toml"),
3389 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3390 )
3391 .unwrap();
3392 crate::FileWorkspaceStore::new()
3393 .save_state(
3394 root,
3395 &Workspace {
3396 mounts: vec![Mount {
3397 mem: "engine".to_string(),
3398 schema: Some("default@1.0.0".parse().unwrap()),
3399 storage: MountStorage::Folder {
3400 path: mem_dir.clone(),
3401 },
3402 capability: MountCapability::Write,
3403 lifecycle: MountLifecycle::Eager,
3404 cross_linkable: false,
3405 migration_target: None,
3406 }],
3407 settings: WorkspaceSettings::default(),
3408 },
3409 )
3410 .unwrap();
3411 let out = std::process::Command::new("git")
3412 .args(["init", "-q"])
3413 .current_dir(root)
3414 .output()
3415 .unwrap();
3416 assert!(out.status.success());
3417 std::fs::create_dir_all(root.join("src")).unwrap();
3418 for f in ["a.rs", "b.rs", "c.rs", "d.rs", "e.rs", "f.rs"] {
3420 std::fs::write(root.join("src").join(f), "fn x() {}\n").unwrap();
3421 }
3422 let mk = |art: &str| Anchor {
3423 artifact: art.to_string(),
3424 grain: AnchorGrain::File,
3425 class: AnchorProvenanceClass::Anchored,
3426 at_version: None,
3427 hash: Some("stale-recorded-hash".to_string()), hash_stability: AnchorHashStability::Stable,
3429 derived_from: Vec::new(),
3430 binding: None,
3431 source: None,
3432 };
3433 let mut sidecar = AnchorSidecar::default();
3434 sidecar.set(
3435 "engine--e",
3436 vec![mk("src/a.rs"), mk("src/b.rs"), mk("src/c.rs")],
3437 );
3438 std::fs::write(
3439 mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
3440 sidecar.to_bytes(),
3441 )
3442 .unwrap();
3443
3444 write_binding(
3445 root,
3446 "engine",
3447 "graph",
3448 &Binding {
3449 version: BINDING_VERSION,
3450 intent: None,
3451 sources: vec![crate::pipeline::Source {
3452 name: "graph".to_string(),
3453 medium_type: MediumType::Codebase,
3454 pointer: String::new(),
3455 change_detection: Some("git".to_string()),
3456 scope: vec![PatternEntry {
3457 path: "src/**/*.rs".to_string(),
3458 mode: PatternMode::Allow,
3459 }],
3460 engagement: None,
3461 preparation: None,
3462 }],
3463 reference_mems: Vec::new(),
3464 destination_mem: "engine".to_string(),
3465 deny_paths: Vec::new(),
3466 coverage_semantics: None,
3467 rules: None,
3468 prune: None,
3469 operations: Operations {
3470 build: None,
3471 sync: None,
3472 verify: Some(VerifyOperation {
3473 trigger: IngestTrigger::Manual,
3474 batch_size: 1, adjudication_cap: 1, full_resync_every: 0, }),
3478 },
3479 },
3480 )
3481 .unwrap();
3482
3483 let engine = Engine::from_workspace_root(root).unwrap();
3484 let configs = load_pipeline_configs(root).unwrap();
3485 let binding = &configs.bindings[0].config;
3486 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
3487
3488 let sampled = verify_binding(&engine, root, binding, &resolved).unwrap();
3492 assert_eq!(sampled.full_resync, FullResyncDecision::Disabled);
3493 let store = read_findings_store(root, "engine", "graph")
3494 .unwrap()
3495 .unwrap();
3496 let current = store.current(&sampled.key);
3497 let count = |c: FindingClass| current.iter().filter(|f| f.class == c).count();
3498 assert_eq!(count(FindingClass::Drifted), 1, "cap-1 adjudicates one");
3499 assert_eq!(
3500 count(FindingClass::QueuedForAdjudication),
3501 2,
3502 "the remainder queues"
3503 );
3504 assert!(
3505 current
3506 .iter()
3507 .any(|f| f.class == FindingClass::QueuedForAdjudication
3508 && f.detail.contains("cap reached")),
3509 "the sampled deferral states the cap"
3510 );
3511 assert!(
3512 count(FindingClass::Uncovered) <= 1,
3513 "batch-1 sample looks at one artifact"
3514 );
3515
3516 let full = verify_binding_full(&engine, root, binding, &resolved).unwrap();
3519 assert_eq!(
3520 full.full_resync,
3521 FullResyncDecision::Forced {
3522 walked_facets: vec!["graph".to_string()]
3523 }
3524 );
3525 assert_eq!(full.backlog, 0, "cap treated as unlimited — no backlog");
3526 let store = read_findings_store(root, "engine", "graph")
3527 .unwrap()
3528 .unwrap();
3529 let current = store.current(&full.key);
3530 let count = |c: FindingClass| current.iter().filter(|f| f.class == c).count();
3531 assert_eq!(
3532 count(FindingClass::Drifted),
3533 3,
3534 "every candidate adjudicated"
3535 );
3536 assert_eq!(count(FindingClass::QueuedForAdjudication), 0);
3537 assert_eq!(
3538 count(FindingClass::Uncovered),
3539 3,
3540 "the whole S(D) walked — every uncovered file flagged"
3541 );
3542 assert!(
3543 current.iter().all(|f| !f.detail.contains("cap reached")),
3544 "a full run's findings carry no cap-deferral caveat"
3545 );
3546 }
3547
3548 #[test]
3553 fn full_verify_refuses_non_enumerable_medium_typed() {
3554 let tmp = tempfile::tempdir().unwrap();
3555 let root = tmp.path();
3556 let mem_dir = root.join("mem");
3557 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3558 std::fs::write(
3559 mem_dir.join(".memstead").join("config.json"),
3560 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3561 )
3562 .unwrap();
3563 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3564 std::fs::write(
3565 root.join(".memstead").join("workspace.toml"),
3566 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3567 )
3568 .unwrap();
3569 crate::FileWorkspaceStore::new()
3570 .save_state(
3571 root,
3572 &Workspace {
3573 mounts: vec![Mount {
3574 mem: "engine".to_string(),
3575 schema: Some("default@1.0.0".parse().unwrap()),
3576 storage: MountStorage::Folder {
3577 path: mem_dir.clone(),
3578 },
3579 capability: MountCapability::Write,
3580 lifecycle: MountLifecycle::Eager,
3581 cross_linkable: false,
3582 migration_target: None,
3583 }],
3584 settings: WorkspaceSettings::default(),
3585 },
3586 )
3587 .unwrap();
3588
3589 write_binding(
3591 root,
3592 "engine",
3593 "manual",
3594 &Binding {
3595 version: BINDING_VERSION,
3596 intent: None,
3597 sources: vec![crate::pipeline::Source {
3598 name: "manual".to_string(),
3599 medium_type: MediumType::Web,
3600 pointer: "https://example.com/docs".to_string(),
3601 change_detection: None,
3602 scope: Vec::new(),
3603 engagement: None,
3604 preparation: None,
3605 }],
3606 reference_mems: Vec::new(),
3607 destination_mem: "engine".to_string(),
3608 deny_paths: Vec::new(),
3609 coverage_semantics: Some(CoverageSemantics::Curated),
3610 rules: None,
3611 prune: None,
3612 operations: Operations {
3613 build: None,
3614 sync: None,
3615 verify: Some(VerifyOperation {
3616 trigger: IngestTrigger::Manual,
3617 batch_size: 20,
3618 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
3619 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
3620 }),
3621 },
3622 },
3623 )
3624 .unwrap();
3625
3626 let engine = Engine::from_workspace_root(root).unwrap();
3627 let configs = load_pipeline_configs(root).unwrap();
3628 let binding = &configs.bindings[0].config;
3629 let resolved = resolve_binding_run("engine/manual", binding).unwrap();
3630
3631 let err = verify_binding_full(&engine, root, binding, &resolved).unwrap_err();
3633 match &err {
3634 FindingsError::FullWalkNonEnumerable(refusal) => {
3635 assert_eq!(refusal.facet, "manual");
3636 assert_eq!(refusal.medium_type, "web");
3637 assert!(refusal.reason.contains("non-enumerable"));
3638 }
3639 other => panic!("expected FullWalkNonEnumerable, got {other:?}"),
3640 }
3641 assert!(
3642 read_findings_store(root, "engine", "manual")
3643 .unwrap()
3644 .is_none(),
3645 "a refused full run records nothing"
3646 );
3647
3648 let sampled = verify_binding(&engine, root, binding, &resolved).unwrap();
3650 assert_eq!(sampled.binding, "engine/manual");
3651 }
3652}