1use std::collections::{BTreeMap, BTreeSet};
63use std::path::{Path, PathBuf};
64use std::time::{SystemTime, UNIX_EPOCH};
65
66use serde::{Deserialize, Serialize};
67
68use crate::Engine;
69use crate::anchor::{Anchor, AnchorState, ObservedArtifactHash};
70use crate::binding::{
71 Binding, DEFAULT_ADJUDICATION_CAP, DEFAULT_FULL_RESYNC_EVERY, hash_binding, medium_capabilities,
72};
73use crate::workspace_store::{StoreError, WORKSPACE_STORE_DIR};
74
75use super::advance::is_single_component;
76use super::cursor::{compute_source_cursor, enumerate_facet_files};
77use super::refinement::{
78 ROTATION_ANCHOR_ADJUDICATION, bump_verify_runs, next_batch, next_rotation_batch,
79};
80use super::resolve::{ResolvedIngest, ResolvedSource};
81
82const STATE_DIR: &str = "state";
85const FINDINGS_DIR: &str = "findings";
87
88#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
105pub struct FindingKey {
106 pub binding_hash: String,
109 pub source_head: String,
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
126#[serde(rename_all = "kebab-case")]
127pub enum FindingClass {
128 Drifted,
131 Wrong,
133 Uncovered,
135 UnresolvableAnchor,
137 QueuedForAdjudication,
140}
141
142impl FindingClass {
143 pub const WIRE_VALUES: &'static [&'static str] = &[
145 "drifted",
146 "wrong",
147 "uncovered",
148 "unresolvable-anchor",
149 "queued-for-adjudication",
150 ];
151
152 pub fn as_wire(&self) -> &'static str {
154 match self {
155 FindingClass::Drifted => "drifted",
156 FindingClass::Wrong => "wrong",
157 FindingClass::Uncovered => "uncovered",
158 FindingClass::UnresolvableAnchor => "unresolvable-anchor",
159 FindingClass::QueuedForAdjudication => "queued-for-adjudication",
160 }
161 }
162
163 pub fn from_wire(s: &str) -> Option<Self> {
165 match s {
166 "drifted" => Some(FindingClass::Drifted),
167 "wrong" => Some(FindingClass::Wrong),
168 "uncovered" => Some(FindingClass::Uncovered),
169 "unresolvable-anchor" => Some(FindingClass::UnresolvableAnchor),
170 "queued-for-adjudication" => Some(FindingClass::QueuedForAdjudication),
171 _ => None,
172 }
173 }
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
179#[serde(tag = "kind", rename_all = "kebab-case")]
180pub enum FindingTarget {
181 Anchor {
184 entity: String,
186 artifact: String,
188 },
189 Artifact {
192 artifact: String,
194 },
195}
196
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202pub struct Finding {
203 pub key: FindingKey,
207 pub facet: String,
210 pub target: FindingTarget,
212 pub class: FindingClass,
214 pub detail: String,
216 pub created_at: String,
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
232pub struct FindingsBatch {
233 pub key: FindingKey,
236 pub recorded_at: String,
238 pub findings: Vec<Finding>,
240}
241
242#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
256pub struct FindingsStore {
257 pub binding: String,
259 #[serde(default)]
262 pub batches: Vec<FindingsBatch>,
263}
264
265impl FindingsStore {
266 fn current_batch_index(&self, binding_hash: &str) -> Option<usize> {
271 self.batches
272 .iter()
273 .enumerate()
274 .filter(|(_, b)| b.key.binding_hash == binding_hash)
275 .max_by_key(|(i, b)| (b.recorded_at.parse::<u64>().unwrap_or(0), *i))
276 .map(|(i, _)| i)
277 }
278
279 pub fn record(&mut self, key: FindingKey, recorded_at: String, findings: Vec<Finding>) {
284 self.batches
285 .retain(|b| b.key.binding_hash != key.binding_hash);
286 self.batches.push(FindingsBatch {
287 key,
288 recorded_at,
289 findings,
290 });
291 }
292
293 pub fn current(&self, key: &FindingKey) -> &[Finding] {
298 self.current_batch_index(&key.binding_hash)
299 .map(|i| self.batches[i].findings.as_slice())
300 .unwrap_or(&[])
301 }
302
303 pub fn superseded(&self, key: &FindingKey) -> Vec<&Finding> {
308 let current = self.current_batch_index(&key.binding_hash);
309 self.batches
310 .iter()
311 .enumerate()
312 .filter(|(i, _)| Some(*i) != current)
313 .flat_map(|(_, b)| b.findings.iter())
314 .collect()
315 }
316}
317
318pub fn findings_store_path(workspace_root: &Path, mem: &str, name: &str) -> PathBuf {
325 workspace_root
326 .join(WORKSPACE_STORE_DIR)
327 .join(STATE_DIR)
328 .join(FINDINGS_DIR)
329 .join(mem)
330 .join(format!("{name}.json"))
331}
332
333pub const STANDALONE_KEY: &str = "standalone";
343
344#[derive(Debug, Clone, Serialize)]
349pub struct AnnotatedStandaloneFinding {
350 #[serde(flatten)]
351 pub finding: Finding,
352 pub already_seen: bool,
353}
354
355pub fn record_standalone_findings(
363 workspace_root: &Path,
364 report: &crate::engine::query::MemAnchorVerification,
365) -> Result<Vec<AnnotatedStandaloneFinding>, StoreError> {
366 let mem = &report.mem;
367 let key = FindingKey {
368 binding_hash: STANDALONE_KEY.to_string(),
369 source_head: String::new(),
370 };
371 let now = SystemTime::now()
372 .duration_since(UNIX_EPOCH)
373 .map(|d| d.as_secs())
374 .unwrap_or(0)
375 .to_string();
376
377 let findings: Vec<Finding> = report
378 .anchors
379 .iter()
380 .filter_map(|a| {
381 let class = match a.state.as_str() {
382 "drifted" => FindingClass::Drifted,
383 "unresolvable" => FindingClass::UnresolvableAnchor,
384 _ => return None,
385 };
386 Some(Finding {
387 key: key.clone(),
388 facet: STANDALONE_KEY.to_string(),
389 target: FindingTarget::Anchor {
390 entity: a.entity_id.clone(),
391 artifact: a.artifact.clone(),
392 },
393 class,
394 detail: format!("{} ({} {})", a.state, a.class, a.grain),
395 created_at: now.clone(),
396 })
397 })
398 .collect();
399
400 let mut store =
401 read_findings_store(workspace_root, mem, STANDALONE_KEY)?.unwrap_or_else(|| {
402 FindingsStore {
403 binding: format!("{mem}/{STANDALONE_KEY}"),
404 ..Default::default()
405 }
406 });
407 let prior: BTreeSet<(String, String)> = store
408 .current(&key)
409 .iter()
410 .map(|f| {
411 (
412 serde_json::to_string(&f.target).unwrap_or_default(),
413 f.class.as_wire().to_string(),
414 )
415 })
416 .collect();
417 let annotated: Vec<AnnotatedStandaloneFinding> = findings
418 .iter()
419 .map(|f| AnnotatedStandaloneFinding {
420 finding: f.clone(),
421 already_seen: prior.contains(&(
422 serde_json::to_string(&f.target).unwrap_or_default(),
423 f.class.as_wire().to_string(),
424 )),
425 })
426 .collect();
427 store.record(key, now, findings);
428 write_findings_store(workspace_root, mem, STANDALONE_KEY, &store)?;
429 Ok(annotated)
430}
431
432pub fn read_findings_store(
435 workspace_root: &Path,
436 mem: &str,
437 name: &str,
438) -> Result<Option<FindingsStore>, StoreError> {
439 let path = findings_store_path(workspace_root, mem, name);
440 match std::fs::read(&path) {
441 Ok(bytes) => serde_json::from_slice(&bytes)
442 .map(Some)
443 .map_err(|e| StoreError::Parse {
444 path,
445 message: e.to_string(),
446 }),
447 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
448 Err(e) => Err(StoreError::Io { path, source: e }),
449 }
450}
451
452pub(crate) fn ensure_selfignoring_store_dir(subtree_root: &Path) -> Result<(), StoreError> {
460 std::fs::create_dir_all(subtree_root).map_err(|e| StoreError::Io {
461 path: subtree_root.to_path_buf(),
462 source: e,
463 })?;
464 let gitignore = subtree_root.join(".gitignore");
465 if !gitignore.exists() {
466 let _ = std::fs::write(&gitignore, "*\n");
467 }
468 Ok(())
469}
470
471pub fn write_findings_store(
474 workspace_root: &Path,
475 mem: &str,
476 name: &str,
477 store: &FindingsStore,
478) -> Result<(), StoreError> {
479 ensure_selfignoring_store_dir(
480 &workspace_root
481 .join(WORKSPACE_STORE_DIR)
482 .join(STATE_DIR)
483 .join(FINDINGS_DIR),
484 )?;
485 let path = findings_store_path(workspace_root, mem, name);
486 if let Some(parent) = path.parent() {
487 std::fs::create_dir_all(parent).map_err(|e| StoreError::Io {
488 path: parent.to_path_buf(),
489 source: e,
490 })?;
491 }
492 let bytes = serde_json::to_vec_pretty(store).map_err(|e| StoreError::Parse {
493 path: path.clone(),
494 message: e.to_string(),
495 })?;
496 std::fs::write(&path, bytes).map_err(|e| StoreError::Io { path, source: e })
497}
498
499pub fn delete_findings_store(
502 workspace_root: &Path,
503 mem: &str,
504 name: &str,
505) -> Result<(), StoreError> {
506 let path = findings_store_path(workspace_root, mem, name);
507 match std::fs::remove_file(&path) {
508 Ok(()) => Ok(()),
509 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
510 Err(e) => Err(StoreError::Io { path, source: e }),
511 }
512}
513
514#[derive(Debug, thiserror::Error)]
520pub enum FindingsError {
521 #[error("malformed binding id '{0}': expected `<mem>/<stem>`")]
523 MalformedId(String),
524 #[error("findings store error: {0}")]
526 Store(#[source] StoreError),
527 #[error("source '{source_name}' unreachable: `{path}` does not exist")]
534 SourceUnreachable {
535 source_name: String,
537 path: String,
539 },
540 #[error(
549 "full verify refused: facet '{}' resolves over non-enumerable medium type '{}' — {}",
550 .0.facet, .0.medium_type, .0.reason
551 )]
552 FullWalkNonEnumerable(FullResyncRefusal),
553}
554
555#[derive(Debug, Clone, PartialEq, Eq)]
557pub struct VerifyOutcome {
558 pub binding: String,
560 pub key: FindingKey,
562 pub recorded: usize,
564 pub superseded: usize,
566 pub backlog: usize,
568 pub full_resync: FullResyncDecision,
572 pub facet_heads: BTreeMap<String, String>,
576 pub hash_backfill: Vec<ObservedArtifactHash>,
586}
587
588pub fn record_verified_baseline(
603 engine: &mut Engine,
604 destination_mem: &str,
605 outcome: &VerifyOutcome,
606 note: Option<&str>,
607) -> Result<Vec<String>, crate::engine::EngineError> {
608 let mut written = Vec::with_capacity(outcome.facet_heads.len());
609 for (facet, token) in &outcome.facet_heads {
610 let key = format!("{}/{facet}#verified", outcome.binding);
611 engine.set_mem_sync_state(destination_mem, &key, token, note)?;
612 written.push(key);
613 }
614 Ok(written)
615}
616
617pub fn record_anchor_hash_backfill(
634 engine: &mut Engine,
635 destination_mem: &str,
636 outcome: &VerifyOutcome,
637 note: Option<&str>,
638) -> Result<usize, crate::engine::EngineError> {
639 engine.record_anchor_observed_hashes(destination_mem, &outcome.hash_backfill, note)
640}
641
642fn split_binding_id(binding_id: &str) -> Result<(String, String), FindingsError> {
646 binding_id
647 .split_once('/')
648 .filter(|(m, n)| is_single_component(m) && is_single_component(n))
649 .map(|(m, n)| (m.to_string(), n.to_string()))
650 .ok_or_else(|| FindingsError::MalformedId(binding_id.to_string()))
651}
652
653fn source_facet_label(resolved: &ResolvedIngest) -> String {
657 let facets: Vec<&str> = resolved
658 .sources
659 .iter()
660 .filter_map(|s| match s {
661 ResolvedSource::Primary(p) => Some(p.name.as_str()),
662 ResolvedSource::Reference { .. } => None,
663 })
664 .collect();
665 facets.join(",")
666}
667
668fn now_seconds() -> String {
670 let secs = SystemTime::now()
671 .duration_since(UNIX_EPOCH)
672 .map(|d| d.as_secs())
673 .unwrap_or(0);
674 secs.to_string()
675}
676
677fn current_facet_heads(
685 engine: &Engine,
686 workspace_root: &Path,
687 resolved: &ResolvedIngest,
688) -> BTreeMap<String, String> {
689 let binding_id = &resolved.name;
690 let prefix = format!("{binding_id}/");
691 let mut tokens: BTreeMap<String, String> = BTreeMap::new();
692
693 if let Some(cfg) = engine.mem_config_for(&resolved.destination_mem) {
695 for (k, v) in &cfg.sync_state {
696 if let Some(rest) = k.strip_prefix(&prefix)
697 && let Some(facet) = rest.strip_suffix("#synced")
698 {
699 tokens.insert(facet.to_string(), v.clone());
700 }
701 }
702 }
703
704 let cursor = compute_source_cursor(engine, resolved, workspace_root);
706 for c in cursor.write_commands.iter().chain(cursor.reseed.iter()) {
707 if let Some(rest) = c.key.strip_prefix(&prefix)
708 && let Some(facet) = rest.strip_suffix("#synced")
709 {
710 tokens.insert(facet.to_string(), c.token.clone());
711 }
712 }
713
714 tokens
715}
716
717fn join_facet_heads(tokens: &BTreeMap<String, String>) -> String {
720 tokens
721 .iter()
722 .map(|(facet, token)| format!("{facet}={token}"))
723 .collect::<Vec<_>>()
724 .join(";")
725}
726
727fn current_source_head(
731 engine: &Engine,
732 workspace_root: &Path,
733 resolved: &ResolvedIngest,
734) -> String {
735 join_facet_heads(¤t_facet_heads(engine, workspace_root, resolved))
736}
737
738fn binding_hash_of(binding: &Binding, _resolved: &ResolvedIngest) -> String {
741 hash_binding(binding)
742}
743
744fn current_key(
748 engine: &Engine,
749 workspace_root: &Path,
750 binding: &Binding,
751 resolved: &ResolvedIngest,
752) -> FindingKey {
753 FindingKey {
754 binding_hash: binding_hash_of(binding, resolved),
755 source_head: current_source_head(engine, workspace_root, resolved),
756 }
757}
758
759pub fn current_findings(
769 engine: &Engine,
770 workspace_root: &Path,
771 binding: &Binding,
772 resolved: &ResolvedIngest,
773) -> Result<(FindingKey, Vec<Finding>), FindingsError> {
774 let (mem, name) = split_binding_id(&resolved.name)?;
775 let key = current_key(engine, workspace_root, binding, resolved);
776 let findings = read_findings_store(workspace_root, &mem, &name)
777 .map_err(FindingsError::Store)?
778 .map(|s| s.current(&key).to_vec())
779 .unwrap_or_default();
780 Ok((key, findings))
781}
782
783pub fn adjudicate_anchor(
794 key: &FindingKey,
795 facet: &str,
796 entity: &str,
797 anchor: &Anchor,
798 state: AnchorState,
799 created_at: &str,
800) -> Option<Finding> {
801 let (class, detail) = match state {
802 AnchorState::Resolves => return None,
803 AnchorState::Orphaned => (
804 FindingClass::UnresolvableAnchor,
805 format!(
806 "artifact '{}' the anchor references is no longer present in the medium",
807 anchor.artifact
808 ),
809 ),
810 AnchorState::Drifted | AnchorState::Recheck => {
811 if !anchor.class.is_hash_bearing() {
813 return None;
814 }
815 match state {
816 AnchorState::Drifted => (
817 FindingClass::Drifted,
818 format!(
819 "prepared-content hash of '{}' drifted from the anchored hash",
820 anchor.artifact
821 ),
822 ),
823 _ => (
824 FindingClass::QueuedForAdjudication,
825 format!(
826 "hash adjudication of '{}' deferred (recheck); queued",
827 anchor.artifact
828 ),
829 ),
830 }
831 }
832 };
833 Some(Finding {
834 key: key.clone(),
835 facet: facet.to_string(),
836 target: FindingTarget::Anchor {
837 entity: entity.to_string(),
838 artifact: anchor.artifact.clone(),
839 },
840 class,
841 detail,
842 created_at: created_at.to_string(),
843 })
844}
845
846#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
853pub struct FacetEnumerability {
854 pub facet: String,
856 pub medium_type: String,
858 pub enumerable: bool,
860}
861
862#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
867pub struct FullResyncRefusal {
868 pub facet: String,
870 pub medium_type: String,
872 pub reason: String,
874}
875
876#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
880#[serde(tag = "state", rename_all = "kebab-case")]
881pub enum FullResyncDecision {
882 Disabled,
885 NotDue {
888 run_count: u64,
890 every: u32,
892 runs_until_due: u32,
894 },
895 Due {
900 run_count: u64,
902 every: u32,
904 walked_facets: Vec<String>,
906 refused: Vec<FullResyncRefusal>,
908 },
909 Forced {
917 walked_facets: Vec<String>,
919 },
920}
921
922impl FullResyncDecision {
923 pub fn is_full_walk(&self) -> bool {
927 matches!(
928 self,
929 FullResyncDecision::Due { .. } | FullResyncDecision::Forced { .. }
930 )
931 }
932}
933
934pub fn schedule_full_resync(
940 every: u32,
941 run_count: u64,
942 facets: &[FacetEnumerability],
943) -> FullResyncDecision {
944 if every == 0 {
945 return FullResyncDecision::Disabled;
946 }
947 let modulo = run_count % u64::from(every);
948 if modulo != 0 {
949 return FullResyncDecision::NotDue {
950 run_count,
951 every,
952 runs_until_due: (u64::from(every) - modulo) as u32,
953 };
954 }
955 let mut walked_facets = Vec::new();
956 let mut refused = Vec::new();
957 for f in facets {
958 if f.enumerable {
959 walked_facets.push(f.facet.clone());
960 } else {
961 refused.push(FullResyncRefusal {
962 facet: f.facet.clone(),
963 medium_type: f.medium_type.clone(),
964 reason: format!(
965 "medium type '{}' is non-enumerable — a full-enumeration walk cannot cover \
966 it; the scheduled full resync refuses rather than claim full coverage",
967 f.medium_type
968 ),
969 });
970 }
971 }
972 FullResyncDecision::Due {
973 run_count,
974 every,
975 walked_facets,
976 refused,
977 }
978}
979
980fn candidate_key(entity: &str, anchor: &Anchor) -> String {
984 format!("{entity}\u{1f}{}", anchor.artifact)
985}
986
987fn adjudicate_candidates(
999 key: &FindingKey,
1000 facet: &str,
1001 candidates: &[(String, Anchor, AnchorState)],
1002 window: Option<&BTreeSet<String>>,
1003 created_at: &str,
1004) -> Vec<Finding> {
1005 let mut out = Vec::new();
1006 for (entity, anchor, state) in candidates {
1007 let ck = candidate_key(entity, anchor);
1008 let adjudicate_now = window.is_none_or(|w| w.contains(&ck));
1009 if adjudicate_now {
1010 if let Some(f) = adjudicate_anchor(key, facet, entity, anchor, *state, created_at) {
1011 out.push(f);
1012 }
1013 } else {
1014 out.push(Finding {
1018 key: key.clone(),
1019 facet: facet.to_string(),
1020 target: FindingTarget::Anchor {
1021 entity: entity.clone(),
1022 artifact: anchor.artifact.clone(),
1023 },
1024 class: FindingClass::QueuedForAdjudication,
1025 detail: format!(
1026 "adjudication of '{}' deferred (per-run adjudication cap reached); queued",
1027 anchor.artifact
1028 ),
1029 created_at: created_at.to_string(),
1030 });
1031 }
1032 }
1033 out
1034}
1035
1036fn target_key(target: &FindingTarget) -> String {
1040 match target {
1041 FindingTarget::Anchor { entity, artifact } => format!("a\u{1f}{entity}\u{1f}{artifact}"),
1042 FindingTarget::Artifact { artifact } => format!("f\u{1f}{artifact}"),
1043 }
1044}
1045
1046struct PassObservation {
1049 anchors_observed: BTreeSet<String>,
1052 anchors_existing: BTreeSet<String>,
1055 files_observed: BTreeSet<String>,
1058 s_d: BTreeSet<String>,
1060}
1061
1062fn merge_with_prior(
1087 mut fresh: Vec<Finding>,
1088 prior: &[Finding],
1089 obs: &PassObservation,
1090 covered_now: impl Fn(&str) -> bool,
1091) -> Vec<Finding> {
1092 let fresh_idx: BTreeMap<String, usize> = fresh
1093 .iter()
1094 .enumerate()
1095 .map(|(i, f)| (target_key(&f.target), i))
1096 .collect();
1097 let mut carried: Vec<Finding> = Vec::new();
1098 for f in prior {
1099 let tkey = target_key(&f.target);
1100 let observed = match &f.target {
1101 FindingTarget::Anchor { .. } => obs.anchors_observed.contains(&tkey),
1102 FindingTarget::Artifact { artifact } => obs.files_observed.contains(artifact),
1103 };
1104 if observed {
1105 if matches!(f.class, FindingClass::Drifted | FindingClass::Wrong)
1107 && let Some(&i) = fresh_idx.get(&tkey)
1108 && fresh[i].class == FindingClass::QueuedForAdjudication
1109 {
1110 fresh[i] = f.clone();
1111 }
1112 continue;
1113 }
1114 if fresh_idx.contains_key(&tkey) {
1115 continue; }
1117 let still_open = match &f.target {
1118 FindingTarget::Anchor { .. } => obs.anchors_existing.contains(&tkey),
1119 FindingTarget::Artifact { artifact } => {
1120 obs.s_d.contains(artifact) && !covered_now(artifact)
1121 }
1122 };
1123 if still_open {
1124 carried.push(f.clone());
1125 }
1126 }
1127 fresh.extend(carried);
1128 fresh
1129}
1130
1131pub fn verify_binding(
1145 engine: &Engine,
1146 workspace_root: &Path,
1147 binding: &Binding,
1148 resolved: &ResolvedIngest,
1149) -> Result<VerifyOutcome, FindingsError> {
1150 run_verify(engine, workspace_root, binding, resolved, false)
1151}
1152
1153pub fn verify_binding_full(
1169 engine: &Engine,
1170 workspace_root: &Path,
1171 binding: &Binding,
1172 resolved: &ResolvedIngest,
1173) -> Result<VerifyOutcome, FindingsError> {
1174 run_verify(engine, workspace_root, binding, resolved, true)
1175}
1176
1177fn run_verify(
1181 engine: &Engine,
1182 workspace_root: &Path,
1183 binding: &Binding,
1184 resolved: &ResolvedIngest,
1185 full: bool,
1186) -> Result<VerifyOutcome, FindingsError> {
1187 let binding_id = resolved.name.clone();
1188 let (mem, name) = split_binding_id(&binding_id)?;
1189
1190 if full {
1194 for source in &resolved.sources {
1195 if let ResolvedSource::Primary(p) = source {
1196 let medium_type = medium_type_wire(p.medium_type);
1197 if !medium_capabilities(p.medium_type).enumerable {
1198 return Err(FindingsError::FullWalkNonEnumerable(FullResyncRefusal {
1199 facet: p.name.clone(),
1200 medium_type: medium_type.clone(),
1201 reason: format!(
1202 "medium type '{medium_type}' is non-enumerable — a full-enumeration \
1203 walk cannot cover it; the full measurement refuses rather than \
1204 render a report with fabricated completeness"
1205 ),
1206 }));
1207 }
1208 }
1209 }
1210 }
1211
1212 for source in &resolved.sources {
1218 if let ResolvedSource::Primary(p) = source
1219 && matches!(
1220 p.medium_type,
1221 crate::pipeline::MediumType::Codebase
1222 | crate::pipeline::MediumType::Filesystem
1223 | crate::pipeline::MediumType::Git
1224 )
1225 {
1226 let base = super::resolve::source_base_path(p, workspace_root);
1227 if !base.exists() {
1228 return Err(FindingsError::SourceUnreachable {
1229 source_name: p.name.clone(),
1230 path: base.display().to_string(),
1231 });
1232 }
1233 }
1234 }
1235
1236 let facet_heads = current_facet_heads(engine, workspace_root, resolved);
1240 let key = FindingKey {
1241 binding_hash: binding_hash_of(binding, resolved),
1242 source_head: join_facet_heads(&facet_heads),
1243 };
1244 let now = now_seconds();
1245 let facet = source_facet_label(resolved);
1246 let cache_root = workspace_root.join(".memstead.cache").join("ingest");
1247
1248 let verify_op = binding.operations.verify.as_ref();
1254 let cap = verify_op.map_or(DEFAULT_ADJUDICATION_CAP, |v| v.adjudication_cap);
1255 let full_resync_every = verify_op.map_or(DEFAULT_FULL_RESYNC_EVERY, |v| v.full_resync_every);
1256 let sample_batch = verify_op
1257 .map_or(resolved.batch_size, |v| v.batch_size)
1258 .max(1) as usize;
1259
1260 let run_count = bump_verify_runs(&cache_root, &binding_id);
1266 let facet_enum: Vec<FacetEnumerability> = resolved
1267 .sources
1268 .iter()
1269 .filter_map(|s| match s {
1270 ResolvedSource::Primary(p) => Some(FacetEnumerability {
1271 facet: p.name.clone(),
1272 medium_type: medium_type_wire(p.medium_type),
1273 enumerable: medium_capabilities(p.medium_type).enumerable,
1274 }),
1275 ResolvedSource::Reference { .. } => None,
1276 })
1277 .collect();
1278 let full_resync = if full {
1279 FullResyncDecision::Forced {
1280 walked_facets: facet_enum.iter().map(|f| f.facet.clone()).collect(),
1281 }
1282 } else {
1283 schedule_full_resync(full_resync_every, run_count, &facet_enum)
1284 };
1285
1286 let mut findings: Vec<Finding> = Vec::new();
1287
1288 let mut existence: Vec<(String, Anchor, AnchorState)> = Vec::new();
1295 let mut candidates: Vec<(String, Anchor, AnchorState)> = Vec::new();
1296 let mut hash_backfill: Vec<ObservedArtifactHash> = Vec::new();
1305 let mut backfill_seen: BTreeSet<(String, String)> = BTreeSet::new();
1306 let mut anchors_existing: BTreeSet<String> = BTreeSet::new();
1309 let mut anchors_observed: BTreeSet<String> = BTreeSet::new();
1310 for (eid, resolved_anchor) in engine.mem_anchors_resolved(&resolved.destination_mem) {
1311 let tkey = target_key(&FindingTarget::Anchor {
1312 entity: eid.as_ref().to_string(),
1313 artifact: resolved_anchor.anchor.artifact.clone(),
1314 });
1315 anchors_existing.insert(tkey.clone());
1316 let Some(state) = resolved_anchor.state else {
1317 continue;
1318 };
1319 anchors_observed.insert(tkey);
1320 let observed_hash = resolved_anchor.observed_hash;
1321 let anchor = resolved_anchor.anchor;
1322 match state {
1323 AnchorState::Resolves => {}
1324 AnchorState::Orphaned => existence.push((eid.as_ref().to_string(), anchor, state)),
1325 AnchorState::Drifted | AnchorState::Recheck => {
1326 if !anchor.class.is_hash_bearing() {
1329 continue;
1330 }
1331 if anchor.hash.is_none()
1332 && let Some(hash) = observed_hash
1333 {
1334 if backfill_seen.insert((eid.as_ref().to_string(), anchor.artifact.clone())) {
1337 hash_backfill.push(ObservedArtifactHash {
1338 entity: eid.as_ref().to_string(),
1339 artifact: anchor.artifact.clone(),
1340 hash,
1341 });
1342 }
1343 continue;
1344 }
1345 candidates.push((eid.as_ref().to_string(), anchor, state));
1346 }
1347 }
1348 }
1349 for (entity, anchor, state) in &existence {
1350 if let Some(f) = adjudicate_anchor(&key, &facet, entity, anchor, *state, &now) {
1351 findings.push(f);
1352 }
1353 }
1354 let window: Option<BTreeSet<String>> = if full || cap == 0 {
1360 None
1361 } else {
1362 let mut keys: Vec<String> = candidates
1363 .iter()
1364 .map(|(e, a, _)| candidate_key(e, a))
1365 .collect();
1366 keys.sort();
1367 keys.dedup();
1368 next_rotation_batch(
1369 &cache_root,
1370 &binding_id,
1371 ROTATION_ANCHOR_ADJUDICATION,
1372 keys,
1373 cap as usize,
1374 )
1375 .map(|b| b.files.into_iter().collect())
1376 };
1377 findings.extend(adjudicate_candidates(
1378 &key,
1379 &facet,
1380 &candidates,
1381 window.as_ref(),
1382 &now,
1383 ));
1384
1385 let sample_files: Vec<String> = if full_resync.is_full_walk() {
1393 let mut all: Vec<String> = Vec::new();
1394 for source in &resolved.sources {
1395 if let ResolvedSource::Primary(p) = source
1396 && medium_capabilities(p.medium_type).enumerable
1397 {
1398 all.extend(enumerate_facet_files(
1399 p,
1400 &resolved.deny_paths,
1401 workspace_root,
1402 ));
1403 }
1404 }
1405 all.sort();
1406 all.dedup();
1407 all
1408 } else {
1409 next_batch(resolved, workspace_root, &cache_root, sample_batch)
1410 .map(|b| b.files)
1411 .unwrap_or_default()
1412 };
1413 let covered_now = |artifact: &str| {
1414 engine
1415 .anchors_referencing_artifact(artifact)
1416 .iter()
1417 .any(|(eid, _)| eid.mem() == resolved.destination_mem.as_str())
1418 };
1419 for file in &sample_files {
1420 if !covered_now(file) {
1421 findings.push(Finding {
1422 key: key.clone(),
1423 facet: facet.clone(),
1424 target: FindingTarget::Artifact {
1425 artifact: file.clone(),
1426 },
1427 class: FindingClass::Uncovered,
1428 detail: "source artifact in scope has no anchor in the destination mem".to_string(),
1429 created_at: now.clone(),
1430 });
1431 }
1432 }
1433
1434 let mut store = read_findings_store(workspace_root, &mem, &name)
1441 .map_err(FindingsError::Store)?
1442 .unwrap_or_else(|| FindingsStore {
1443 binding: binding_id.clone(),
1444 ..Default::default()
1445 });
1446 let mut s_d: BTreeSet<String> = BTreeSet::new();
1447 for source in &resolved.sources {
1448 if let ResolvedSource::Primary(p) = source
1449 && medium_capabilities(p.medium_type).enumerable
1450 {
1451 s_d.extend(enumerate_facet_files(
1452 p,
1453 &resolved.deny_paths,
1454 workspace_root,
1455 ));
1456 }
1457 }
1458 let obs = PassObservation {
1459 anchors_observed,
1460 anchors_existing,
1461 files_observed: sample_files.into_iter().collect(),
1462 s_d,
1463 };
1464 let prior = store.current(&key).to_vec();
1465 let findings = merge_with_prior(findings, &prior, &obs, covered_now);
1466
1467 let backlog = findings
1468 .iter()
1469 .filter(|f| f.class == FindingClass::QueuedForAdjudication)
1470 .count();
1471
1472 let recorded = findings.len();
1475 store.record(key.clone(), now, findings);
1476 let superseded = store.superseded(&key).len();
1477 write_findings_store(workspace_root, &mem, &name, &store).map_err(FindingsError::Store)?;
1478
1479 Ok(VerifyOutcome {
1480 binding: binding_id,
1481 key,
1482 recorded,
1483 superseded,
1484 backlog,
1485 full_resync,
1486 facet_heads,
1487 hash_backfill,
1488 })
1489}
1490
1491fn medium_type_wire(t: crate::pipeline::MediumType) -> String {
1494 serde_json::to_value(t)
1495 .ok()
1496 .and_then(|v| v.as_str().map(str::to_string))
1497 .unwrap_or_default()
1498}
1499
1500#[cfg(test)]
1501mod tests {
1502 use super::*;
1503 use crate::anchor::{Anchor, AnchorGrain, AnchorHashStability, AnchorProvenanceClass};
1504
1505 fn key(hash: &str, head: &str) -> FindingKey {
1506 FindingKey {
1507 binding_hash: hash.to_string(),
1508 source_head: head.to_string(),
1509 }
1510 }
1511
1512 fn anchor(class: AnchorProvenanceClass) -> Anchor {
1513 Anchor {
1514 artifact: "src/lib.rs".to_string(),
1515 grain: AnchorGrain::File,
1516 class,
1517 at_version: None,
1518 hash: if class.is_hash_bearing() {
1519 Some("h1".to_string())
1520 } else {
1521 None
1522 },
1523 hash_stability: AnchorHashStability::Stable,
1524 derived_from: Vec::new(),
1525 binding: None,
1526 source: None,
1527 }
1528 }
1529
1530 #[test]
1533 fn store_round_trips_on_disk_and_delete_is_idempotent() {
1534 let tmp = tempfile::tempdir().unwrap();
1535 let root = tmp.path();
1536 assert!(
1537 read_findings_store(root, "engine", "graph")
1538 .unwrap()
1539 .is_none()
1540 );
1541
1542 let mut store = FindingsStore {
1543 binding: "engine/graph".to_string(),
1544 ..Default::default()
1545 };
1546 let k = key("hashA", "head1");
1547 store.record(
1548 k.clone(),
1549 "1".to_string(),
1550 vec![Finding {
1551 key: k.clone(),
1552 facet: "src".to_string(),
1553 target: FindingTarget::Artifact {
1554 artifact: "src/a.rs".to_string(),
1555 },
1556 class: FindingClass::Uncovered,
1557 detail: "d".to_string(),
1558 created_at: "1".to_string(),
1559 }],
1560 );
1561 write_findings_store(root, "engine", "graph", &store).unwrap();
1562 assert!(findings_store_path(root, "engine", "graph").exists());
1563
1564 let ignore = root
1567 .join(WORKSPACE_STORE_DIR)
1568 .join(STATE_DIR)
1569 .join(FINDINGS_DIR)
1570 .join(".gitignore");
1571 assert_eq!(std::fs::read_to_string(&ignore).unwrap(), "*\n");
1572
1573 let back = read_findings_store(root, "engine", "graph")
1575 .unwrap()
1576 .unwrap();
1577 assert_eq!(back, store);
1578 assert_eq!(back.current(&k).len(), 1);
1579
1580 delete_findings_store(root, "engine", "graph").unwrap();
1581 assert!(
1582 read_findings_store(root, "engine", "graph")
1583 .unwrap()
1584 .is_none()
1585 );
1586 delete_findings_store(root, "engine", "graph").unwrap();
1588 }
1589
1590 #[test]
1593 fn changed_binding_hash_supersedes_prior_findings() {
1594 let mut store = FindingsStore::default();
1595 let old = key("hashOLD", "head1");
1596 let new = key("hashNEW", "head1");
1597 let f_old = Finding {
1598 key: old.clone(),
1599 facet: "src".to_string(),
1600 target: FindingTarget::Artifact {
1601 artifact: "src/old.rs".to_string(),
1602 },
1603 class: FindingClass::Uncovered,
1604 detail: "old".to_string(),
1605 created_at: "1".to_string(),
1606 };
1607 store.record(old.clone(), "1".to_string(), vec![f_old.clone()]);
1608
1609 store.record(new.clone(), "2".to_string(), Vec::new());
1611 assert!(store.current(&new).is_empty(), "new key has its own view");
1612 let superseded = store.superseded(&new);
1613 assert_eq!(superseded.len(), 1, "old batch is segregated as superseded");
1614 assert_eq!(superseded[0], &f_old);
1615 assert!(!store.current(&new).contains(&f_old));
1617 }
1618
1619 #[test]
1626 fn moved_source_head_keeps_findings_current_until_superseded() {
1627 let mut store = FindingsStore::default();
1628 let before = key("hashA", "head1");
1629 let after = key("hashA", "head2");
1630 let f = Finding {
1631 key: before.clone(),
1632 facet: "src".to_string(),
1633 target: FindingTarget::Anchor {
1634 entity: "engine--e".to_string(),
1635 artifact: "src/x.rs".to_string(),
1636 },
1637 class: FindingClass::UnresolvableAnchor,
1638 detail: "gone".to_string(),
1639 created_at: "1".to_string(),
1640 };
1641 store.record(before.clone(), "1".to_string(), vec![f.clone()]);
1642
1643 assert_eq!(store.current(&after), std::slice::from_ref(&f));
1646 assert_eq!(store.current(&after)[0].key.source_head, "head1");
1647 assert!(store.superseded(&after).is_empty());
1648
1649 store.record(after.clone(), "2".to_string(), Vec::new());
1652 assert!(store.current(&after).is_empty());
1653 assert!(store.current(&before).is_empty(), "at the old head too");
1654 assert_eq!(store.batches.len(), 1, "one batch per hash(D)");
1655 }
1656
1657 #[test]
1665 fn legacy_per_head_store_loads_and_presents_head_agnostically() {
1666 let tmp = tempfile::tempdir().unwrap();
1667 let root = tmp.path();
1668 let path = findings_store_path(root, "engine", "graph");
1669 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1670 std::fs::write(
1675 &path,
1676 r#"{
1677 "binding": "engine/graph",
1678 "batches": [
1679 {
1680 "key": { "binding_hash": "hashOLD", "source_head": "src=aaa" },
1681 "recorded_at": "100",
1682 "findings": [
1683 {
1684 "key": { "binding_hash": "hashOLD", "source_head": "src=aaa" },
1685 "facet": "src",
1686 "target": { "kind": "artifact", "artifact": "src/old.rs" },
1687 "class": "uncovered",
1688 "detail": "old declaration",
1689 "created_at": "100"
1690 }
1691 ]
1692 },
1693 {
1694 "key": { "binding_hash": "hashCUR", "source_head": "src=bbb" },
1695 "recorded_at": "200",
1696 "findings": [
1697 {
1698 "key": { "binding_hash": "hashCUR", "source_head": "src=bbb" },
1699 "facet": "src",
1700 "target": { "kind": "artifact", "artifact": "src/resolved-at-ccc.rs" },
1701 "class": "uncovered",
1702 "detail": "was open at bbb, absent from the ccc batch",
1703 "created_at": "200"
1704 }
1705 ]
1706 },
1707 {
1708 "key": { "binding_hash": "hashCUR", "source_head": "src=ccc" },
1709 "recorded_at": "300",
1710 "findings": [
1711 {
1712 "key": { "binding_hash": "hashCUR", "source_head": "src=ccc" },
1713 "facet": "src",
1714 "target": { "kind": "anchor", "entity": "engine--e", "artifact": "src/x.rs" },
1715 "class": "unresolvable-anchor",
1716 "detail": "gone",
1717 "created_at": "300"
1718 }
1719 ]
1720 }
1721 ]
1722 }"#,
1723 )
1724 .unwrap();
1725
1726 let mut store = read_findings_store(root, "engine", "graph")
1727 .unwrap()
1728 .expect("the legacy on-disk format loads as-is");
1729 assert_eq!(store.binding, "engine/graph");
1730 assert_eq!(store.batches.len(), 3, "loaded without loss");
1731
1732 let now = key("hashCUR", "src=ddd");
1735 let current = store.current(&now);
1736 assert_eq!(current.len(), 1);
1737 assert_eq!(current[0].detail, "gone");
1738 assert_eq!(
1739 current[0].key.source_head, "src=ccc",
1740 "the finding keeps the head it was observed at"
1741 );
1742 let superseded = store.superseded(&now);
1745 assert_eq!(superseded.len(), 2);
1746 assert!(
1747 !current.iter().any(|f| f.detail.contains("was open at bbb")),
1748 "the older same-hash batch was superseded at write time and is not resurrected"
1749 );
1750
1751 store.record(now.clone(), "400".to_string(), Vec::new());
1754 assert_eq!(store.batches.len(), 2, "hashCUR collapsed, hashOLD kept");
1755 assert_eq!(store.superseded(&now).len(), 1);
1756 }
1757
1758 #[test]
1763 fn merge_carries_unobserved_open_findings_and_closes_departed() {
1764 let k_old = key("h", "head1");
1765 let mk_artifact = |artifact: &str, detail: &str| Finding {
1766 key: k_old.clone(),
1767 facet: "src".to_string(),
1768 target: FindingTarget::Artifact {
1769 artifact: artifact.to_string(),
1770 },
1771 class: FindingClass::Uncovered,
1772 detail: detail.to_string(),
1773 created_at: "1".to_string(),
1774 };
1775 let anchor_finding = Finding {
1776 key: k_old.clone(),
1777 facet: "src".to_string(),
1778 target: FindingTarget::Anchor {
1779 entity: "engine--gone".to_string(),
1780 artifact: "src/gone.rs".to_string(),
1781 },
1782 class: FindingClass::UnresolvableAnchor,
1783 detail: "anchor since removed from the mem".to_string(),
1784 created_at: "1".to_string(),
1785 };
1786 let prior = vec![
1787 mk_artifact("src/unsampled.rs", "still open, not in this window"),
1788 mk_artifact("src/departed.rs", "left S(D)"),
1789 mk_artifact("src/now-covered.rs", "gained an anchor since"),
1790 mk_artifact("src/observed-clean.rs", "re-sampled and now covered"),
1791 anchor_finding,
1792 ];
1793 let obs = PassObservation {
1794 anchors_observed: BTreeSet::new(),
1795 anchors_existing: BTreeSet::new(), files_observed: ["src/observed-clean.rs".to_string()].into(),
1797 s_d: [
1798 "src/unsampled.rs".to_string(),
1799 "src/now-covered.rs".to_string(),
1800 "src/observed-clean.rs".to_string(),
1801 ]
1802 .into(),
1803 };
1804 let merged = merge_with_prior(Vec::new(), &prior, &obs, |artifact| {
1805 artifact == "src/now-covered.rs" || artifact == "src/observed-clean.rs"
1806 });
1807 assert_eq!(merged.len(), 1, "only the still-open unsampled one carries");
1808 assert_eq!(
1809 merged[0].target,
1810 FindingTarget::Artifact {
1811 artifact: "src/unsampled.rs".to_string()
1812 }
1813 );
1814 assert_eq!(
1815 merged[0].key.source_head, "head1",
1816 "a carried finding keeps the head it was observed at"
1817 );
1818 }
1819
1820 #[test]
1825 fn merge_deferral_never_downgrades_prior_adjudication() {
1826 let k_old = key("h", "head1");
1827 let k_new = key("h", "head2");
1828 let target = FindingTarget::Anchor {
1829 entity: "engine--e".to_string(),
1830 artifact: "src/x.rs".to_string(),
1831 };
1832 let prior_drifted = Finding {
1833 key: k_old.clone(),
1834 facet: "src".to_string(),
1835 target: target.clone(),
1836 class: FindingClass::Drifted,
1837 detail: "adjudicated drifted at head1".to_string(),
1838 created_at: "1".to_string(),
1839 };
1840 let fresh_queued = Finding {
1841 key: k_new.clone(),
1842 facet: "src".to_string(),
1843 target: target.clone(),
1844 class: FindingClass::QueuedForAdjudication,
1845 detail: "deferred by the cap this run".to_string(),
1846 created_at: "2".to_string(),
1847 };
1848 let obs = PassObservation {
1849 anchors_observed: [target_key(&target)].into(),
1850 anchors_existing: [target_key(&target)].into(),
1851 files_observed: BTreeSet::new(),
1852 s_d: BTreeSet::new(),
1853 };
1854 let merged = merge_with_prior(
1855 vec![fresh_queued],
1856 std::slice::from_ref(&prior_drifted),
1857 &obs,
1858 |_| true,
1859 );
1860 assert_eq!(merged.len(), 1);
1861 assert_eq!(
1862 merged[0].class,
1863 FindingClass::Drifted,
1864 "the prior verdict stands over a deferral"
1865 );
1866 assert_eq!(merged[0].key.source_head, "head1");
1867 }
1868
1869 #[test]
1872 fn informed_by_anchor_never_drifts() {
1873 let k = key("h", "s");
1874 for class in [
1875 AnchorProvenanceClass::InformedBy,
1876 AnchorProvenanceClass::Authored,
1877 ] {
1878 let a = anchor(class);
1879 assert!(
1880 adjudicate_anchor(&k, "f", "engine--e", &a, AnchorState::Drifted, "1").is_none(),
1881 "{class:?} must not produce a drift finding"
1882 );
1883 assert!(
1884 adjudicate_anchor(&k, "f", "engine--e", &a, AnchorState::Recheck, "1").is_none(),
1885 "{class:?} must not produce a queued finding"
1886 );
1887 }
1888 }
1889
1890 #[test]
1893 fn hash_bearing_drifts_and_orphan_is_class_independent() {
1894 let k = key("h", "s");
1895 let anchored = anchor(AnchorProvenanceClass::Anchored);
1896 let drifted =
1897 adjudicate_anchor(&k, "f", "engine--e", &anchored, AnchorState::Drifted, "1").unwrap();
1898 assert_eq!(drifted.class, FindingClass::Drifted);
1899 assert_eq!(drifted.key, k, "the finding carries its recording key (A2)");
1900
1901 let queued =
1902 adjudicate_anchor(&k, "f", "engine--e", &anchored, AnchorState::Recheck, "1").unwrap();
1903 assert_eq!(queued.class, FindingClass::QueuedForAdjudication);
1904
1905 let informed = anchor(AnchorProvenanceClass::InformedBy);
1907 let orphan =
1908 adjudicate_anchor(&k, "f", "engine--e", &informed, AnchorState::Orphaned, "1").unwrap();
1909 assert_eq!(orphan.class, FindingClass::UnresolvableAnchor);
1910
1911 assert!(
1913 adjudicate_anchor(&k, "f", "engine--e", &anchored, AnchorState::Resolves, "1")
1914 .is_none()
1915 );
1916 }
1917
1918 #[test]
1920 fn finding_class_wire_round_trips() {
1921 for w in FindingClass::WIRE_VALUES {
1922 let c = FindingClass::from_wire(w).expect("known wire value");
1923 assert_eq!(c.as_wire(), *w);
1924 }
1925 assert!(FindingClass::from_wire("nonsense").is_none());
1926 }
1927
1928 #[test]
1930 fn malformed_binding_id_refuses() {
1931 assert!(matches!(
1932 split_binding_id("../escape"),
1933 Err(FindingsError::MalformedId(_))
1934 ));
1935 assert!(matches!(
1936 split_binding_id("no-slash"),
1937 Err(FindingsError::MalformedId(_))
1938 ));
1939 assert_eq!(
1940 split_binding_id("engine/graph").unwrap(),
1941 ("engine".to_string(), "graph".to_string())
1942 );
1943 }
1944
1945 use crate::anchor::AnchorSidecar;
1948 use crate::binding::{
1949 BINDING_VERSION, BuildMode, BuildOperation, CoverageSemantics, DEFAULT_ADJUDICATION_CAP,
1950 DEFAULT_FULL_RESYNC_EVERY, Operations, VerifyOperation,
1951 };
1952 use crate::ingest::resolve::resolve_binding_run;
1953 use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode};
1954 use crate::pipeline_store::{load_pipeline_configs, write_binding};
1955 use crate::workspace::{
1956 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
1957 };
1958 use crate::workspace_store::WorkspaceStoreAdapter;
1959
1960 #[test]
1968 fn verify_persists_findings_readable_fresh() {
1969 let tmp = tempfile::tempdir().unwrap();
1970 let root = tmp.path();
1971 let mem_dir = root.join("mem");
1972 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1973 std::fs::write(
1974 mem_dir.join(".memstead").join("config.json"),
1975 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
1976 )
1977 .unwrap();
1978
1979 std::fs::create_dir_all(root.join(".memstead")).unwrap();
1982 std::fs::write(
1983 root.join(".memstead").join("workspace.toml"),
1984 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1985 )
1986 .unwrap();
1987 let mount = Mount {
1988 mem: "engine".to_string(),
1989 schema: Some("default@1.0.0".parse().unwrap()),
1990 storage: MountStorage::Folder {
1991 path: mem_dir.clone(),
1992 },
1993 capability: MountCapability::Write,
1994 lifecycle: MountLifecycle::Eager,
1995 cross_linkable: false,
1996 migration_target: None,
1997 };
1998 crate::FileWorkspaceStore::new()
1999 .save_state(
2000 root,
2001 &Workspace {
2002 mounts: vec![mount],
2003 settings: WorkspaceSettings::default(),
2004 },
2005 )
2006 .unwrap();
2007
2008 let out = std::process::Command::new("git")
2012 .args(["init", "-q"])
2013 .current_dir(root)
2014 .output()
2015 .unwrap();
2016 assert!(out.status.success());
2017 std::fs::create_dir_all(root.join("src")).unwrap();
2018 std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
2019 std::fs::write(root.join("src").join("uncovered.rs"), "fn b() {}\n").unwrap();
2020
2021 let mk = |artifact: &str, class: AnchorProvenanceClass| Anchor {
2024 artifact: artifact.to_string(),
2025 grain: AnchorGrain::File,
2026 class,
2027 at_version: None,
2028 hash: class.is_hash_bearing().then(|| "recorded".to_string()),
2029 hash_stability: AnchorHashStability::Stable,
2030 derived_from: Vec::new(),
2031 binding: None,
2032 source: None,
2033 };
2034 let mut sidecar = AnchorSidecar::default();
2035 sidecar.set(
2036 "engine--e",
2037 vec![
2038 mk("src/present.rs", AnchorProvenanceClass::Anchored), mk("src/gone.rs", AnchorProvenanceClass::Anchored), mk("src/present.rs", AnchorProvenanceClass::InformedBy), ],
2042 );
2043 std::fs::write(
2044 mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
2045 sidecar.to_bytes(),
2046 )
2047 .unwrap();
2048
2049 write_binding(
2051 root,
2052 "engine",
2053 "graph",
2054 &Binding {
2055 version: BINDING_VERSION,
2056 intent: None,
2057 sources: vec![crate::pipeline::Source {
2058 name: "graph".to_string(),
2059 medium_type: MediumType::Codebase,
2060 pointer: String::new(),
2061 change_detection: Some("git".to_string()),
2062 scope: vec![PatternEntry {
2063 path: "src/**/*.rs".to_string(),
2064 mode: PatternMode::Allow,
2065 }],
2066 engagement: None,
2067 preparation: None,
2068 }],
2069 reference_mems: Vec::new(),
2070 destination_mem: "engine".to_string(),
2071 deny_paths: Vec::new(),
2072 coverage_semantics: None,
2073 rules: None,
2074 prune: None,
2075 operations: Operations {
2076 build: Some(BuildOperation {
2077 mode: BuildMode::Discovery,
2078 trigger: IngestTrigger::Loop,
2079 batch_size: 20,
2080 post_actions: None,
2081 }),
2082 sync: None,
2083 verify: Some(VerifyOperation {
2084 trigger: IngestTrigger::Manual,
2085 batch_size: 20,
2086 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2087 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2088 }),
2089 },
2090 },
2091 )
2092 .unwrap();
2093
2094 let engine = Engine::from_workspace_root(root).unwrap();
2095
2096 let configs = load_pipeline_configs(root).unwrap();
2097 let binding = &configs.bindings[0].config;
2098 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
2099
2100 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2102 assert!(
2103 outcome.recorded >= 3,
2104 "orphan + drifted + uncovered at least"
2105 );
2106 assert_eq!(outcome.superseded, 0, "no prior key yet");
2107 assert_eq!(
2108 outcome.backlog, 0,
2109 "the mismatching hash adjudicated deterministically — nothing queued"
2110 );
2111 assert!(
2112 outcome.hash_backfill.is_empty(),
2113 "every hash-bearing anchor already carries a recorded hash — nothing to backfill"
2114 );
2115
2116 let store = read_findings_store(root, "engine", "graph")
2118 .unwrap()
2119 .unwrap();
2120 let current = store.current(&outcome.key);
2121 assert_eq!(current.len(), outcome.recorded);
2122
2123 let has = |c: FindingClass, art: &str| {
2124 current.iter().any(|f| {
2125 f.class == c
2126 && match &f.target {
2127 FindingTarget::Anchor { artifact, .. } => artifact == art,
2128 FindingTarget::Artifact { artifact } => artifact == art,
2129 }
2130 })
2131 };
2132 assert!(has(FindingClass::UnresolvableAnchor, "src/gone.rs"));
2133 assert!(
2134 has(FindingClass::Drifted, "src/present.rs"),
2135 "recorded-hash mismatch on a stable medium adjudicates drifted deterministically"
2136 );
2137 assert!(has(FindingClass::Uncovered, "src/uncovered.rs"));
2138 assert!(
2142 !current
2143 .iter()
2144 .any(|f| f.class == FindingClass::QueuedForAdjudication
2145 || f.class == FindingClass::Wrong),
2146 "deterministic adjudication leaves nothing queued"
2147 );
2148 assert!(!has(FindingClass::Uncovered, "src/present.rs"));
2150 }
2151
2152 #[test]
2158 fn finding_recorded_at_old_head_presents_in_brief_at_new_head() {
2159 use crate::ingest::render::render_sync_brief_for;
2160
2161 let tmp = tempfile::tempdir().unwrap();
2162 let root = tmp.path();
2163 let mem_dir = root.join("mem");
2164 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2165 std::fs::write(
2166 mem_dir.join(".memstead").join("config.json"),
2167 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2168 )
2169 .unwrap();
2170 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2171 std::fs::write(
2172 root.join(".memstead").join("workspace.toml"),
2173 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2174 )
2175 .unwrap();
2176 let mount = Mount {
2177 mem: "engine".to_string(),
2178 schema: Some("default@1.0.0".parse().unwrap()),
2179 storage: MountStorage::Folder {
2180 path: mem_dir.clone(),
2181 },
2182 capability: MountCapability::Write,
2183 lifecycle: MountLifecycle::Eager,
2184 cross_linkable: false,
2185 migration_target: None,
2186 };
2187 crate::FileWorkspaceStore::new()
2188 .save_state(
2189 root,
2190 &Workspace {
2191 mounts: vec![mount],
2192 settings: WorkspaceSettings::default(),
2193 },
2194 )
2195 .unwrap();
2196
2197 let git = |args: &[&str]| {
2199 let out = std::process::Command::new("git")
2200 .args(args)
2201 .current_dir(root)
2202 .env("GIT_AUTHOR_NAME", "t")
2203 .env("GIT_AUTHOR_EMAIL", "t@t")
2204 .env("GIT_COMMITTER_NAME", "t")
2205 .env("GIT_COMMITTER_EMAIL", "t@t")
2206 .output()
2207 .unwrap();
2208 assert!(
2209 out.status.success(),
2210 "git {args:?}: {}",
2211 String::from_utf8_lossy(&out.stderr)
2212 );
2213 };
2214 git(&["init", "-q"]);
2215 std::fs::create_dir_all(root.join("src")).unwrap();
2216 std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
2217 git(&["add", "-A"]);
2218 git(&["commit", "-qm", "head-a"]);
2219
2220 let mk = |artifact: &str| Anchor {
2223 artifact: artifact.to_string(),
2224 grain: AnchorGrain::File,
2225 class: AnchorProvenanceClass::InformedBy,
2226 at_version: None,
2227 hash: None,
2228 hash_stability: AnchorHashStability::Stable,
2229 derived_from: Vec::new(),
2230 binding: None,
2231 source: None,
2232 };
2233 let mut sidecar = AnchorSidecar::default();
2234 sidecar.set("engine--e", vec![mk("src/present.rs"), mk("src/gone.rs")]);
2235 std::fs::write(
2236 mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
2237 sidecar.to_bytes(),
2238 )
2239 .unwrap();
2240
2241 write_binding(
2242 root,
2243 "engine",
2244 "graph",
2245 &Binding {
2246 version: BINDING_VERSION,
2247 intent: None,
2248 sources: vec![crate::pipeline::Source {
2249 name: "graph".to_string(),
2250 medium_type: MediumType::Codebase,
2251 pointer: String::new(),
2252 change_detection: Some("git".to_string()),
2253 scope: vec![PatternEntry {
2254 path: "src/**/*.rs".to_string(),
2255 mode: PatternMode::Allow,
2256 }],
2257 engagement: None,
2258 preparation: None,
2259 }],
2260 reference_mems: Vec::new(),
2261 destination_mem: "engine".to_string(),
2262 deny_paths: Vec::new(),
2263 coverage_semantics: None,
2264 rules: None,
2265 prune: None,
2266 operations: Operations {
2267 build: None,
2268 sync: Some(crate::binding::SyncOperation {
2269 trigger: IngestTrigger::Manual,
2270 batch_size: 20,
2271 }),
2272 verify: Some(VerifyOperation {
2273 trigger: IngestTrigger::Manual,
2274 batch_size: 20,
2275 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2276 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2277 }),
2278 },
2279 },
2280 )
2281 .unwrap();
2282
2283 let configs = load_pipeline_configs(root).unwrap();
2285 let binding = &configs.bindings[0].config;
2286 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
2287 let head_a_outcome = {
2288 let engine = Engine::from_workspace_root(root).unwrap();
2289 verify_binding(&engine, root, binding, &resolved).unwrap()
2290 };
2291 assert!(
2292 head_a_outcome.key.source_head.contains("graph="),
2293 "the run observed a facet head"
2294 );
2295
2296 std::fs::write(root.join("src").join("present.rs"), "fn a() {} // more\n").unwrap();
2298 git(&["add", "-A"]);
2299 git(&["commit", "-qm", "head-b"]);
2300
2301 {
2304 let engine = Engine::from_workspace_root(root).unwrap();
2305 let (key_b, findings) = current_findings(&engine, root, binding, &resolved).unwrap();
2306 assert_ne!(
2307 key_b.source_head, head_a_outcome.key.source_head,
2308 "the head really moved"
2309 );
2310 assert_eq!(findings.len(), 1);
2311 assert_eq!(findings[0].class, FindingClass::UnresolvableAnchor);
2312 assert_eq!(
2313 findings[0].key.source_head, head_a_outcome.key.source_head,
2314 "the finding still records the head it was observed at"
2315 );
2316
2317 let brief = render_sync_brief_for(&engine, root, "engine/graph").unwrap();
2318 assert!(brief.contains("## Open findings to repair"));
2319 assert!(brief.contains("src/gone.rs"));
2320 }
2321
2322 std::fs::write(root.join("src").join("gone.rs"), "fn g() {}\n").unwrap();
2325 git(&["add", "-A"]);
2326 git(&["commit", "-qm", "head-c"]);
2327 {
2328 let engine = Engine::from_workspace_root(root).unwrap();
2329 verify_binding(&engine, root, binding, &resolved).unwrap();
2330 }
2331 {
2333 let engine = Engine::from_workspace_root(root).unwrap();
2334 let (_key, findings) = current_findings(&engine, root, binding, &resolved).unwrap();
2335 assert!(
2336 findings
2337 .iter()
2338 .all(|f| f.class != FindingClass::UnresolvableAnchor),
2339 "the resolved orphan finding must not re-present: {findings:?}"
2340 );
2341 }
2342 }
2343
2344 #[test]
2359 fn hashless_anchor_backfills_once_then_drift_adjudicates_deterministically() {
2360 let tmp = tempfile::tempdir().unwrap();
2361 let root = tmp.path();
2362 let mem_dir = root.join("mem");
2363 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2364 std::fs::write(
2365 mem_dir.join(".memstead").join("config.json"),
2366 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2367 )
2368 .unwrap();
2369 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2370 std::fs::write(
2371 root.join(".memstead").join("workspace.toml"),
2372 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2373 )
2374 .unwrap();
2375 let mount = Mount {
2376 mem: "engine".to_string(),
2377 schema: Some("default@1.0.0".parse().unwrap()),
2378 storage: MountStorage::Folder {
2379 path: mem_dir.clone(),
2380 },
2381 capability: MountCapability::Write,
2382 lifecycle: MountLifecycle::Eager,
2383 cross_linkable: false,
2384 migration_target: None,
2385 };
2386 crate::FileWorkspaceStore::new()
2387 .save_state(
2388 root,
2389 &Workspace {
2390 mounts: vec![mount],
2391 settings: WorkspaceSettings::default(),
2392 },
2393 )
2394 .unwrap();
2395
2396 let git = |args: &[&str]| {
2398 let out = std::process::Command::new("git")
2399 .args(args)
2400 .current_dir(root)
2401 .env("GIT_AUTHOR_NAME", "t")
2402 .env("GIT_AUTHOR_EMAIL", "t@t")
2403 .env("GIT_COMMITTER_NAME", "t")
2404 .env("GIT_COMMITTER_EMAIL", "t@t")
2405 .output()
2406 .unwrap();
2407 assert!(
2408 out.status.success(),
2409 "git {args:?}: {}",
2410 String::from_utf8_lossy(&out.stderr)
2411 );
2412 };
2413 git(&["init", "-q"]);
2414 std::fs::create_dir_all(root.join("src")).unwrap();
2415 std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
2416 std::fs::write(root.join("src").join("other.rs"), "fn o() {}\n").unwrap();
2417 git(&["add", "-A"]);
2418 git(&["commit", "-qm", "head-a"]);
2419
2420 let mk = |artifact: &str, class: AnchorProvenanceClass, stab: AnchorHashStability| Anchor {
2424 artifact: artifact.to_string(),
2425 grain: AnchorGrain::File,
2426 class,
2427 at_version: None,
2428 hash: None,
2429 hash_stability: stab,
2430 derived_from: if class == AnchorProvenanceClass::Derived {
2431 vec!["src/present.rs".to_string()]
2432 } else {
2433 Vec::new()
2434 },
2435 binding: None,
2436 source: None,
2437 };
2438 use AnchorHashStability::{Stable, Unstable};
2439 let mut sidecar = AnchorSidecar::default();
2440 sidecar.set(
2441 "engine--e",
2442 vec![
2443 mk("src/present.rs", AnchorProvenanceClass::Anchored, Stable),
2444 mk("src/present.rs", AnchorProvenanceClass::Derived, Stable),
2445 mk("src/other.rs", AnchorProvenanceClass::Anchored, Unstable),
2446 mk("src/present.rs", AnchorProvenanceClass::Authored, Stable),
2447 mk("src/present.rs", AnchorProvenanceClass::InformedBy, Stable),
2448 ],
2449 );
2450 std::fs::write(
2451 mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
2452 sidecar.to_bytes(),
2453 )
2454 .unwrap();
2455
2456 write_binding(
2457 root,
2458 "engine",
2459 "graph",
2460 &Binding {
2461 version: BINDING_VERSION,
2462 intent: None,
2463 sources: vec![crate::pipeline::Source {
2464 name: "graph".to_string(),
2465 medium_type: MediumType::Codebase,
2466 pointer: String::new(),
2467 change_detection: Some("git".to_string()),
2468 scope: vec![PatternEntry {
2469 path: "src/**/*.rs".to_string(),
2470 mode: PatternMode::Allow,
2471 }],
2472 engagement: None,
2473 preparation: None,
2474 }],
2475 reference_mems: Vec::new(),
2476 destination_mem: "engine".to_string(),
2477 deny_paths: Vec::new(),
2478 coverage_semantics: None,
2479 rules: None,
2480 prune: None,
2481 operations: Operations {
2482 build: None,
2483 sync: None,
2484 verify: Some(VerifyOperation {
2485 trigger: IngestTrigger::Manual,
2486 batch_size: 20,
2487 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2488 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2489 }),
2490 },
2491 },
2492 )
2493 .unwrap();
2494
2495 let configs = load_pipeline_configs(root).unwrap();
2496 let binding = &configs.bindings[0].config;
2497 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
2498
2499 {
2501 let mut engine = Engine::from_workspace_root(root).unwrap();
2502 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2503 let mut backfilled: Vec<(&str, &str)> = outcome
2506 .hash_backfill
2507 .iter()
2508 .map(|b| (b.entity.as_str(), b.artifact.as_str()))
2509 .collect();
2510 backfilled.sort();
2511 backfilled.dedup();
2512 assert_eq!(
2513 backfilled,
2514 vec![
2515 ("engine--e", "src/other.rs"),
2516 ("engine--e", "src/present.rs"),
2517 ],
2518 "hash-bearing anchors backfill; authored/informed-by never appear"
2519 );
2520 assert_eq!(
2523 outcome.backlog, 0,
2524 "no recheck queue for backfilled anchors"
2525 );
2526 let store = read_findings_store(root, "engine", "graph")
2527 .unwrap()
2528 .unwrap();
2529 assert!(
2530 store
2531 .current(&outcome.key)
2532 .iter()
2533 .all(|f| !matches!(f.target, FindingTarget::Anchor { .. })),
2534 "no anchor finding on the backfill pass: {:?}",
2535 store.current(&outcome.key)
2536 );
2537
2538 let written =
2540 record_anchor_hash_backfill(&mut engine, "engine", &outcome, None).unwrap();
2541 assert_eq!(
2542 written, 3,
2543 "anchored + derived + unstable-anchored gain hashes"
2544 );
2545 }
2546
2547 let expected_present = crate::anchor::prepared_content_hash(
2550 &std::fs::read(root.join("src").join("present.rs")).unwrap(),
2551 );
2552 {
2553 let sc = AnchorSidecar::from_bytes(
2554 &std::fs::read(mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH)).unwrap(),
2555 )
2556 .unwrap();
2557 for a in sc.get("engine--e") {
2558 if a.class.is_hash_bearing() {
2559 assert!(a.hash.is_some(), "hash-bearing anchor backfilled: {a:?}");
2560 } else {
2561 assert!(a.hash.is_none(), "non-hash class never gains a hash: {a:?}");
2562 }
2563 if a.artifact == "src/present.rs" && a.class.is_hash_bearing() {
2564 assert_eq!(a.hash.as_deref(), Some(expected_present.as_str()));
2565 }
2566 }
2567 }
2568
2569 {
2571 let mut engine = Engine::from_workspace_root(root).unwrap();
2572 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2573 assert!(
2574 outcome.hash_backfill.is_empty(),
2575 "backfill happens once — a re-verify observes an empty worklist"
2576 );
2577 assert_eq!(outcome.backlog, 0, "steady state: nothing re-queues");
2578 let store = read_findings_store(root, "engine", "graph")
2579 .unwrap()
2580 .unwrap();
2581 assert!(
2582 store
2583 .current(&outcome.key)
2584 .iter()
2585 .all(|f| !matches!(f.target, FindingTarget::Anchor { .. })),
2586 "recorded hashes match the source — no anchor finding"
2587 );
2588 let written =
2589 record_anchor_hash_backfill(&mut engine, "engine", &outcome, None).unwrap();
2590 assert_eq!(written, 0, "no write, no commit on the idempotent pass");
2591 }
2592
2593 std::fs::write(
2595 root.join("src").join("present.rs"),
2596 "fn a() { /* changed */ }\n",
2597 )
2598 .unwrap();
2599 std::fs::write(
2600 root.join("src").join("other.rs"),
2601 "fn o() { /* changed */ }\n",
2602 )
2603 .unwrap();
2604 git(&["add", "-A"]);
2605 git(&["commit", "-qm", "head-b"]);
2606
2607 {
2610 let engine = Engine::from_workspace_root(root).unwrap();
2611 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2612 assert!(
2613 outcome.hash_backfill.is_empty(),
2614 "recorded hashes are never overwritten by observation"
2615 );
2616 let store = read_findings_store(root, "engine", "graph")
2617 .unwrap()
2618 .unwrap();
2619 let current = store.current(&outcome.key);
2620 let drifted: Vec<&Finding> = current
2621 .iter()
2622 .filter(|f| f.class == FindingClass::Drifted)
2623 .collect();
2624 assert_eq!(
2627 drifted.len(),
2628 2,
2629 "stable-medium mismatch → drifted: {current:?}"
2630 );
2631 assert!(drifted.iter().all(|f| matches!(
2632 &f.target,
2633 FindingTarget::Anchor { artifact, .. } if artifact == "src/present.rs"
2634 )));
2635 assert!(
2638 current
2639 .iter()
2640 .any(|f| f.class == FindingClass::QueuedForAdjudication
2641 && matches!(
2642 &f.target,
2643 FindingTarget::Anchor { artifact, .. } if artifact == "src/other.rs"
2644 )),
2645 "unstable medium resolves recheck (queued), not drifted: {current:?}"
2646 );
2647 assert!(
2648 !current.iter().any(|f| f.class == FindingClass::Drifted
2649 && matches!(
2650 &f.target,
2651 FindingTarget::Anchor { artifact, .. } if artifact == "src/other.rs"
2652 )),
2653 "an unstable hash break must never assert drift"
2654 );
2655 }
2656 }
2657
2658 #[test]
2663 fn backfill_writer_refuses_non_hash_classes_and_never_overwrites() {
2664 let tmp = tempfile::tempdir().unwrap();
2665 let root = tmp.path();
2666 let mem_dir = root.join("mem");
2667 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2668 std::fs::write(
2669 mem_dir.join(".memstead").join("config.json"),
2670 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2671 )
2672 .unwrap();
2673 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2674 std::fs::write(
2675 root.join(".memstead").join("workspace.toml"),
2676 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2677 )
2678 .unwrap();
2679 crate::FileWorkspaceStore::new()
2680 .save_state(
2681 root,
2682 &Workspace {
2683 mounts: vec![Mount {
2684 mem: "engine".to_string(),
2685 schema: Some("default@1.0.0".parse().unwrap()),
2686 storage: MountStorage::Folder {
2687 path: mem_dir.clone(),
2688 },
2689 capability: MountCapability::Write,
2690 lifecycle: MountLifecycle::Eager,
2691 cross_linkable: false,
2692 migration_target: None,
2693 }],
2694 settings: WorkspaceSettings::default(),
2695 },
2696 )
2697 .unwrap();
2698
2699 let anchor = |class: AnchorProvenanceClass, hash: Option<&str>| Anchor {
2700 artifact: "src/a.rs".to_string(),
2701 grain: AnchorGrain::File,
2702 class,
2703 at_version: None,
2704 hash: hash.map(str::to_string),
2705 hash_stability: AnchorHashStability::Stable,
2706 derived_from: Vec::new(),
2707 binding: None,
2708 source: None,
2709 };
2710 let mut sidecar = AnchorSidecar::default();
2711 sidecar.set(
2712 "engine--e",
2713 vec![
2714 anchor(AnchorProvenanceClass::Authored, None),
2715 anchor(AnchorProvenanceClass::InformedBy, None),
2716 anchor(AnchorProvenanceClass::Anchored, Some("recorded")),
2717 ],
2718 );
2719 std::fs::write(
2720 mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
2721 sidecar.to_bytes(),
2722 )
2723 .unwrap();
2724
2725 let mut engine = Engine::from_workspace_root(root).unwrap();
2726 let written = engine
2727 .record_anchor_observed_hashes(
2728 "engine",
2729 &[crate::anchor::ObservedArtifactHash {
2730 entity: "engine--e".to_string(),
2731 artifact: "src/a.rs".to_string(),
2732 hash: "observed".to_string(),
2733 }],
2734 None,
2735 )
2736 .unwrap();
2737 assert_eq!(
2738 written, 0,
2739 "non-hash classes refuse the hash; a recorded hash is never overwritten"
2740 );
2741 let sc = AnchorSidecar::from_bytes(
2742 &std::fs::read(mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH)).unwrap(),
2743 )
2744 .unwrap();
2745 for a in sc.get("engine--e") {
2746 match a.class {
2747 AnchorProvenanceClass::Anchored => {
2748 assert_eq!(a.hash.as_deref(), Some("recorded"), "baseline stands")
2749 }
2750 _ => assert!(a.hash.is_none(), "non-hash class stays hash-less: {a:?}"),
2751 }
2752 }
2753 }
2754
2755 #[test]
2771 fn verify_refuses_unreachable_source_with_typed_error() {
2772 let tmp = tempfile::tempdir().unwrap();
2773 let root = tmp.path();
2774 let mem_dir = root.join("mem");
2775 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2776 std::fs::write(
2777 mem_dir.join(".memstead").join("config.json"),
2778 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2779 )
2780 .unwrap();
2781 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2782 std::fs::write(
2783 root.join(".memstead").join("workspace.toml"),
2784 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2785 )
2786 .unwrap();
2787 let mount = Mount {
2788 mem: "engine".to_string(),
2789 schema: Some("default@1.0.0".parse().unwrap()),
2790 storage: MountStorage::Folder {
2791 path: mem_dir.clone(),
2792 },
2793 capability: MountCapability::Write,
2794 lifecycle: MountLifecycle::Eager,
2795 cross_linkable: false,
2796 migration_target: None,
2797 };
2798 crate::FileWorkspaceStore::new()
2799 .save_state(
2800 root,
2801 &Workspace {
2802 mounts: vec![mount],
2803 settings: WorkspaceSettings::default(),
2804 },
2805 )
2806 .unwrap();
2807
2808 write_binding(
2812 root,
2813 "engine",
2814 "gone",
2815 &Binding {
2816 version: BINDING_VERSION,
2817 intent: None,
2818 sources: vec![crate::pipeline::Source {
2819 name: "gone".to_string(),
2820 medium_type: MediumType::Codebase,
2821 pointer: "vanished-src".to_string(),
2822 change_detection: Some("git".to_string()),
2823 scope: vec![PatternEntry {
2824 path: "**/*.rs".to_string(),
2825 mode: PatternMode::Allow,
2826 }],
2827 engagement: None,
2828 preparation: None,
2829 }],
2830 reference_mems: Vec::new(),
2831 destination_mem: "engine".to_string(),
2832 deny_paths: Vec::new(),
2833 coverage_semantics: None,
2834 rules: None,
2835 prune: None,
2836 operations: Operations {
2837 build: None,
2838 sync: None,
2839 verify: Some(VerifyOperation {
2840 trigger: IngestTrigger::Manual,
2841 batch_size: 20,
2842 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2843 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2844 }),
2845 },
2846 },
2847 )
2848 .unwrap();
2849
2850 let engine = Engine::from_workspace_root(root).unwrap();
2851 let configs = load_pipeline_configs(root).unwrap();
2852 let binding = &configs.bindings[0].config;
2853 let resolved = resolve_binding_run("engine/gone", binding).unwrap();
2854
2855 match verify_binding(&engine, root, binding, &resolved) {
2856 Err(FindingsError::SourceUnreachable { source_name, path }) => {
2857 assert_eq!(source_name, "gone");
2858 assert!(
2859 path.ends_with("vanished-src"),
2860 "refusal must name the resolved missing path, got `{path}`",
2861 );
2862 }
2863 other => panic!("expected SourceUnreachable refusal, got {other:?}"),
2864 }
2865
2866 assert!(
2869 !engine
2870 .mem_config_for("engine")
2871 .unwrap()
2872 .sync_state
2873 .keys()
2874 .any(|k| k.ends_with("#verified")),
2875 "a refused verify must not leave any #verified token",
2876 );
2877 }
2878
2879 #[test]
2880 fn completed_verify_records_the_verified_baseline() {
2881 let tmp = tempfile::tempdir().unwrap();
2882 let root = tmp.path();
2883 let mem_dir = root.join("mem");
2884 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2885 std::fs::write(
2886 mem_dir.join(".memstead").join("config.json"),
2887 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2888 )
2889 .unwrap();
2890 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2891 std::fs::write(
2892 root.join(".memstead").join("workspace.toml"),
2893 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2894 )
2895 .unwrap();
2896 let mount = Mount {
2897 mem: "engine".to_string(),
2898 schema: Some("default@1.0.0".parse().unwrap()),
2899 storage: MountStorage::Folder {
2900 path: mem_dir.clone(),
2901 },
2902 capability: MountCapability::Write,
2903 lifecycle: MountLifecycle::Eager,
2904 cross_linkable: false,
2905 migration_target: None,
2906 };
2907 crate::FileWorkspaceStore::new()
2908 .save_state(
2909 root,
2910 &Workspace {
2911 mounts: vec![mount],
2912 settings: WorkspaceSettings::default(),
2913 },
2914 )
2915 .unwrap();
2916 let out = std::process::Command::new("git")
2917 .args(["init", "-q"])
2918 .current_dir(root)
2919 .output()
2920 .unwrap();
2921 assert!(out.status.success());
2922
2923 write_binding(
2924 root,
2925 "engine",
2926 "graph",
2927 &Binding {
2928 version: BINDING_VERSION,
2929 intent: None,
2930 sources: vec![crate::pipeline::Source {
2931 name: "graph".to_string(),
2932 medium_type: MediumType::Codebase,
2933 pointer: String::new(),
2934 change_detection: Some("git".to_string()),
2935 scope: vec![PatternEntry {
2936 path: "src/**/*.rs".to_string(),
2937 mode: PatternMode::Allow,
2938 }],
2939 engagement: None,
2940 preparation: None,
2941 }],
2942 reference_mems: Vec::new(),
2943 destination_mem: "engine".to_string(),
2944 deny_paths: Vec::new(),
2945 coverage_semantics: None,
2946 rules: None,
2947 prune: None,
2948 operations: Operations {
2949 build: Some(BuildOperation {
2950 mode: BuildMode::Discovery,
2951 trigger: IngestTrigger::Loop,
2952 batch_size: 20,
2953 post_actions: None,
2954 }),
2955 sync: None,
2956 verify: Some(VerifyOperation {
2957 trigger: IngestTrigger::Manual,
2958 batch_size: 20,
2959 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2960 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2961 }),
2962 },
2963 },
2964 )
2965 .unwrap();
2966
2967 let mut engine = Engine::from_workspace_root(root).unwrap();
2968 engine
2971 .set_mem_sync_state("engine", "engine/graph/graph#synced", "deadbeef", None)
2972 .unwrap();
2973
2974 let configs = load_pipeline_configs(root).unwrap();
2975 let binding = &configs.bindings[0].config;
2976 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
2977
2978 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2979 assert_eq!(
2981 outcome.facet_heads.get("graph").map(String::as_str),
2982 Some("deadbeef")
2983 );
2984 assert_eq!(outcome.key.source_head, "graph=deadbeef");
2985 assert_eq!(
2986 join_facet_heads(&outcome.facet_heads),
2987 outcome.key.source_head
2988 );
2989
2990 assert!(
2992 !engine
2993 .mem_config_for("engine")
2994 .unwrap()
2995 .sync_state
2996 .contains_key("engine/graph/graph#verified")
2997 );
2998
2999 let written = record_verified_baseline(&mut engine, "engine", &outcome, None).unwrap();
3000 assert_eq!(written, vec!["engine/graph/graph#verified".to_string()]);
3001
3002 assert_eq!(
3004 engine
3005 .mem_config_for("engine")
3006 .unwrap()
3007 .sync_state
3008 .get("engine/graph/graph#verified")
3009 .map(String::as_str),
3010 Some("deadbeef")
3011 );
3012 let disk: serde_json::Value = serde_json::from_slice(
3014 &std::fs::read(mem_dir.join(".memstead").join("config.json")).unwrap(),
3015 )
3016 .unwrap();
3017 assert_eq!(
3018 disk["syncState"]["engine/graph/graph#verified"],
3019 serde_json::json!("deadbeef")
3020 );
3021 }
3022
3023 #[test]
3030 fn adjudication_cap_queues_the_remainder() {
3031 let k = key("h", "s");
3032 let mk = |art: &str| {
3033 let mut a = anchor(AnchorProvenanceClass::Anchored);
3034 a.artifact = art.to_string();
3035 a
3036 };
3037 let candidates = vec![
3038 (
3039 "engine--a".to_string(),
3040 mk("src/a.rs"),
3041 AnchorState::Drifted,
3042 ),
3043 (
3044 "engine--b".to_string(),
3045 mk("src/b.rs"),
3046 AnchorState::Drifted,
3047 ),
3048 (
3049 "engine--c".to_string(),
3050 mk("src/c.rs"),
3051 AnchorState::Drifted,
3052 ),
3053 ];
3054 let window: BTreeSet<String> = [candidate_key("engine--a", &mk("src/a.rs"))]
3056 .into_iter()
3057 .collect();
3058 let out = adjudicate_candidates(&k, "f", &candidates, Some(&window), "1");
3059 let drifted = out
3060 .iter()
3061 .filter(|f| f.class == FindingClass::Drifted)
3062 .count();
3063 let queued = out
3064 .iter()
3065 .filter(|f| f.class == FindingClass::QueuedForAdjudication)
3066 .count();
3067 assert_eq!(drifted, 1, "only the in-window candidate is adjudicated");
3068 assert_eq!(queued, 2, "the remainder is queued as the tier-3 backlog");
3069 assert!(
3071 out.iter()
3072 .any(|f| f.class == FindingClass::QueuedForAdjudication
3073 && f.detail.contains("cap reached")),
3074 "capped remainder states it was deferred by the cap"
3075 );
3076
3077 let uncapped = adjudicate_candidates(&k, "f", &candidates, None, "1");
3079 assert_eq!(
3080 uncapped
3081 .iter()
3082 .filter(|f| f.class == FindingClass::Drifted)
3083 .count(),
3084 3,
3085 "uncapped adjudicates every candidate"
3086 );
3087 assert_eq!(
3088 uncapped
3089 .iter()
3090 .filter(|f| f.class == FindingClass::QueuedForAdjudication)
3091 .count(),
3092 0
3093 );
3094 }
3095
3096 #[test]
3102 fn full_resync_schedule_disabled_notdue_due() {
3103 let codebase = FacetEnumerability {
3104 facet: "src".to_string(),
3105 medium_type: "codebase".to_string(),
3106 enumerable: true,
3107 };
3108 assert_eq!(
3109 schedule_full_resync(0, 5, std::slice::from_ref(&codebase)),
3110 FullResyncDecision::Disabled
3111 );
3112 match schedule_full_resync(3, 2, std::slice::from_ref(&codebase)) {
3113 FullResyncDecision::NotDue { runs_until_due, .. } => assert_eq!(runs_until_due, 1),
3114 other => panic!("expected NotDue, got {other:?}"),
3115 }
3116 match schedule_full_resync(3, 3, std::slice::from_ref(&codebase)) {
3117 FullResyncDecision::Due {
3118 walked_facets,
3119 refused,
3120 ..
3121 } => {
3122 assert_eq!(walked_facets, vec!["src".to_string()]);
3123 assert!(refused.is_empty(), "enumerable facet is not refused");
3124 }
3125 other => panic!("expected Due, got {other:?}"),
3126 }
3127 }
3128
3129 #[test]
3132 fn full_resync_refuses_non_enumerable_medium() {
3133 let web = FacetEnumerability {
3134 facet: "manual".to_string(),
3135 medium_type: "web".to_string(),
3136 enumerable: false,
3137 };
3138 let d = schedule_full_resync(1, 1, &[web]);
3139 assert!(
3140 d.is_full_walk(),
3141 "a due sweep is a full walk even when refused"
3142 );
3143 match d {
3144 FullResyncDecision::Due {
3145 walked_facets,
3146 refused,
3147 ..
3148 } => {
3149 assert!(walked_facets.is_empty(), "nothing enumerable to walk");
3150 assert_eq!(refused.len(), 1, "the non-enumerable facet is refused");
3151 assert_eq!(refused[0].facet, "manual");
3152 assert_eq!(refused[0].medium_type, "web");
3153 assert!(
3154 refused[0].reason.contains("non-enumerable"),
3155 "the refusal is typed and states why"
3156 );
3157 }
3158 other => panic!("expected Due with a refusal, got {other:?}"),
3159 }
3160 }
3161
3162 #[test]
3167 fn full_resync_full_walk_covers_whole_source() {
3168 let tmp = tempfile::tempdir().unwrap();
3169 let root = tmp.path();
3170 let mem_dir = root.join("mem");
3171 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3172 std::fs::write(
3173 mem_dir.join(".memstead").join("config.json"),
3174 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3175 )
3176 .unwrap();
3177 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3178 std::fs::write(
3179 root.join(".memstead").join("workspace.toml"),
3180 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3181 )
3182 .unwrap();
3183 let mount = Mount {
3184 mem: "engine".to_string(),
3185 schema: Some("default@1.0.0".parse().unwrap()),
3186 storage: MountStorage::Folder {
3187 path: mem_dir.clone(),
3188 },
3189 capability: MountCapability::Write,
3190 lifecycle: MountLifecycle::Eager,
3191 cross_linkable: false,
3192 migration_target: None,
3193 };
3194 crate::FileWorkspaceStore::new()
3195 .save_state(
3196 root,
3197 &Workspace {
3198 mounts: vec![mount],
3199 settings: WorkspaceSettings::default(),
3200 },
3201 )
3202 .unwrap();
3203 let out = std::process::Command::new("git")
3204 .args(["init", "-q"])
3205 .current_dir(root)
3206 .output()
3207 .unwrap();
3208 assert!(out.status.success());
3209 std::fs::create_dir_all(root.join("src")).unwrap();
3210 for f in ["a.rs", "b.rs", "c.rs"] {
3211 std::fs::write(root.join("src").join(f), "fn x() {}\n").unwrap();
3212 }
3213
3214 write_binding(
3215 root,
3216 "engine",
3217 "graph",
3218 &Binding {
3219 version: BINDING_VERSION,
3220 intent: None,
3221 sources: vec![crate::pipeline::Source {
3222 name: "graph".to_string(),
3223 medium_type: MediumType::Codebase,
3224 pointer: String::new(),
3225 change_detection: Some("git".to_string()),
3226 scope: vec![PatternEntry {
3227 path: "src/**/*.rs".to_string(),
3228 mode: PatternMode::Allow,
3229 }],
3230 engagement: None,
3231 preparation: None,
3232 }],
3233 reference_mems: Vec::new(),
3234 destination_mem: "engine".to_string(),
3235 deny_paths: Vec::new(),
3236 coverage_semantics: None,
3237 rules: None,
3238 prune: None,
3239 operations: Operations {
3240 build: Some(BuildOperation {
3241 mode: BuildMode::Discovery,
3242 trigger: IngestTrigger::Loop,
3243 batch_size: 20,
3244 post_actions: None,
3245 }),
3246 sync: None,
3247 verify: Some(VerifyOperation {
3248 trigger: IngestTrigger::Manual,
3249 batch_size: 1, adjudication_cap: DEFAULT_ADJUDICATION_CAP,
3251 full_resync_every: 1, }),
3253 },
3254 },
3255 )
3256 .unwrap();
3257
3258 let engine = Engine::from_workspace_root(root).unwrap();
3259 let configs = load_pipeline_configs(root).unwrap();
3260 let binding = &configs.bindings[0].config;
3261 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
3262
3263 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
3264 match &outcome.full_resync {
3266 FullResyncDecision::Due {
3267 walked_facets,
3268 refused,
3269 run_count,
3270 ..
3271 } => {
3272 assert_eq!(*run_count, 1);
3273 assert_eq!(walked_facets, &vec!["graph".to_string()]);
3274 assert!(refused.is_empty());
3275 }
3276 other => panic!("expected a due full walk, got {other:?}"),
3277 }
3278 let store = read_findings_store(root, "engine", "graph")
3280 .unwrap()
3281 .unwrap();
3282 let uncovered = store
3283 .current(&outcome.key)
3284 .iter()
3285 .filter(|f| f.class == FindingClass::Uncovered)
3286 .count();
3287 assert_eq!(
3288 uncovered, 3,
3289 "the scheduled full walk covers the whole source, not a batch of one"
3290 );
3291 }
3292
3293 #[test]
3304 fn full_verify_uncaps_adjudication_and_walks_whole_source() {
3305 let tmp = tempfile::tempdir().unwrap();
3306 let root = tmp.path();
3307 let mem_dir = root.join("mem");
3308 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3309 std::fs::write(
3310 mem_dir.join(".memstead").join("config.json"),
3311 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3312 )
3313 .unwrap();
3314 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3315 std::fs::write(
3316 root.join(".memstead").join("workspace.toml"),
3317 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3318 )
3319 .unwrap();
3320 crate::FileWorkspaceStore::new()
3321 .save_state(
3322 root,
3323 &Workspace {
3324 mounts: vec![Mount {
3325 mem: "engine".to_string(),
3326 schema: Some("default@1.0.0".parse().unwrap()),
3327 storage: MountStorage::Folder {
3328 path: mem_dir.clone(),
3329 },
3330 capability: MountCapability::Write,
3331 lifecycle: MountLifecycle::Eager,
3332 cross_linkable: false,
3333 migration_target: None,
3334 }],
3335 settings: WorkspaceSettings::default(),
3336 },
3337 )
3338 .unwrap();
3339 let out = std::process::Command::new("git")
3340 .args(["init", "-q"])
3341 .current_dir(root)
3342 .output()
3343 .unwrap();
3344 assert!(out.status.success());
3345 std::fs::create_dir_all(root.join("src")).unwrap();
3346 for f in ["a.rs", "b.rs", "c.rs", "d.rs", "e.rs", "f.rs"] {
3348 std::fs::write(root.join("src").join(f), "fn x() {}\n").unwrap();
3349 }
3350 let mk = |art: &str| Anchor {
3351 artifact: art.to_string(),
3352 grain: AnchorGrain::File,
3353 class: AnchorProvenanceClass::Anchored,
3354 at_version: None,
3355 hash: Some("stale-recorded-hash".to_string()), hash_stability: AnchorHashStability::Stable,
3357 derived_from: Vec::new(),
3358 binding: None,
3359 source: None,
3360 };
3361 let mut sidecar = AnchorSidecar::default();
3362 sidecar.set(
3363 "engine--e",
3364 vec![mk("src/a.rs"), mk("src/b.rs"), mk("src/c.rs")],
3365 );
3366 std::fs::write(
3367 mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
3368 sidecar.to_bytes(),
3369 )
3370 .unwrap();
3371
3372 write_binding(
3373 root,
3374 "engine",
3375 "graph",
3376 &Binding {
3377 version: BINDING_VERSION,
3378 intent: None,
3379 sources: vec![crate::pipeline::Source {
3380 name: "graph".to_string(),
3381 medium_type: MediumType::Codebase,
3382 pointer: String::new(),
3383 change_detection: Some("git".to_string()),
3384 scope: vec![PatternEntry {
3385 path: "src/**/*.rs".to_string(),
3386 mode: PatternMode::Allow,
3387 }],
3388 engagement: None,
3389 preparation: None,
3390 }],
3391 reference_mems: Vec::new(),
3392 destination_mem: "engine".to_string(),
3393 deny_paths: Vec::new(),
3394 coverage_semantics: None,
3395 rules: None,
3396 prune: None,
3397 operations: Operations {
3398 build: None,
3399 sync: None,
3400 verify: Some(VerifyOperation {
3401 trigger: IngestTrigger::Manual,
3402 batch_size: 1, adjudication_cap: 1, full_resync_every: 0, }),
3406 },
3407 },
3408 )
3409 .unwrap();
3410
3411 let engine = Engine::from_workspace_root(root).unwrap();
3412 let configs = load_pipeline_configs(root).unwrap();
3413 let binding = &configs.bindings[0].config;
3414 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
3415
3416 let sampled = verify_binding(&engine, root, binding, &resolved).unwrap();
3420 assert_eq!(sampled.full_resync, FullResyncDecision::Disabled);
3421 let store = read_findings_store(root, "engine", "graph")
3422 .unwrap()
3423 .unwrap();
3424 let current = store.current(&sampled.key);
3425 let count = |c: FindingClass| current.iter().filter(|f| f.class == c).count();
3426 assert_eq!(count(FindingClass::Drifted), 1, "cap-1 adjudicates one");
3427 assert_eq!(
3428 count(FindingClass::QueuedForAdjudication),
3429 2,
3430 "the remainder queues"
3431 );
3432 assert!(
3433 current
3434 .iter()
3435 .any(|f| f.class == FindingClass::QueuedForAdjudication
3436 && f.detail.contains("cap reached")),
3437 "the sampled deferral states the cap"
3438 );
3439 assert!(
3440 count(FindingClass::Uncovered) <= 1,
3441 "batch-1 sample looks at one artifact"
3442 );
3443
3444 let full = verify_binding_full(&engine, root, binding, &resolved).unwrap();
3447 assert_eq!(
3448 full.full_resync,
3449 FullResyncDecision::Forced {
3450 walked_facets: vec!["graph".to_string()]
3451 }
3452 );
3453 assert_eq!(full.backlog, 0, "cap treated as unlimited — no backlog");
3454 let store = read_findings_store(root, "engine", "graph")
3455 .unwrap()
3456 .unwrap();
3457 let current = store.current(&full.key);
3458 let count = |c: FindingClass| current.iter().filter(|f| f.class == c).count();
3459 assert_eq!(
3460 count(FindingClass::Drifted),
3461 3,
3462 "every candidate adjudicated"
3463 );
3464 assert_eq!(count(FindingClass::QueuedForAdjudication), 0);
3465 assert_eq!(
3466 count(FindingClass::Uncovered),
3467 3,
3468 "the whole S(D) walked — every uncovered file flagged"
3469 );
3470 assert!(
3471 current.iter().all(|f| !f.detail.contains("cap reached")),
3472 "a full run's findings carry no cap-deferral caveat"
3473 );
3474 }
3475
3476 #[test]
3481 fn full_verify_refuses_non_enumerable_medium_typed() {
3482 let tmp = tempfile::tempdir().unwrap();
3483 let root = tmp.path();
3484 let mem_dir = root.join("mem");
3485 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3486 std::fs::write(
3487 mem_dir.join(".memstead").join("config.json"),
3488 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3489 )
3490 .unwrap();
3491 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3492 std::fs::write(
3493 root.join(".memstead").join("workspace.toml"),
3494 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3495 )
3496 .unwrap();
3497 crate::FileWorkspaceStore::new()
3498 .save_state(
3499 root,
3500 &Workspace {
3501 mounts: vec![Mount {
3502 mem: "engine".to_string(),
3503 schema: Some("default@1.0.0".parse().unwrap()),
3504 storage: MountStorage::Folder {
3505 path: mem_dir.clone(),
3506 },
3507 capability: MountCapability::Write,
3508 lifecycle: MountLifecycle::Eager,
3509 cross_linkable: false,
3510 migration_target: None,
3511 }],
3512 settings: WorkspaceSettings::default(),
3513 },
3514 )
3515 .unwrap();
3516
3517 write_binding(
3519 root,
3520 "engine",
3521 "manual",
3522 &Binding {
3523 version: BINDING_VERSION,
3524 intent: None,
3525 sources: vec![crate::pipeline::Source {
3526 name: "manual".to_string(),
3527 medium_type: MediumType::Web,
3528 pointer: "https://example.com/docs".to_string(),
3529 change_detection: None,
3530 scope: Vec::new(),
3531 engagement: None,
3532 preparation: None,
3533 }],
3534 reference_mems: Vec::new(),
3535 destination_mem: "engine".to_string(),
3536 deny_paths: Vec::new(),
3537 coverage_semantics: Some(CoverageSemantics::Curated),
3538 rules: None,
3539 prune: None,
3540 operations: Operations {
3541 build: None,
3542 sync: None,
3543 verify: Some(VerifyOperation {
3544 trigger: IngestTrigger::Manual,
3545 batch_size: 20,
3546 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
3547 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
3548 }),
3549 },
3550 },
3551 )
3552 .unwrap();
3553
3554 let engine = Engine::from_workspace_root(root).unwrap();
3555 let configs = load_pipeline_configs(root).unwrap();
3556 let binding = &configs.bindings[0].config;
3557 let resolved = resolve_binding_run("engine/manual", binding).unwrap();
3558
3559 let err = verify_binding_full(&engine, root, binding, &resolved).unwrap_err();
3561 match &err {
3562 FindingsError::FullWalkNonEnumerable(refusal) => {
3563 assert_eq!(refusal.facet, "manual");
3564 assert_eq!(refusal.medium_type, "web");
3565 assert!(refusal.reason.contains("non-enumerable"));
3566 }
3567 other => panic!("expected FullWalkNonEnumerable, got {other:?}"),
3568 }
3569 assert!(
3570 read_findings_store(root, "engine", "manual")
3571 .unwrap()
3572 .is_none(),
3573 "a refused full run records nothing"
3574 );
3575
3576 let sampled = verify_binding(&engine, root, binding, &resolved).unwrap();
3578 assert_eq!(sampled.binding, "engine/manual");
3579 }
3580}