1use std::collections::{BTreeMap, BTreeSet};
65use std::path::{Path, PathBuf};
66use std::time::{SystemTime, UNIX_EPOCH};
67
68use serde::{Deserialize, Serialize};
69
70use crate::Engine;
71use crate::anchor::{Anchor, AnchorState, ObservedArtifactHash};
72use crate::binding::{
73 Binding, DEFAULT_ADJUDICATION_CAP, DEFAULT_FULL_RESYNC_EVERY, hash_binding, medium_capabilities,
74};
75use crate::workspace_store::{StoreError, WORKSPACE_STORE_DIR};
76
77use super::advance::is_single_component;
78use super::cursor::{compute_source_cursor, enumerate_source_artifacts};
79use super::refinement::{
80 ROTATION_ANCHOR_ADJUDICATION, bump_verify_runs, next_batch, next_rotation_batch,
81};
82use super::resolve::{ResolvedIngest, ResolvedSource};
83
84const STATE_DIR: &str = "state";
87const FINDINGS_DIR: &str = "findings";
89
90#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
107pub struct FindingKey {
108 pub binding_hash: String,
111 pub source_head: String,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(rename_all = "kebab-case")]
129pub enum FindingClass {
130 Drifted,
133 Wrong,
135 Uncovered,
137 UnresolvableAnchor,
139 QueuedForAdjudication,
142}
143
144impl FindingClass {
145 pub const WIRE_VALUES: &'static [&'static str] = &[
147 "drifted",
148 "wrong",
149 "uncovered",
150 "unresolvable-anchor",
151 "queued-for-adjudication",
152 ];
153
154 pub fn as_wire(&self) -> &'static str {
156 match self {
157 FindingClass::Drifted => "drifted",
158 FindingClass::Wrong => "wrong",
159 FindingClass::Uncovered => "uncovered",
160 FindingClass::UnresolvableAnchor => "unresolvable-anchor",
161 FindingClass::QueuedForAdjudication => "queued-for-adjudication",
162 }
163 }
164
165 pub fn from_wire(s: &str) -> Option<Self> {
167 match s {
168 "drifted" => Some(FindingClass::Drifted),
169 "wrong" => Some(FindingClass::Wrong),
170 "uncovered" => Some(FindingClass::Uncovered),
171 "unresolvable-anchor" => Some(FindingClass::UnresolvableAnchor),
172 "queued-for-adjudication" => Some(FindingClass::QueuedForAdjudication),
173 _ => None,
174 }
175 }
176}
177
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(tag = "kind", rename_all = "kebab-case")]
182pub enum FindingTarget {
183 Anchor {
186 entity: String,
188 artifact: String,
190 },
191 Artifact {
194 artifact: String,
196 },
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
204pub struct Finding {
205 pub key: FindingKey,
209 pub facet: String,
212 pub target: FindingTarget,
214 pub class: FindingClass,
216 pub detail: String,
218 pub created_at: String,
220}
221
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
234pub struct FindingsBatch {
235 pub key: FindingKey,
238 pub recorded_at: String,
240 pub findings: Vec<Finding>,
242}
243
244#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
258pub struct FindingsStore {
259 pub binding: String,
261 #[serde(default)]
264 pub batches: Vec<FindingsBatch>,
265}
266
267impl FindingsStore {
268 fn current_batch_index(&self, binding_hash: &str) -> Option<usize> {
273 self.batches
274 .iter()
275 .enumerate()
276 .filter(|(_, b)| b.key.binding_hash == binding_hash)
277 .max_by_key(|(i, b)| (b.recorded_at.parse::<u64>().unwrap_or(0), *i))
278 .map(|(i, _)| i)
279 }
280
281 pub fn record(&mut self, key: FindingKey, recorded_at: String, findings: Vec<Finding>) {
286 self.batches
287 .retain(|b| b.key.binding_hash != key.binding_hash);
288 self.batches.push(FindingsBatch {
289 key,
290 recorded_at,
291 findings,
292 });
293 }
294
295 pub fn current(&self, key: &FindingKey) -> &[Finding] {
300 self.current_batch_index(&key.binding_hash)
301 .map(|i| self.batches[i].findings.as_slice())
302 .unwrap_or(&[])
303 }
304
305 pub fn superseded(&self, key: &FindingKey) -> Vec<&Finding> {
310 let current = self.current_batch_index(&key.binding_hash);
311 self.batches
312 .iter()
313 .enumerate()
314 .filter(|(i, _)| Some(*i) != current)
315 .flat_map(|(_, b)| b.findings.iter())
316 .collect()
317 }
318}
319
320pub fn findings_store_path(workspace_root: &Path, mem: &str, name: &str) -> PathBuf {
327 workspace_root
328 .join(WORKSPACE_STORE_DIR)
329 .join(STATE_DIR)
330 .join(FINDINGS_DIR)
331 .join(mem)
332 .join(format!("{name}.json"))
333}
334
335pub const STANDALONE_KEY: &str = "standalone";
345
346#[derive(Debug, Clone, Serialize)]
351pub struct AnnotatedStandaloneFinding {
352 #[serde(flatten)]
353 pub finding: Finding,
354 pub already_seen: bool,
355}
356
357pub fn record_standalone_findings(
365 workspace_root: &Path,
366 report: &crate::engine::query::MemAnchorVerification,
367) -> Result<Vec<AnnotatedStandaloneFinding>, StoreError> {
368 let mem = &report.mem;
369 let key = FindingKey {
370 binding_hash: STANDALONE_KEY.to_string(),
371 source_head: String::new(),
372 };
373 let now = SystemTime::now()
374 .duration_since(UNIX_EPOCH)
375 .map(|d| d.as_secs())
376 .unwrap_or(0)
377 .to_string();
378
379 let findings: Vec<Finding> = report
380 .anchors
381 .iter()
382 .filter_map(|a| {
383 let class = match a.state.as_str() {
393 "drifted" => FindingClass::Drifted,
394 "unresolvable" => FindingClass::UnresolvableAnchor,
395 _ => return None,
396 };
397 Some(Finding {
398 key: key.clone(),
399 facet: STANDALONE_KEY.to_string(),
400 target: FindingTarget::Anchor {
401 entity: a.entity_id.clone(),
402 artifact: a.artifact.clone(),
403 },
404 class,
405 detail: format!("{} ({} {})", a.state, a.class, a.grain),
406 created_at: now.clone(),
407 })
408 })
409 .collect();
410
411 let mut store =
412 read_findings_store(workspace_root, mem, STANDALONE_KEY)?.unwrap_or_else(|| {
413 FindingsStore {
414 binding: format!("{mem}/{STANDALONE_KEY}"),
415 ..Default::default()
416 }
417 });
418 let prior: BTreeSet<(String, String)> = store
419 .current(&key)
420 .iter()
421 .map(|f| {
422 (
423 serde_json::to_string(&f.target).unwrap_or_default(),
424 f.class.as_wire().to_string(),
425 )
426 })
427 .collect();
428 let annotated: Vec<AnnotatedStandaloneFinding> = findings
429 .iter()
430 .map(|f| AnnotatedStandaloneFinding {
431 finding: f.clone(),
432 already_seen: prior.contains(&(
433 serde_json::to_string(&f.target).unwrap_or_default(),
434 f.class.as_wire().to_string(),
435 )),
436 })
437 .collect();
438 store.record(key, now, findings);
439 write_findings_store(workspace_root, mem, STANDALONE_KEY, &store)?;
440 Ok(annotated)
441}
442
443pub fn read_findings_store(
446 workspace_root: &Path,
447 mem: &str,
448 name: &str,
449) -> Result<Option<FindingsStore>, StoreError> {
450 let path = findings_store_path(workspace_root, mem, name);
451 match std::fs::read(&path) {
452 Ok(bytes) => serde_json::from_slice(&bytes)
453 .map(Some)
454 .map_err(|e| StoreError::Parse {
455 path,
456 message: e.to_string(),
457 }),
458 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
459 Err(e) => Err(StoreError::Io { path, source: e }),
460 }
461}
462
463pub(crate) fn ensure_selfignoring_store_dir(subtree_root: &Path) -> Result<(), StoreError> {
471 std::fs::create_dir_all(subtree_root).map_err(|e| StoreError::Io {
472 path: subtree_root.to_path_buf(),
473 source: e,
474 })?;
475 let gitignore = subtree_root.join(".gitignore");
476 if !gitignore.exists() {
477 let _ = std::fs::write(&gitignore, "*\n");
478 }
479 Ok(())
480}
481
482pub fn write_findings_store(
485 workspace_root: &Path,
486 mem: &str,
487 name: &str,
488 store: &FindingsStore,
489) -> Result<(), StoreError> {
490 ensure_selfignoring_store_dir(
491 &workspace_root
492 .join(WORKSPACE_STORE_DIR)
493 .join(STATE_DIR)
494 .join(FINDINGS_DIR),
495 )?;
496 let path = findings_store_path(workspace_root, mem, name);
497 if let Some(parent) = path.parent() {
498 std::fs::create_dir_all(parent).map_err(|e| StoreError::Io {
499 path: parent.to_path_buf(),
500 source: e,
501 })?;
502 }
503 let bytes = serde_json::to_vec_pretty(store).map_err(|e| StoreError::Parse {
504 path: path.clone(),
505 message: e.to_string(),
506 })?;
507 std::fs::write(&path, bytes).map_err(|e| StoreError::Io { path, source: e })
508}
509
510pub fn delete_findings_store(
513 workspace_root: &Path,
514 mem: &str,
515 name: &str,
516) -> Result<(), StoreError> {
517 let path = findings_store_path(workspace_root, mem, name);
518 match std::fs::remove_file(&path) {
519 Ok(()) => Ok(()),
520 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
521 Err(e) => Err(StoreError::Io { path, source: e }),
522 }
523}
524
525#[derive(Debug, thiserror::Error)]
531pub enum FindingsError {
532 #[error("malformed binding id '{0}': expected `<mem>/<stem>`")]
534 MalformedId(String),
535 #[error("findings store error: {0}")]
537 Store(#[source] StoreError),
538 #[error("source '{source_name}' unreachable: `{path}` does not exist")]
545 SourceUnreachable {
546 source_name: String,
548 path: String,
550 },
551 #[error(
560 "full verify refused: facet '{}' over medium type '{}' cannot be fully walked — {}",
561 .0.facet, .0.medium_type, .0.reason
562 )]
563 FullWalkNonEnumerable(FullResyncRefusal),
564}
565
566#[derive(Debug, Clone, PartialEq, Eq)]
568pub struct VerifyOutcome {
569 pub binding: String,
571 pub key: FindingKey,
573 pub recorded: usize,
575 pub superseded: usize,
577 pub backlog: usize,
579 pub full_resync: FullResyncDecision,
583 pub facet_heads: BTreeMap<String, String>,
587 pub hash_backfill: Vec<ObservedArtifactHash>,
597}
598
599pub fn record_verified_baseline(
614 engine: &mut Engine,
615 destination_mem: &str,
616 outcome: &VerifyOutcome,
617 note: Option<&str>,
618) -> Result<Vec<String>, crate::engine::EngineError> {
619 let mut written = Vec::with_capacity(outcome.facet_heads.len());
620 for (facet, token) in &outcome.facet_heads {
621 let key = format!("{}/{facet}#verified", outcome.binding);
622 engine.set_mem_sync_state(destination_mem, &key, token, note)?;
623 written.push(key);
624 }
625 Ok(written)
626}
627
628pub fn record_anchor_hash_backfill(
645 engine: &mut Engine,
646 destination_mem: &str,
647 outcome: &VerifyOutcome,
648 note: Option<&str>,
649) -> Result<usize, crate::engine::EngineError> {
650 engine.record_anchor_observed_hashes(destination_mem, &outcome.hash_backfill, note)
651}
652
653fn split_binding_id(binding_id: &str) -> Result<(String, String), FindingsError> {
657 binding_id
658 .split_once('/')
659 .filter(|(m, n)| is_single_component(m) && is_single_component(n))
660 .map(|(m, n)| (m.to_string(), n.to_string()))
661 .ok_or_else(|| FindingsError::MalformedId(binding_id.to_string()))
662}
663
664fn source_facet_label(resolved: &ResolvedIngest) -> String {
668 let facets: Vec<&str> = resolved
669 .sources
670 .iter()
671 .filter_map(|s| match s {
672 ResolvedSource::Primary(p) => Some(p.name.as_str()),
673 ResolvedSource::Reference { .. } => None,
674 })
675 .collect();
676 facets.join(",")
677}
678
679fn now_seconds() -> String {
681 let secs = SystemTime::now()
682 .duration_since(UNIX_EPOCH)
683 .map(|d| d.as_secs())
684 .unwrap_or(0);
685 secs.to_string()
686}
687
688fn current_facet_heads(
696 engine: &Engine,
697 workspace_root: &Path,
698 resolved: &ResolvedIngest,
699) -> BTreeMap<String, String> {
700 let binding_id = &resolved.name;
701 let prefix = format!("{binding_id}/");
702 let mut tokens: BTreeMap<String, String> = BTreeMap::new();
703
704 if let Some(cfg) = engine.mem_config_for(&resolved.destination_mem) {
706 for (k, v) in &cfg.sync_state {
707 if let Some(rest) = k.strip_prefix(&prefix)
708 && let Some(facet) = rest.strip_suffix("#synced")
709 {
710 tokens.insert(facet.to_string(), v.clone());
711 }
712 }
713 }
714
715 let cursor = compute_source_cursor(engine, resolved, workspace_root);
717 for c in cursor.write_commands.iter().chain(cursor.reseed.iter()) {
718 if let Some(rest) = c.key.strip_prefix(&prefix)
719 && let Some(facet) = rest.strip_suffix("#synced")
720 {
721 tokens.insert(facet.to_string(), c.token.clone());
722 }
723 }
724
725 tokens
726}
727
728fn join_facet_heads(tokens: &BTreeMap<String, String>) -> String {
731 tokens
732 .iter()
733 .map(|(facet, token)| format!("{facet}={token}"))
734 .collect::<Vec<_>>()
735 .join(";")
736}
737
738fn current_source_head(
742 engine: &Engine,
743 workspace_root: &Path,
744 resolved: &ResolvedIngest,
745) -> String {
746 join_facet_heads(¤t_facet_heads(engine, workspace_root, resolved))
747}
748
749fn binding_hash_of(binding: &Binding, _resolved: &ResolvedIngest) -> String {
752 hash_binding(binding)
753}
754
755fn current_key(
759 engine: &Engine,
760 workspace_root: &Path,
761 binding: &Binding,
762 resolved: &ResolvedIngest,
763) -> FindingKey {
764 FindingKey {
765 binding_hash: binding_hash_of(binding, resolved),
766 source_head: current_source_head(engine, workspace_root, resolved),
767 }
768}
769
770pub fn current_findings(
780 engine: &Engine,
781 workspace_root: &Path,
782 binding: &Binding,
783 resolved: &ResolvedIngest,
784) -> Result<(FindingKey, Vec<Finding>), FindingsError> {
785 let (mem, name) = split_binding_id(&resolved.name)?;
786 let key = current_key(engine, workspace_root, binding, resolved);
787 let findings = read_findings_store(workspace_root, &mem, &name)
788 .map_err(FindingsError::Store)?
789 .map(|s| s.current(&key).to_vec())
790 .unwrap_or_default();
791 Ok((key, findings))
792}
793
794pub fn adjudicate_anchor(
805 key: &FindingKey,
806 facet: &str,
807 entity: &str,
808 anchor: &Anchor,
809 state: AnchorState,
810 created_at: &str,
811) -> Option<Finding> {
812 let (class, detail) = match state {
813 AnchorState::Resolves => return None,
814 AnchorState::Orphaned => (
815 FindingClass::UnresolvableAnchor,
816 format!(
817 "artifact '{}' the anchor references is no longer present in the medium",
818 anchor.artifact
819 ),
820 ),
821 AnchorState::Drifted | AnchorState::Recheck => {
822 if !anchor.class.is_hash_bearing() {
824 return None;
825 }
826 match state {
827 AnchorState::Drifted => (
828 FindingClass::Drifted,
829 format!(
830 "prepared-content hash of '{}' drifted from the anchored hash",
831 anchor.artifact
832 ),
833 ),
834 _ => (
835 FindingClass::QueuedForAdjudication,
836 format!(
837 "hash adjudication of '{}' deferred (recheck); queued",
838 anchor.artifact
839 ),
840 ),
841 }
842 }
843 };
844 Some(Finding {
845 key: key.clone(),
846 facet: facet.to_string(),
847 target: FindingTarget::Anchor {
848 entity: entity.to_string(),
849 artifact: anchor.artifact.clone(),
850 },
851 class,
852 detail,
853 created_at: created_at.to_string(),
854 })
855}
856
857#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
864pub struct FacetEnumerability {
865 pub facet: String,
867 pub medium_type: String,
869 pub enumerable: bool,
871}
872
873#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
878pub struct FullResyncRefusal {
879 pub facet: String,
881 pub medium_type: String,
883 pub reason: String,
885}
886
887#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
891#[serde(tag = "state", rename_all = "kebab-case")]
892pub enum FullResyncDecision {
893 Disabled,
896 NotDue {
899 run_count: u64,
901 every: u32,
903 runs_until_due: u32,
905 },
906 Due {
911 run_count: u64,
913 every: u32,
915 walked_facets: Vec<String>,
917 refused: Vec<FullResyncRefusal>,
919 },
920 Forced {
928 walked_facets: Vec<String>,
930 },
931}
932
933impl FullResyncDecision {
934 pub fn is_full_walk(&self) -> bool {
938 matches!(
939 self,
940 FullResyncDecision::Due { .. } | FullResyncDecision::Forced { .. }
941 )
942 }
943}
944
945pub fn schedule_full_resync(
951 every: u32,
952 run_count: u64,
953 facets: &[FacetEnumerability],
954) -> FullResyncDecision {
955 if every == 0 {
956 return FullResyncDecision::Disabled;
957 }
958 let modulo = run_count % u64::from(every);
959 if modulo != 0 {
960 return FullResyncDecision::NotDue {
961 run_count,
962 every,
963 runs_until_due: (u64::from(every) - modulo) as u32,
964 };
965 }
966 let mut walked_facets = Vec::new();
967 let mut refused = Vec::new();
968 for f in facets {
969 if f.enumerable {
970 walked_facets.push(f.facet.clone());
971 } else {
972 refused.push(FullResyncRefusal {
973 facet: f.facet.clone(),
974 medium_type: f.medium_type.clone(),
975 reason: format!(
976 "medium type '{}' is non-enumerable — a full-enumeration walk cannot cover \
977 it; the scheduled full resync refuses rather than claim full coverage",
978 f.medium_type
979 ),
980 });
981 }
982 }
983 FullResyncDecision::Due {
984 run_count,
985 every,
986 walked_facets,
987 refused,
988 }
989}
990
991fn candidate_key(entity: &str, anchor: &Anchor) -> String {
995 format!("{entity}\u{1f}{}", anchor.artifact)
996}
997
998fn adjudicate_candidates(
1010 key: &FindingKey,
1011 facet: &str,
1012 candidates: &[(String, Anchor, AnchorState)],
1013 window: Option<&BTreeSet<String>>,
1014 created_at: &str,
1015) -> Vec<Finding> {
1016 let mut out = Vec::new();
1017 for (entity, anchor, state) in candidates {
1018 let ck = candidate_key(entity, anchor);
1019 let adjudicate_now = window.is_none_or(|w| w.contains(&ck));
1020 if adjudicate_now {
1021 if let Some(f) = adjudicate_anchor(key, facet, entity, anchor, *state, created_at) {
1022 out.push(f);
1023 }
1024 } else {
1025 out.push(Finding {
1029 key: key.clone(),
1030 facet: facet.to_string(),
1031 target: FindingTarget::Anchor {
1032 entity: entity.clone(),
1033 artifact: anchor.artifact.clone(),
1034 },
1035 class: FindingClass::QueuedForAdjudication,
1036 detail: format!(
1037 "adjudication of '{}' deferred (per-run adjudication cap reached); queued",
1038 anchor.artifact
1039 ),
1040 created_at: created_at.to_string(),
1041 });
1042 }
1043 }
1044 out
1045}
1046
1047fn target_key(target: &FindingTarget) -> String {
1051 match target {
1052 FindingTarget::Anchor { entity, artifact } => format!("a\u{1f}{entity}\u{1f}{artifact}"),
1053 FindingTarget::Artifact { artifact } => format!("f\u{1f}{artifact}"),
1054 }
1055}
1056
1057struct PassObservation {
1060 anchors_observed: BTreeSet<String>,
1063 anchors_existing: BTreeSet<String>,
1066 files_observed: BTreeSet<String>,
1069 s_d: BTreeSet<String>,
1071}
1072
1073fn merge_with_prior(
1098 mut fresh: Vec<Finding>,
1099 prior: &[Finding],
1100 obs: &PassObservation,
1101 covered_now: impl Fn(&str) -> bool,
1102) -> Vec<Finding> {
1103 let fresh_idx: BTreeMap<String, usize> = fresh
1104 .iter()
1105 .enumerate()
1106 .map(|(i, f)| (target_key(&f.target), i))
1107 .collect();
1108 let mut carried: Vec<Finding> = Vec::new();
1109 for f in prior {
1110 let tkey = target_key(&f.target);
1111 let observed = match &f.target {
1112 FindingTarget::Anchor { .. } => obs.anchors_observed.contains(&tkey),
1113 FindingTarget::Artifact { artifact } => obs.files_observed.contains(artifact),
1114 };
1115 if observed {
1116 if matches!(f.class, FindingClass::Drifted | FindingClass::Wrong)
1118 && let Some(&i) = fresh_idx.get(&tkey)
1119 && fresh[i].class == FindingClass::QueuedForAdjudication
1120 {
1121 fresh[i] = f.clone();
1122 }
1123 continue;
1124 }
1125 if fresh_idx.contains_key(&tkey) {
1126 continue; }
1128 let still_open = match &f.target {
1129 FindingTarget::Anchor { .. } => obs.anchors_existing.contains(&tkey),
1130 FindingTarget::Artifact { artifact } => {
1131 obs.s_d.contains(artifact) && !covered_now(artifact)
1132 }
1133 };
1134 if still_open {
1135 carried.push(f.clone());
1136 }
1137 }
1138 fresh.extend(carried);
1139 fresh
1140}
1141
1142pub fn verify_binding(
1156 engine: &Engine,
1157 workspace_root: &Path,
1158 binding: &Binding,
1159 resolved: &ResolvedIngest,
1160) -> Result<VerifyOutcome, FindingsError> {
1161 run_verify(engine, workspace_root, binding, resolved, false)
1162}
1163
1164pub fn verify_binding_full(
1180 engine: &Engine,
1181 workspace_root: &Path,
1182 binding: &Binding,
1183 resolved: &ResolvedIngest,
1184) -> Result<VerifyOutcome, FindingsError> {
1185 run_verify(engine, workspace_root, binding, resolved, true)
1186}
1187
1188fn run_verify(
1192 engine: &Engine,
1193 workspace_root: &Path,
1194 binding: &Binding,
1195 resolved: &ResolvedIngest,
1196 full: bool,
1197) -> Result<VerifyOutcome, FindingsError> {
1198 let binding_id = resolved.name.clone();
1199 let (mem, name) = split_binding_id(&binding_id)?;
1200
1201 if full {
1205 for source in &resolved.sources {
1206 if let ResolvedSource::Primary(p) = source {
1207 let medium_type = medium_type_wire(p.medium_type);
1208 if !medium_capabilities(p.medium_type).enumerable {
1209 return Err(FindingsError::FullWalkNonEnumerable(FullResyncRefusal {
1210 facet: p.name.clone(),
1211 medium_type: medium_type.clone(),
1212 reason: format!(
1213 "medium type '{medium_type}' is non-enumerable — a full-enumeration \
1214 walk cannot cover it; the full measurement refuses rather than \
1215 render a report with fabricated completeness"
1216 ),
1217 }));
1218 }
1219 }
1220 }
1221
1222 for source in &resolved.sources {
1238 if let ResolvedSource::Primary(p) = source
1239 && medium_capabilities(p.medium_type).enumerable
1240 {
1241 let walked = super::cursor::enumerate_source_artifacts_reported(
1242 engine,
1243 p,
1244 &resolved.deny_paths,
1245 workspace_root,
1246 );
1247 let medium_type = medium_type_wire(p.medium_type);
1248 if let Some(why) = walked.partiality_reason() {
1256 return Err(FindingsError::FullWalkNonEnumerable(FullResyncRefusal {
1257 facet: p.name.clone(),
1258 medium_type: medium_type.clone(),
1259 reason: format!(
1260 "this facet's enumeration is incomplete — {why} — so a full \
1261 measurement would claim complete coverage over a denominator \
1262 that is not the population. Fix those patterns first"
1263 ),
1264 }));
1265 }
1266 if walked.files.is_empty() {
1267 let remedy = if walked.legacy_dialect.is_empty() {
1272 "Check that its scope patterns actually select something".to_string()
1273 } else {
1274 format!(
1275 "its scope pattern(s) are still written against the workspace root \
1276 rather than the source pointer ({}), so they select nothing under \
1277 the pointer join — rewrite them relative to the pointer",
1278 walked
1279 .legacy_dialect
1280 .iter()
1281 .map(|n| n.pattern.as_str())
1282 .collect::<Vec<_>>()
1283 .join(", ")
1284 )
1285 };
1286 return Err(FindingsError::FullWalkNonEnumerable(FullResyncRefusal {
1287 facet: p.name.clone(),
1288 medium_type: medium_type.clone(),
1289 reason: format!(
1290 "medium type '{medium_type}' claims to be enumerable, but this \
1291 facet's enumeration yielded no artifacts — a full measurement over \
1292 an empty walk would report complete coverage of nothing. {remedy}"
1293 ),
1294 }));
1295 }
1296 }
1297 }
1298 }
1299
1300 for source in &resolved.sources {
1306 if let ResolvedSource::Primary(p) = source
1307 && matches!(
1308 p.medium_type,
1309 crate::pipeline::MediumType::Codebase
1310 | crate::pipeline::MediumType::Filesystem
1311 | crate::pipeline::MediumType::Git
1312 )
1313 {
1314 let base = super::resolve::source_base_path(p, workspace_root);
1315 let reachable = base.is_dir() && std::fs::read_dir(&base).is_ok();
1327 if !reachable {
1328 return Err(FindingsError::SourceUnreachable {
1329 source_name: p.name.clone(),
1330 path: base.display().to_string(),
1331 });
1332 }
1333 }
1334 }
1335
1336 for source in &resolved.sources {
1346 if let ResolvedSource::Primary(p) = source
1347 && p.medium_type == crate::pipeline::MediumType::Graph
1348 && !engine.mem_names().iter().any(|m| *m == p.pointer)
1349 {
1350 return Err(FindingsError::SourceUnreachable {
1351 source_name: p.name.clone(),
1352 path: format!("mem `{}` (not mounted in this workspace)", p.pointer),
1353 });
1354 }
1355 }
1356
1357 let facet_heads = current_facet_heads(engine, workspace_root, resolved);
1361 let key = FindingKey {
1362 binding_hash: binding_hash_of(binding, resolved),
1363 source_head: join_facet_heads(&facet_heads),
1364 };
1365 let now = now_seconds();
1366 let facet = source_facet_label(resolved);
1367 let cache_root = workspace_root.join(".memstead.cache").join("ingest");
1368
1369 let verify_op = binding.operations.verify.as_ref();
1375 let cap = verify_op.map_or(DEFAULT_ADJUDICATION_CAP, |v| v.adjudication_cap);
1376 let full_resync_every = verify_op.map_or(DEFAULT_FULL_RESYNC_EVERY, |v| v.full_resync_every);
1377 let sample_batch = verify_op
1378 .map_or(resolved.batch_size, |v| v.batch_size)
1379 .max(1) as usize;
1380
1381 let run_count = bump_verify_runs(&cache_root, &binding_id);
1387 let facet_enum: Vec<FacetEnumerability> = resolved
1388 .sources
1389 .iter()
1390 .filter_map(|s| match s {
1391 ResolvedSource::Primary(p) => Some(FacetEnumerability {
1392 facet: p.name.clone(),
1393 medium_type: medium_type_wire(p.medium_type),
1394 enumerable: medium_capabilities(p.medium_type).enumerable,
1395 }),
1396 ResolvedSource::Reference { .. } => None,
1397 })
1398 .collect();
1399 let full_resync = if full {
1400 FullResyncDecision::Forced {
1401 walked_facets: facet_enum.iter().map(|f| f.facet.clone()).collect(),
1402 }
1403 } else {
1404 schedule_full_resync(full_resync_every, run_count, &facet_enum)
1405 };
1406 let mut full_walk_files: Vec<String> = Vec::new();
1417 let full_resync = match full_resync {
1418 FullResyncDecision::Due {
1419 run_count,
1420 every,
1421 walked_facets,
1422 mut refused,
1423 } => {
1424 let mut kept: Vec<String> = Vec::new();
1425 for source in &resolved.sources {
1426 if let ResolvedSource::Primary(p) = source
1427 && walked_facets.iter().any(|f| f == &p.name)
1428 {
1429 let walked = super::cursor::enumerate_source_artifacts_reported(
1430 engine,
1431 p,
1432 &resolved.deny_paths,
1433 workspace_root,
1434 );
1435 if let Some(why) = walked.partiality_reason() {
1436 refused.push(FullResyncRefusal {
1437 facet: p.name.clone(),
1438 medium_type: medium_type_wire(p.medium_type),
1439 reason: format!(
1440 "this facet's enumeration is incomplete — {why} — so the \
1441 scheduled full walk refuses it rather than announce complete \
1442 coverage over a denominator that is not the population"
1443 ),
1444 });
1445 } else {
1446 kept.push(p.name.clone());
1447 full_walk_files.extend(walked.files);
1448 }
1449 }
1450 }
1451 FullResyncDecision::Due {
1452 run_count,
1453 every,
1454 walked_facets: kept,
1455 refused,
1456 }
1457 }
1458 FullResyncDecision::Forced { walked_facets } => {
1459 for source in &resolved.sources {
1460 if let ResolvedSource::Primary(p) = source
1461 && medium_capabilities(p.medium_type).enumerable
1462 {
1463 full_walk_files.extend(enumerate_source_artifacts(
1464 engine,
1465 p,
1466 &resolved.deny_paths,
1467 workspace_root,
1468 ));
1469 }
1470 }
1471 FullResyncDecision::Forced { walked_facets }
1472 }
1473 other => other,
1474 };
1475
1476 let mut findings: Vec<Finding> = Vec::new();
1477
1478 let mut existence: Vec<(String, Anchor, AnchorState)> = Vec::new();
1485 let mut candidates: Vec<(String, Anchor, AnchorState)> = Vec::new();
1486 let mut hash_backfill: Vec<ObservedArtifactHash> = Vec::new();
1495 let mut backfill_seen: BTreeSet<(String, String)> = BTreeSet::new();
1496 let mut anchors_existing: BTreeSet<String> = BTreeSet::new();
1499 let mut anchors_observed: BTreeSet<String> = BTreeSet::new();
1500 let population = crate::ingest::anchor_population::population_for(
1504 engine,
1505 resolved,
1506 Some(binding_hash_of(binding, resolved).as_str()),
1507 );
1508 for (eid, resolved_anchor) in population.included {
1509 let tkey = target_key(&FindingTarget::Anchor {
1510 entity: eid.as_ref().to_string(),
1511 artifact: resolved_anchor.anchor.artifact.clone(),
1512 });
1513 anchors_existing.insert(tkey.clone());
1514 let Some(state) = resolved_anchor.state else {
1515 continue;
1516 };
1517 anchors_observed.insert(tkey);
1518 let observed_hash = resolved_anchor.observed_hash;
1519 let anchor = resolved_anchor.anchor;
1520 match state {
1521 AnchorState::Resolves => {}
1522 AnchorState::Orphaned => existence.push((eid.as_ref().to_string(), anchor, state)),
1523 AnchorState::Drifted | AnchorState::Recheck => {
1524 if !anchor.class.is_hash_bearing() {
1527 continue;
1528 }
1529 if anchor.hash.is_none()
1530 && let Some(hash) = observed_hash
1531 {
1532 if backfill_seen.insert((eid.as_ref().to_string(), anchor.artifact.clone())) {
1535 hash_backfill.push(ObservedArtifactHash {
1536 entity: eid.as_ref().to_string(),
1537 artifact: anchor.artifact.clone(),
1538 hash,
1539 });
1540 }
1541 continue;
1542 }
1543 candidates.push((eid.as_ref().to_string(), anchor, state));
1544 }
1545 }
1546 }
1547 for (entity, anchor, state) in &existence {
1548 if let Some(f) = adjudicate_anchor(&key, &facet, entity, anchor, *state, &now) {
1549 findings.push(f);
1550 }
1551 }
1552 let window: Option<BTreeSet<String>> = if full || cap == 0 {
1558 None
1559 } else {
1560 let mut keys: Vec<String> = candidates
1561 .iter()
1562 .map(|(e, a, _)| candidate_key(e, a))
1563 .collect();
1564 keys.sort();
1565 keys.dedup();
1566 next_rotation_batch(
1567 &cache_root,
1568 &binding_id,
1569 ROTATION_ANCHOR_ADJUDICATION,
1570 keys,
1571 cap as usize,
1572 )
1573 .map(|b| b.files.into_iter().collect())
1574 };
1575 findings.extend(adjudicate_candidates(
1576 &key,
1577 &facet,
1578 &candidates,
1579 window.as_ref(),
1580 &now,
1581 ));
1582
1583 let sample_files: Vec<String> = if full_resync.is_full_walk() {
1591 let mut all = full_walk_files;
1594 all.sort();
1595 all.dedup();
1596 all
1597 } else {
1598 next_batch(engine, resolved, workspace_root, &cache_root, sample_batch)
1599 .map(|b| b.files)
1600 .unwrap_or_default()
1601 };
1602 let this_binding = binding_hash_of(binding, resolved);
1610 let entity_end_reconciled = engine
1615 .entity_set_is_reconcilable(&resolved.destination_mem)
1616 .is_ok();
1617 let covered_now = |artifact: &str| {
1618 engine
1619 .anchors_referencing_artifact(artifact)
1620 .iter()
1621 .any(|(eid, a)| {
1622 eid.mem() == resolved.destination_mem.as_str()
1623 && a.binding
1624 .as_deref()
1625 .map(|b| b == this_binding.as_str())
1626 .unwrap_or(true)
1627 && (!entity_end_reconciled || !engine.entity_is_absent(eid))
1628 })
1629 };
1630 let excluded: BTreeSet<String> =
1638 crate::ingest::advance::read_advance_store(workspace_root, &mem, &name)
1639 .ok()
1640 .flatten()
1641 .map(|state| state.exclusions.keys().cloned().collect())
1642 .unwrap_or_default();
1643 for file in &sample_files {
1644 if !covered_now(file) && !excluded.contains(file) {
1645 findings.push(Finding {
1646 key: key.clone(),
1647 facet: facet.clone(),
1648 target: FindingTarget::Artifact {
1649 artifact: file.clone(),
1650 },
1651 class: FindingClass::Uncovered,
1652 detail: "source artifact in scope has no anchor in the destination mem".to_string(),
1653 created_at: now.clone(),
1654 });
1655 }
1656 }
1657
1658 let mut store = read_findings_store(workspace_root, &mem, &name)
1665 .map_err(FindingsError::Store)?
1666 .unwrap_or_else(|| FindingsStore {
1667 binding: binding_id.clone(),
1668 ..Default::default()
1669 });
1670 let mut s_d: BTreeSet<String> = BTreeSet::new();
1671 for source in &resolved.sources {
1672 if let ResolvedSource::Primary(p) = source
1673 && medium_capabilities(p.medium_type).enumerable
1674 {
1675 s_d.extend(enumerate_source_artifacts(
1676 engine,
1677 p,
1678 &resolved.deny_paths,
1679 workspace_root,
1680 ));
1681 }
1682 }
1683 let obs = PassObservation {
1684 anchors_observed,
1685 anchors_existing,
1686 files_observed: sample_files.into_iter().collect(),
1687 s_d,
1688 };
1689 let prior = store.current(&key).to_vec();
1690 let findings = merge_with_prior(findings, &prior, &obs, covered_now);
1691
1692 let backlog = findings
1693 .iter()
1694 .filter(|f| f.class == FindingClass::QueuedForAdjudication)
1695 .count();
1696
1697 let recorded = findings.len();
1700 store.record(key.clone(), now, findings);
1701 let superseded = store.superseded(&key).len();
1702 write_findings_store(workspace_root, &mem, &name, &store).map_err(FindingsError::Store)?;
1703
1704 Ok(VerifyOutcome {
1705 binding: binding_id,
1706 key,
1707 recorded,
1708 superseded,
1709 backlog,
1710 full_resync,
1711 facet_heads,
1712 hash_backfill,
1713 })
1714}
1715
1716fn medium_type_wire(t: crate::pipeline::MediumType) -> String {
1719 serde_json::to_value(t)
1720 .ok()
1721 .and_then(|v| v.as_str().map(str::to_string))
1722 .unwrap_or_default()
1723}
1724
1725#[cfg(test)]
1726mod tests {
1727 use super::*;
1728 use crate::anchor::{Anchor, AnchorGrain, AnchorHashStability, AnchorProvenanceClass};
1729
1730 fn key(hash: &str, head: &str) -> FindingKey {
1731 FindingKey {
1732 binding_hash: hash.to_string(),
1733 source_head: head.to_string(),
1734 }
1735 }
1736
1737 fn anchor(class: AnchorProvenanceClass) -> Anchor {
1738 Anchor {
1739 artifact: "src/lib.rs".to_string(),
1740 grain: AnchorGrain::File,
1741 class,
1742 at_version: None,
1743 hash: if class.is_hash_bearing() {
1744 Some("h1".to_string())
1745 } else {
1746 None
1747 },
1748 hash_stability: AnchorHashStability::Stable,
1749 derived_from: Vec::new(),
1750 binding: None,
1751 source: None,
1752 span_unvalidated: false,
1753 hash_source: None,
1754 }
1755 }
1756
1757 #[test]
1760 fn store_round_trips_on_disk_and_delete_is_idempotent() {
1761 let tmp = tempfile::tempdir().unwrap();
1762 let root = tmp.path();
1763 assert!(
1764 read_findings_store(root, "engine", "graph")
1765 .unwrap()
1766 .is_none()
1767 );
1768
1769 let mut store = FindingsStore {
1770 binding: "engine/graph".to_string(),
1771 ..Default::default()
1772 };
1773 let k = key("hashA", "head1");
1774 store.record(
1775 k.clone(),
1776 "1".to_string(),
1777 vec![Finding {
1778 key: k.clone(),
1779 facet: "src".to_string(),
1780 target: FindingTarget::Artifact {
1781 artifact: "src/a.rs".to_string(),
1782 },
1783 class: FindingClass::Uncovered,
1784 detail: "d".to_string(),
1785 created_at: "1".to_string(),
1786 }],
1787 );
1788 write_findings_store(root, "engine", "graph", &store).unwrap();
1789 assert!(findings_store_path(root, "engine", "graph").exists());
1790
1791 let ignore = root
1794 .join(WORKSPACE_STORE_DIR)
1795 .join(STATE_DIR)
1796 .join(FINDINGS_DIR)
1797 .join(".gitignore");
1798 assert_eq!(std::fs::read_to_string(&ignore).unwrap(), "*\n");
1799
1800 let back = read_findings_store(root, "engine", "graph")
1802 .unwrap()
1803 .unwrap();
1804 assert_eq!(back, store);
1805 assert_eq!(back.current(&k).len(), 1);
1806
1807 delete_findings_store(root, "engine", "graph").unwrap();
1808 assert!(
1809 read_findings_store(root, "engine", "graph")
1810 .unwrap()
1811 .is_none()
1812 );
1813 delete_findings_store(root, "engine", "graph").unwrap();
1815 }
1816
1817 #[test]
1820 fn changed_binding_hash_supersedes_prior_findings() {
1821 let mut store = FindingsStore::default();
1822 let old = key("hashOLD", "head1");
1823 let new = key("hashNEW", "head1");
1824 let f_old = Finding {
1825 key: old.clone(),
1826 facet: "src".to_string(),
1827 target: FindingTarget::Artifact {
1828 artifact: "src/old.rs".to_string(),
1829 },
1830 class: FindingClass::Uncovered,
1831 detail: "old".to_string(),
1832 created_at: "1".to_string(),
1833 };
1834 store.record(old.clone(), "1".to_string(), vec![f_old.clone()]);
1835
1836 store.record(new.clone(), "2".to_string(), Vec::new());
1838 assert!(store.current(&new).is_empty(), "new key has its own view");
1839 let superseded = store.superseded(&new);
1840 assert_eq!(superseded.len(), 1, "old batch is segregated as superseded");
1841 assert_eq!(superseded[0], &f_old);
1842 assert!(!store.current(&new).contains(&f_old));
1844 }
1845
1846 #[test]
1854 fn impl_version_bump_invalidates_findings_by_construction() {
1855 use crate::binding::{
1856 PREPARATION_IMPL_VERSION, ScaffoldParams, hash_binding, hash_binding_at_impl_version,
1857 scaffold_binding,
1858 };
1859 let binding = scaffold_binding(ScaffoldParams {
1860 destination_mem: "plugin",
1861 source_name: "source-tree",
1862 pointer: "../public",
1863 medium_type: crate::pipeline::MediumType::Codebase,
1864 intent: None,
1865 additional_deny_paths: Vec::new(),
1866 })
1867 .binding;
1868 assert!(binding.sources[0].preparation.is_none());
1869 let _ = PREPARATION_IMPL_VERSION;
1873 let old = key(&hash_binding_at_impl_version(&binding, 0), "head1");
1874 let live = key(&hash_binding(&binding), "head1");
1875 assert_ne!(old.binding_hash, live.binding_hash);
1876
1877 let mut store = FindingsStore::default();
1878 let f_old = Finding {
1879 key: old.clone(),
1880 facet: "source-tree".to_string(),
1881 target: FindingTarget::Artifact {
1882 artifact: "src/old.rs".to_string(),
1883 },
1884 class: FindingClass::Uncovered,
1885 detail: "recorded before the bump".to_string(),
1886 created_at: "1".to_string(),
1887 };
1888 store.record(old.clone(), "1".to_string(), vec![f_old.clone()]);
1889
1890 assert!(
1891 store.current(&live).is_empty(),
1892 "a finding keyed on the pre-bump hash is invalid under the live hash"
1893 );
1894 assert_eq!(store.superseded(&live), vec![&f_old]);
1895 assert_eq!(
1896 store.current(&old),
1897 &[f_old.clone()][..],
1898 "nothing is deleted"
1899 );
1900 }
1901
1902 #[test]
1909 fn moved_source_head_keeps_findings_current_until_superseded() {
1910 let mut store = FindingsStore::default();
1911 let before = key("hashA", "head1");
1912 let after = key("hashA", "head2");
1913 let f = Finding {
1914 key: before.clone(),
1915 facet: "src".to_string(),
1916 target: FindingTarget::Anchor {
1917 entity: "engine--e".to_string(),
1918 artifact: "src/x.rs".to_string(),
1919 },
1920 class: FindingClass::UnresolvableAnchor,
1921 detail: "gone".to_string(),
1922 created_at: "1".to_string(),
1923 };
1924 store.record(before.clone(), "1".to_string(), vec![f.clone()]);
1925
1926 assert_eq!(store.current(&after), std::slice::from_ref(&f));
1929 assert_eq!(store.current(&after)[0].key.source_head, "head1");
1930 assert!(store.superseded(&after).is_empty());
1931
1932 store.record(after.clone(), "2".to_string(), Vec::new());
1935 assert!(store.current(&after).is_empty());
1936 assert!(store.current(&before).is_empty(), "at the old head too");
1937 assert_eq!(store.batches.len(), 1, "one batch per hash(D)");
1938 }
1939
1940 #[test]
1948 fn legacy_per_head_store_loads_and_presents_head_agnostically() {
1949 let tmp = tempfile::tempdir().unwrap();
1950 let root = tmp.path();
1951 let path = findings_store_path(root, "engine", "graph");
1952 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1953 std::fs::write(
1958 &path,
1959 r#"{
1960 "binding": "engine/graph",
1961 "batches": [
1962 {
1963 "key": { "binding_hash": "hashOLD", "source_head": "src=aaa" },
1964 "recorded_at": "100",
1965 "findings": [
1966 {
1967 "key": { "binding_hash": "hashOLD", "source_head": "src=aaa" },
1968 "facet": "src",
1969 "target": { "kind": "artifact", "artifact": "src/old.rs" },
1970 "class": "uncovered",
1971 "detail": "old declaration",
1972 "created_at": "100"
1973 }
1974 ]
1975 },
1976 {
1977 "key": { "binding_hash": "hashCUR", "source_head": "src=bbb" },
1978 "recorded_at": "200",
1979 "findings": [
1980 {
1981 "key": { "binding_hash": "hashCUR", "source_head": "src=bbb" },
1982 "facet": "src",
1983 "target": { "kind": "artifact", "artifact": "src/resolved-at-ccc.rs" },
1984 "class": "uncovered",
1985 "detail": "was open at bbb, absent from the ccc batch",
1986 "created_at": "200"
1987 }
1988 ]
1989 },
1990 {
1991 "key": { "binding_hash": "hashCUR", "source_head": "src=ccc" },
1992 "recorded_at": "300",
1993 "findings": [
1994 {
1995 "key": { "binding_hash": "hashCUR", "source_head": "src=ccc" },
1996 "facet": "src",
1997 "target": { "kind": "anchor", "entity": "engine--e", "artifact": "src/x.rs" },
1998 "class": "unresolvable-anchor",
1999 "detail": "gone",
2000 "created_at": "300"
2001 }
2002 ]
2003 }
2004 ]
2005 }"#,
2006 )
2007 .unwrap();
2008
2009 let mut store = read_findings_store(root, "engine", "graph")
2010 .unwrap()
2011 .expect("the legacy on-disk format loads as-is");
2012 assert_eq!(store.binding, "engine/graph");
2013 assert_eq!(store.batches.len(), 3, "loaded without loss");
2014
2015 let now = key("hashCUR", "src=ddd");
2018 let current = store.current(&now);
2019 assert_eq!(current.len(), 1);
2020 assert_eq!(current[0].detail, "gone");
2021 assert_eq!(
2022 current[0].key.source_head, "src=ccc",
2023 "the finding keeps the head it was observed at"
2024 );
2025 let superseded = store.superseded(&now);
2028 assert_eq!(superseded.len(), 2);
2029 assert!(
2030 !current.iter().any(|f| f.detail.contains("was open at bbb")),
2031 "the older same-hash batch was superseded at write time and is not resurrected"
2032 );
2033
2034 store.record(now.clone(), "400".to_string(), Vec::new());
2037 assert_eq!(store.batches.len(), 2, "hashCUR collapsed, hashOLD kept");
2038 assert_eq!(store.superseded(&now).len(), 1);
2039 }
2040
2041 #[test]
2046 fn merge_carries_unobserved_open_findings_and_closes_departed() {
2047 let k_old = key("h", "head1");
2048 let mk_artifact = |artifact: &str, detail: &str| Finding {
2049 key: k_old.clone(),
2050 facet: "src".to_string(),
2051 target: FindingTarget::Artifact {
2052 artifact: artifact.to_string(),
2053 },
2054 class: FindingClass::Uncovered,
2055 detail: detail.to_string(),
2056 created_at: "1".to_string(),
2057 };
2058 let anchor_finding = Finding {
2059 key: k_old.clone(),
2060 facet: "src".to_string(),
2061 target: FindingTarget::Anchor {
2062 entity: "engine--gone".to_string(),
2063 artifact: "src/gone.rs".to_string(),
2064 },
2065 class: FindingClass::UnresolvableAnchor,
2066 detail: "anchor since removed from the mem".to_string(),
2067 created_at: "1".to_string(),
2068 };
2069 let prior = vec![
2070 mk_artifact("src/unsampled.rs", "still open, not in this window"),
2071 mk_artifact("src/departed.rs", "left S(D)"),
2072 mk_artifact("src/now-covered.rs", "gained an anchor since"),
2073 mk_artifact("src/observed-clean.rs", "re-sampled and now covered"),
2074 anchor_finding,
2075 ];
2076 let obs = PassObservation {
2077 anchors_observed: BTreeSet::new(),
2078 anchors_existing: BTreeSet::new(), files_observed: ["src/observed-clean.rs".to_string()].into(),
2080 s_d: [
2081 "src/unsampled.rs".to_string(),
2082 "src/now-covered.rs".to_string(),
2083 "src/observed-clean.rs".to_string(),
2084 ]
2085 .into(),
2086 };
2087 let merged = merge_with_prior(Vec::new(), &prior, &obs, |artifact| {
2088 artifact == "src/now-covered.rs" || artifact == "src/observed-clean.rs"
2089 });
2090 assert_eq!(merged.len(), 1, "only the still-open unsampled one carries");
2091 assert_eq!(
2092 merged[0].target,
2093 FindingTarget::Artifact {
2094 artifact: "src/unsampled.rs".to_string()
2095 }
2096 );
2097 assert_eq!(
2098 merged[0].key.source_head, "head1",
2099 "a carried finding keeps the head it was observed at"
2100 );
2101 }
2102
2103 #[test]
2108 fn merge_deferral_never_downgrades_prior_adjudication() {
2109 let k_old = key("h", "head1");
2110 let k_new = key("h", "head2");
2111 let target = FindingTarget::Anchor {
2112 entity: "engine--e".to_string(),
2113 artifact: "src/x.rs".to_string(),
2114 };
2115 let prior_drifted = Finding {
2116 key: k_old.clone(),
2117 facet: "src".to_string(),
2118 target: target.clone(),
2119 class: FindingClass::Drifted,
2120 detail: "adjudicated drifted at head1".to_string(),
2121 created_at: "1".to_string(),
2122 };
2123 let fresh_queued = Finding {
2124 key: k_new.clone(),
2125 facet: "src".to_string(),
2126 target: target.clone(),
2127 class: FindingClass::QueuedForAdjudication,
2128 detail: "deferred by the cap this run".to_string(),
2129 created_at: "2".to_string(),
2130 };
2131 let obs = PassObservation {
2132 anchors_observed: [target_key(&target)].into(),
2133 anchors_existing: [target_key(&target)].into(),
2134 files_observed: BTreeSet::new(),
2135 s_d: BTreeSet::new(),
2136 };
2137 let merged = merge_with_prior(
2138 vec![fresh_queued],
2139 std::slice::from_ref(&prior_drifted),
2140 &obs,
2141 |_| true,
2142 );
2143 assert_eq!(merged.len(), 1);
2144 assert_eq!(
2145 merged[0].class,
2146 FindingClass::Drifted,
2147 "the prior verdict stands over a deferral"
2148 );
2149 assert_eq!(merged[0].key.source_head, "head1");
2150 }
2151
2152 #[test]
2155 fn informed_by_anchor_never_drifts() {
2156 let k = key("h", "s");
2157 for class in [
2158 AnchorProvenanceClass::InformedBy,
2159 AnchorProvenanceClass::Authored,
2160 ] {
2161 let a = anchor(class);
2162 assert!(
2163 adjudicate_anchor(&k, "f", "engine--e", &a, AnchorState::Drifted, "1").is_none(),
2164 "{class:?} must not produce a drift finding"
2165 );
2166 assert!(
2167 adjudicate_anchor(&k, "f", "engine--e", &a, AnchorState::Recheck, "1").is_none(),
2168 "{class:?} must not produce a queued finding"
2169 );
2170 }
2171 }
2172
2173 #[test]
2176 fn hash_bearing_drifts_and_orphan_is_class_independent() {
2177 let k = key("h", "s");
2178 let anchored = anchor(AnchorProvenanceClass::Anchored);
2179 let drifted =
2180 adjudicate_anchor(&k, "f", "engine--e", &anchored, AnchorState::Drifted, "1").unwrap();
2181 assert_eq!(drifted.class, FindingClass::Drifted);
2182 assert_eq!(drifted.key, k, "the finding carries its recording key (A2)");
2183
2184 let queued =
2185 adjudicate_anchor(&k, "f", "engine--e", &anchored, AnchorState::Recheck, "1").unwrap();
2186 assert_eq!(queued.class, FindingClass::QueuedForAdjudication);
2187
2188 let informed = anchor(AnchorProvenanceClass::InformedBy);
2190 let orphan =
2191 adjudicate_anchor(&k, "f", "engine--e", &informed, AnchorState::Orphaned, "1").unwrap();
2192 assert_eq!(orphan.class, FindingClass::UnresolvableAnchor);
2193
2194 assert!(
2196 adjudicate_anchor(&k, "f", "engine--e", &anchored, AnchorState::Resolves, "1")
2197 .is_none()
2198 );
2199 }
2200
2201 #[test]
2203 fn finding_class_wire_round_trips() {
2204 for w in FindingClass::WIRE_VALUES {
2205 let c = FindingClass::from_wire(w).expect("known wire value");
2206 assert_eq!(c.as_wire(), *w);
2207 }
2208 assert!(FindingClass::from_wire("nonsense").is_none());
2209 }
2210
2211 #[test]
2213 fn malformed_binding_id_refuses() {
2214 assert!(matches!(
2215 split_binding_id("../escape"),
2216 Err(FindingsError::MalformedId(_))
2217 ));
2218 assert!(matches!(
2219 split_binding_id("no-slash"),
2220 Err(FindingsError::MalformedId(_))
2221 ));
2222 assert_eq!(
2223 split_binding_id("engine/graph").unwrap(),
2224 ("engine".to_string(), "graph".to_string())
2225 );
2226 }
2227
2228 use crate::anchor::AnchorSidecar;
2231 use crate::binding::{
2232 BINDING_VERSION, BuildMode, BuildOperation, CoverageSemantics, DEFAULT_ADJUDICATION_CAP,
2233 DEFAULT_FULL_RESYNC_EVERY, Operations, VerifyOperation,
2234 };
2235 use crate::ingest::resolve::resolve_binding_run;
2236 use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode};
2237 use crate::pipeline_store::{load_pipeline_configs, write_binding};
2238 use crate::workspace::{
2239 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
2240 };
2241 use crate::workspace_store::WorkspaceStoreAdapter;
2242
2243 #[test]
2251 fn verify_persists_findings_readable_fresh() {
2252 let tmp = tempfile::tempdir().unwrap();
2253 let root = tmp.path();
2254 let mem_dir = root.join("mem");
2255 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2256 std::fs::write(
2257 mem_dir.join(".memstead").join("config.json"),
2258 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2259 )
2260 .unwrap();
2261
2262 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2265 std::fs::write(
2266 root.join(".memstead").join("workspace.toml"),
2267 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2268 )
2269 .unwrap();
2270 let mount = Mount {
2271 mem: "engine".to_string(),
2272 schema: Some("default@1.0.0".parse().unwrap()),
2273 storage: MountStorage::Folder {
2274 path: mem_dir.clone(),
2275 },
2276 capability: MountCapability::Write,
2277 lifecycle: MountLifecycle::Eager,
2278 cross_linkable: false,
2279 migration_target: None,
2280 };
2281 crate::FileWorkspaceStore::new()
2282 .save_state(
2283 root,
2284 &Workspace {
2285 mounts: vec![mount],
2286 settings: WorkspaceSettings::default(),
2287 },
2288 )
2289 .unwrap();
2290
2291 let out = std::process::Command::new("git")
2295 .args(["init", "-q"])
2296 .current_dir(root)
2297 .output()
2298 .unwrap();
2299 assert!(out.status.success());
2300 std::fs::create_dir_all(root.join("src")).unwrap();
2301 std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
2302 std::fs::write(root.join("src").join("uncovered.rs"), "fn b() {}\n").unwrap();
2303
2304 let mk = |artifact: &str, class: AnchorProvenanceClass| Anchor {
2307 artifact: artifact.to_string(),
2308 grain: AnchorGrain::File,
2309 class,
2310 at_version: None,
2311 hash: class.is_hash_bearing().then(|| "recorded".to_string()),
2312 hash_stability: AnchorHashStability::Stable,
2313 derived_from: Vec::new(),
2314 binding: None,
2315 source: None,
2316 span_unvalidated: false,
2317 hash_source: None,
2318 };
2319 std::fs::write(
2323 mem_dir.join("e.md"),
2324 "---\ntype: decision\n---\n\n# E\n\n## Decision\n\nBody.\n",
2325 )
2326 .unwrap();
2327 let mut sidecar = AnchorSidecar::default();
2328 sidecar.set(
2329 "engine--e",
2330 vec![
2331 mk("src/present.rs", AnchorProvenanceClass::Anchored), mk("src/gone.rs", AnchorProvenanceClass::Anchored), mk("src/present.rs", AnchorProvenanceClass::InformedBy), ],
2335 );
2336 std::fs::write(
2337 mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
2338 sidecar.to_bytes(),
2339 )
2340 .unwrap();
2341
2342 write_binding(
2344 root,
2345 "engine",
2346 "graph",
2347 &Binding {
2348 version: BINDING_VERSION,
2349 intent: None,
2350 sources: vec![crate::pipeline::Source {
2351 name: "graph".to_string(),
2352 medium_type: MediumType::Codebase,
2353 pointer: String::new(),
2354 change_detection: Some("git".to_string()),
2355 scope: vec![PatternEntry {
2356 path: "src/**/*.rs".to_string(),
2357 mode: PatternMode::Allow,
2358 }],
2359 engagement: None,
2360 preparation: None,
2361 }],
2362 reference_mems: Vec::new(),
2363 destination_mem: "engine".to_string(),
2364 deny_paths: Vec::new(),
2365 coverage_semantics: None,
2366 rules: None,
2367 prune: None,
2368 operations: Operations {
2369 build: Some(BuildOperation {
2370 mode: BuildMode::Discovery,
2371 trigger: IngestTrigger::Loop,
2372 batch_size: 20,
2373 post_actions: None,
2374 }),
2375 sync: None,
2376 verify: Some(VerifyOperation {
2377 trigger: IngestTrigger::Manual,
2378 batch_size: 20,
2379 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2380 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2381 }),
2382 },
2383 },
2384 )
2385 .unwrap();
2386
2387 let engine = Engine::from_workspace_root(root).unwrap();
2388
2389 let configs = load_pipeline_configs(root).unwrap();
2390 let binding = &configs.bindings[0].config;
2391 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
2392
2393 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2395 assert!(
2396 outcome.recorded >= 3,
2397 "orphan + drifted + uncovered at least"
2398 );
2399 assert_eq!(outcome.superseded, 0, "no prior key yet");
2400 assert_eq!(
2401 outcome.backlog, 0,
2402 "the mismatching hash adjudicated deterministically — nothing queued"
2403 );
2404 assert!(
2405 outcome.hash_backfill.is_empty(),
2406 "every hash-bearing anchor already carries a recorded hash — nothing to backfill"
2407 );
2408
2409 let store = read_findings_store(root, "engine", "graph")
2411 .unwrap()
2412 .unwrap();
2413 let current = store.current(&outcome.key);
2414 assert_eq!(current.len(), outcome.recorded);
2415
2416 let has = |c: FindingClass, art: &str| {
2417 current.iter().any(|f| {
2418 f.class == c
2419 && match &f.target {
2420 FindingTarget::Anchor { artifact, .. } => artifact == art,
2421 FindingTarget::Artifact { artifact } => artifact == art,
2422 }
2423 })
2424 };
2425 assert!(has(FindingClass::UnresolvableAnchor, "src/gone.rs"));
2426 assert!(
2427 has(FindingClass::Drifted, "src/present.rs"),
2428 "recorded-hash mismatch on a stable medium adjudicates drifted deterministically"
2429 );
2430 assert!(has(FindingClass::Uncovered, "src/uncovered.rs"));
2431 assert!(
2435 !current
2436 .iter()
2437 .any(|f| f.class == FindingClass::QueuedForAdjudication
2438 || f.class == FindingClass::Wrong),
2439 "deterministic adjudication leaves nothing queued"
2440 );
2441 assert!(!has(FindingClass::Uncovered, "src/present.rs"));
2443 }
2444
2445 #[test]
2451 fn finding_recorded_at_old_head_presents_in_brief_at_new_head() {
2452 use crate::ingest::render::render_sync_brief_for;
2453
2454 let tmp = tempfile::tempdir().unwrap();
2455 let root = tmp.path();
2456 let mem_dir = root.join("mem");
2457 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2458 std::fs::write(
2459 mem_dir.join(".memstead").join("config.json"),
2460 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2461 )
2462 .unwrap();
2463 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2464 std::fs::write(
2465 root.join(".memstead").join("workspace.toml"),
2466 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2467 )
2468 .unwrap();
2469 let mount = Mount {
2470 mem: "engine".to_string(),
2471 schema: Some("default@1.0.0".parse().unwrap()),
2472 storage: MountStorage::Folder {
2473 path: mem_dir.clone(),
2474 },
2475 capability: MountCapability::Write,
2476 lifecycle: MountLifecycle::Eager,
2477 cross_linkable: false,
2478 migration_target: None,
2479 };
2480 crate::FileWorkspaceStore::new()
2481 .save_state(
2482 root,
2483 &Workspace {
2484 mounts: vec![mount],
2485 settings: WorkspaceSettings::default(),
2486 },
2487 )
2488 .unwrap();
2489
2490 let git = |args: &[&str]| {
2492 let out = std::process::Command::new("git")
2493 .args(args)
2494 .current_dir(root)
2495 .env("GIT_AUTHOR_NAME", "t")
2496 .env("GIT_AUTHOR_EMAIL", "t@t")
2497 .env("GIT_COMMITTER_NAME", "t")
2498 .env("GIT_COMMITTER_EMAIL", "t@t")
2499 .output()
2500 .unwrap();
2501 assert!(
2502 out.status.success(),
2503 "git {args:?}: {}",
2504 String::from_utf8_lossy(&out.stderr)
2505 );
2506 };
2507 git(&["init", "-q"]);
2508 std::fs::create_dir_all(root.join("src")).unwrap();
2509 std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
2510 git(&["add", "-A"]);
2511 git(&["commit", "-qm", "head-a"]);
2512
2513 let mk = |artifact: &str| Anchor {
2516 artifact: artifact.to_string(),
2517 grain: AnchorGrain::File,
2518 class: AnchorProvenanceClass::InformedBy,
2519 at_version: None,
2520 hash: None,
2521 hash_stability: AnchorHashStability::Stable,
2522 derived_from: Vec::new(),
2523 binding: None,
2524 source: None,
2525 span_unvalidated: false,
2526 hash_source: None,
2527 };
2528 std::fs::write(
2532 mem_dir.join("e.md"),
2533 "---\ntype: decision\n---\n\n# E\n\n## Decision\n\nBody.\n",
2534 )
2535 .unwrap();
2536 let mut sidecar = AnchorSidecar::default();
2537 sidecar.set("engine--e", vec![mk("src/present.rs"), mk("src/gone.rs")]);
2538 std::fs::write(
2539 mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
2540 sidecar.to_bytes(),
2541 )
2542 .unwrap();
2543
2544 write_binding(
2545 root,
2546 "engine",
2547 "graph",
2548 &Binding {
2549 version: BINDING_VERSION,
2550 intent: None,
2551 sources: vec![crate::pipeline::Source {
2552 name: "graph".to_string(),
2553 medium_type: MediumType::Codebase,
2554 pointer: String::new(),
2555 change_detection: Some("git".to_string()),
2556 scope: vec![PatternEntry {
2557 path: "src/**/*.rs".to_string(),
2558 mode: PatternMode::Allow,
2559 }],
2560 engagement: None,
2561 preparation: None,
2562 }],
2563 reference_mems: Vec::new(),
2564 destination_mem: "engine".to_string(),
2565 deny_paths: Vec::new(),
2566 coverage_semantics: None,
2567 rules: None,
2568 prune: None,
2569 operations: Operations {
2570 build: None,
2571 sync: Some(crate::binding::SyncOperation {
2572 trigger: IngestTrigger::Manual,
2573 batch_size: 20,
2574 }),
2575 verify: Some(VerifyOperation {
2576 trigger: IngestTrigger::Manual,
2577 batch_size: 20,
2578 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2579 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2580 }),
2581 },
2582 },
2583 )
2584 .unwrap();
2585
2586 let configs = load_pipeline_configs(root).unwrap();
2588 let binding = &configs.bindings[0].config;
2589 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
2590 let head_a_outcome = {
2591 let engine = Engine::from_workspace_root(root).unwrap();
2592 verify_binding(&engine, root, binding, &resolved).unwrap()
2593 };
2594 assert!(
2595 head_a_outcome.key.source_head.contains("graph="),
2596 "the run observed a facet head"
2597 );
2598
2599 std::fs::write(root.join("src").join("present.rs"), "fn a() {} // more\n").unwrap();
2601 git(&["add", "-A"]);
2602 git(&["commit", "-qm", "head-b"]);
2603
2604 {
2607 let engine = Engine::from_workspace_root(root).unwrap();
2608 let (key_b, findings) = current_findings(&engine, root, binding, &resolved).unwrap();
2609 assert_ne!(
2610 key_b.source_head, head_a_outcome.key.source_head,
2611 "the head really moved"
2612 );
2613 assert_eq!(findings.len(), 1);
2614 assert_eq!(findings[0].class, FindingClass::UnresolvableAnchor);
2615 assert_eq!(
2616 findings[0].key.source_head, head_a_outcome.key.source_head,
2617 "the finding still records the head it was observed at"
2618 );
2619
2620 let brief = render_sync_brief_for(&engine, root, "engine/graph").unwrap();
2621 assert!(brief.contains("## Open findings to repair"));
2622 assert!(brief.contains("src/gone.rs"));
2623 }
2624
2625 std::fs::write(root.join("src").join("gone.rs"), "fn g() {}\n").unwrap();
2628 git(&["add", "-A"]);
2629 git(&["commit", "-qm", "head-c"]);
2630 {
2631 let engine = Engine::from_workspace_root(root).unwrap();
2632 verify_binding(&engine, root, binding, &resolved).unwrap();
2633 }
2634 {
2636 let engine = Engine::from_workspace_root(root).unwrap();
2637 let (_key, findings) = current_findings(&engine, root, binding, &resolved).unwrap();
2638 assert!(
2639 findings
2640 .iter()
2641 .all(|f| f.class != FindingClass::UnresolvableAnchor),
2642 "the resolved orphan finding must not re-present: {findings:?}"
2643 );
2644 }
2645 }
2646
2647 #[test]
2662 fn hashless_anchor_backfills_once_then_drift_adjudicates_deterministically() {
2663 let tmp = tempfile::tempdir().unwrap();
2664 let root = tmp.path();
2665 let mem_dir = root.join("mem");
2666 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2667 std::fs::write(
2668 mem_dir.join(".memstead").join("config.json"),
2669 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2670 )
2671 .unwrap();
2672 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2673 std::fs::write(
2674 root.join(".memstead").join("workspace.toml"),
2675 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2676 )
2677 .unwrap();
2678 let mount = Mount {
2679 mem: "engine".to_string(),
2680 schema: Some("default@1.0.0".parse().unwrap()),
2681 storage: MountStorage::Folder {
2682 path: mem_dir.clone(),
2683 },
2684 capability: MountCapability::Write,
2685 lifecycle: MountLifecycle::Eager,
2686 cross_linkable: false,
2687 migration_target: None,
2688 };
2689 crate::FileWorkspaceStore::new()
2690 .save_state(
2691 root,
2692 &Workspace {
2693 mounts: vec![mount],
2694 settings: WorkspaceSettings::default(),
2695 },
2696 )
2697 .unwrap();
2698
2699 let git = |args: &[&str]| {
2701 let out = std::process::Command::new("git")
2702 .args(args)
2703 .current_dir(root)
2704 .env("GIT_AUTHOR_NAME", "t")
2705 .env("GIT_AUTHOR_EMAIL", "t@t")
2706 .env("GIT_COMMITTER_NAME", "t")
2707 .env("GIT_COMMITTER_EMAIL", "t@t")
2708 .output()
2709 .unwrap();
2710 assert!(
2711 out.status.success(),
2712 "git {args:?}: {}",
2713 String::from_utf8_lossy(&out.stderr)
2714 );
2715 };
2716 git(&["init", "-q"]);
2717 std::fs::create_dir_all(root.join("src")).unwrap();
2718 std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
2719 std::fs::write(root.join("src").join("other.rs"), "fn o() {}\n").unwrap();
2720 git(&["add", "-A"]);
2721 git(&["commit", "-qm", "head-a"]);
2722
2723 let mk = |artifact: &str, class: AnchorProvenanceClass, stab: AnchorHashStability| Anchor {
2727 artifact: artifact.to_string(),
2728 grain: AnchorGrain::File,
2729 class,
2730 at_version: None,
2731 hash: None,
2732 hash_stability: stab,
2733 derived_from: if class == AnchorProvenanceClass::Derived {
2734 vec!["src/present.rs".to_string()]
2735 } else {
2736 Vec::new()
2737 },
2738 binding: None,
2739 source: None,
2740 span_unvalidated: false,
2741 hash_source: None,
2742 };
2743 use AnchorHashStability::{Stable, Unstable};
2744 std::fs::write(
2748 mem_dir.join("e.md"),
2749 "---\ntype: decision\n---\n\n# E\n\n## Decision\n\nBody.\n",
2750 )
2751 .unwrap();
2752 let mut sidecar = AnchorSidecar::default();
2753 sidecar.set(
2754 "engine--e",
2755 vec![
2756 mk("src/present.rs", AnchorProvenanceClass::Anchored, Stable),
2757 mk("src/present.rs", AnchorProvenanceClass::Derived, Stable),
2758 mk("src/other.rs", AnchorProvenanceClass::Anchored, Unstable),
2759 mk("src/present.rs", AnchorProvenanceClass::Authored, Stable),
2760 mk("src/present.rs", AnchorProvenanceClass::InformedBy, Stable),
2761 ],
2762 );
2763 std::fs::write(
2764 mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
2765 sidecar.to_bytes(),
2766 )
2767 .unwrap();
2768
2769 write_binding(
2770 root,
2771 "engine",
2772 "graph",
2773 &Binding {
2774 version: BINDING_VERSION,
2775 intent: None,
2776 sources: vec![crate::pipeline::Source {
2777 name: "graph".to_string(),
2778 medium_type: MediumType::Codebase,
2779 pointer: String::new(),
2780 change_detection: Some("git".to_string()),
2781 scope: vec![PatternEntry {
2782 path: "src/**/*.rs".to_string(),
2783 mode: PatternMode::Allow,
2784 }],
2785 engagement: None,
2786 preparation: None,
2787 }],
2788 reference_mems: Vec::new(),
2789 destination_mem: "engine".to_string(),
2790 deny_paths: Vec::new(),
2791 coverage_semantics: None,
2792 rules: None,
2793 prune: None,
2794 operations: Operations {
2795 build: None,
2796 sync: None,
2797 verify: Some(VerifyOperation {
2798 trigger: IngestTrigger::Manual,
2799 batch_size: 20,
2800 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2801 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2802 }),
2803 },
2804 },
2805 )
2806 .unwrap();
2807
2808 let configs = load_pipeline_configs(root).unwrap();
2809 let binding = &configs.bindings[0].config;
2810 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
2811
2812 {
2814 let mut engine = Engine::from_workspace_root(root).unwrap();
2815 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2816 let mut backfilled: Vec<(&str, &str)> = outcome
2819 .hash_backfill
2820 .iter()
2821 .map(|b| (b.entity.as_str(), b.artifact.as_str()))
2822 .collect();
2823 backfilled.sort();
2824 backfilled.dedup();
2825 assert_eq!(
2826 backfilled,
2827 vec![
2828 ("engine--e", "src/other.rs"),
2829 ("engine--e", "src/present.rs"),
2830 ],
2831 "hash-bearing anchors backfill; authored/informed-by never appear"
2832 );
2833 assert_eq!(
2836 outcome.backlog, 0,
2837 "no recheck queue for backfilled anchors"
2838 );
2839 let store = read_findings_store(root, "engine", "graph")
2840 .unwrap()
2841 .unwrap();
2842 assert!(
2843 store
2844 .current(&outcome.key)
2845 .iter()
2846 .all(|f| !matches!(f.target, FindingTarget::Anchor { .. })),
2847 "no anchor finding on the backfill pass: {:?}",
2848 store.current(&outcome.key)
2849 );
2850
2851 let written =
2853 record_anchor_hash_backfill(&mut engine, "engine", &outcome, None).unwrap();
2854 assert_eq!(
2855 written, 3,
2856 "anchored + derived + unstable-anchored gain hashes"
2857 );
2858 }
2859
2860 let expected_present = crate::anchor::prepared_content_hash(
2863 &std::fs::read(root.join("src").join("present.rs")).unwrap(),
2864 );
2865 {
2866 let sc = AnchorSidecar::from_bytes(
2867 &std::fs::read(mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH)).unwrap(),
2868 )
2869 .unwrap();
2870 for a in sc.get("engine--e") {
2871 if a.class.is_hash_bearing() {
2872 assert!(a.hash.is_some(), "hash-bearing anchor backfilled: {a:?}");
2873 } else {
2874 assert!(a.hash.is_none(), "non-hash class never gains a hash: {a:?}");
2875 }
2876 if a.artifact == "src/present.rs" && a.class.is_hash_bearing() {
2877 assert_eq!(a.hash.as_deref(), Some(expected_present.as_str()));
2878 }
2879 }
2880 }
2881
2882 {
2884 let mut engine = Engine::from_workspace_root(root).unwrap();
2885 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2886 assert!(
2887 outcome.hash_backfill.is_empty(),
2888 "backfill happens once — a re-verify observes an empty worklist"
2889 );
2890 assert_eq!(outcome.backlog, 0, "steady state: nothing re-queues");
2891 let store = read_findings_store(root, "engine", "graph")
2892 .unwrap()
2893 .unwrap();
2894 assert!(
2895 store
2896 .current(&outcome.key)
2897 .iter()
2898 .all(|f| !matches!(f.target, FindingTarget::Anchor { .. })),
2899 "recorded hashes match the source — no anchor finding"
2900 );
2901 let written =
2902 record_anchor_hash_backfill(&mut engine, "engine", &outcome, None).unwrap();
2903 assert_eq!(written, 0, "no write, no commit on the idempotent pass");
2904 }
2905
2906 std::fs::write(
2908 root.join("src").join("present.rs"),
2909 "fn a() { /* changed */ }\n",
2910 )
2911 .unwrap();
2912 std::fs::write(
2913 root.join("src").join("other.rs"),
2914 "fn o() { /* changed */ }\n",
2915 )
2916 .unwrap();
2917 git(&["add", "-A"]);
2918 git(&["commit", "-qm", "head-b"]);
2919
2920 {
2923 let engine = Engine::from_workspace_root(root).unwrap();
2924 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2925 assert!(
2926 outcome.hash_backfill.is_empty(),
2927 "recorded hashes are never overwritten by observation"
2928 );
2929 let store = read_findings_store(root, "engine", "graph")
2930 .unwrap()
2931 .unwrap();
2932 let current = store.current(&outcome.key);
2933 let drifted: Vec<&Finding> = current
2934 .iter()
2935 .filter(|f| f.class == FindingClass::Drifted)
2936 .collect();
2937 assert_eq!(
2940 drifted.len(),
2941 2,
2942 "stable-medium mismatch → drifted: {current:?}"
2943 );
2944 assert!(drifted.iter().all(|f| matches!(
2945 &f.target,
2946 FindingTarget::Anchor { artifact, .. } if artifact == "src/present.rs"
2947 )));
2948 assert!(
2951 current
2952 .iter()
2953 .any(|f| f.class == FindingClass::QueuedForAdjudication
2954 && matches!(
2955 &f.target,
2956 FindingTarget::Anchor { artifact, .. } if artifact == "src/other.rs"
2957 )),
2958 "unstable medium resolves recheck (queued), not drifted: {current:?}"
2959 );
2960 assert!(
2961 !current.iter().any(|f| f.class == FindingClass::Drifted
2962 && matches!(
2963 &f.target,
2964 FindingTarget::Anchor { artifact, .. } if artifact == "src/other.rs"
2965 )),
2966 "an unstable hash break must never assert drift"
2967 );
2968 }
2969 }
2970
2971 #[test]
2976 fn backfill_writer_refuses_non_hash_classes_and_never_overwrites() {
2977 let tmp = tempfile::tempdir().unwrap();
2978 let root = tmp.path();
2979 let mem_dir = root.join("mem");
2980 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2981 std::fs::write(
2982 mem_dir.join(".memstead").join("config.json"),
2983 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2984 )
2985 .unwrap();
2986 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2987 std::fs::write(
2988 root.join(".memstead").join("workspace.toml"),
2989 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2990 )
2991 .unwrap();
2992 crate::FileWorkspaceStore::new()
2993 .save_state(
2994 root,
2995 &Workspace {
2996 mounts: vec![Mount {
2997 mem: "engine".to_string(),
2998 schema: Some("default@1.0.0".parse().unwrap()),
2999 storage: MountStorage::Folder {
3000 path: mem_dir.clone(),
3001 },
3002 capability: MountCapability::Write,
3003 lifecycle: MountLifecycle::Eager,
3004 cross_linkable: false,
3005 migration_target: None,
3006 }],
3007 settings: WorkspaceSettings::default(),
3008 },
3009 )
3010 .unwrap();
3011
3012 let anchor = |class: AnchorProvenanceClass, hash: Option<&str>| Anchor {
3013 artifact: "src/a.rs".to_string(),
3014 grain: AnchorGrain::File,
3015 class,
3016 at_version: None,
3017 hash: hash.map(str::to_string),
3018 hash_stability: AnchorHashStability::Stable,
3019 derived_from: Vec::new(),
3020 binding: None,
3021 source: None,
3022 span_unvalidated: false,
3023 hash_source: None,
3024 };
3025 std::fs::write(
3029 mem_dir.join("e.md"),
3030 "---\ntype: decision\n---\n\n# E\n\n## Decision\n\nBody.\n",
3031 )
3032 .unwrap();
3033 let mut sidecar = AnchorSidecar::default();
3034 sidecar.set(
3035 "engine--e",
3036 vec![
3037 anchor(AnchorProvenanceClass::Authored, None),
3038 anchor(AnchorProvenanceClass::InformedBy, None),
3039 anchor(AnchorProvenanceClass::Anchored, Some("recorded")),
3040 ],
3041 );
3042 std::fs::write(
3043 mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
3044 sidecar.to_bytes(),
3045 )
3046 .unwrap();
3047
3048 let mut engine = Engine::from_workspace_root(root).unwrap();
3049 let written = engine
3050 .record_anchor_observed_hashes(
3051 "engine",
3052 &[crate::anchor::ObservedArtifactHash {
3053 entity: "engine--e".to_string(),
3054 artifact: "src/a.rs".to_string(),
3055 hash: "observed".to_string(),
3056 }],
3057 None,
3058 )
3059 .unwrap();
3060 assert_eq!(
3061 written, 0,
3062 "non-hash classes refuse the hash; a recorded hash is never overwritten"
3063 );
3064 let sc = AnchorSidecar::from_bytes(
3065 &std::fs::read(mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH)).unwrap(),
3066 )
3067 .unwrap();
3068 for a in sc.get("engine--e") {
3069 match a.class {
3070 AnchorProvenanceClass::Anchored => {
3071 assert_eq!(a.hash.as_deref(), Some("recorded"), "baseline stands")
3072 }
3073 _ => assert!(a.hash.is_none(), "non-hash class stays hash-less: {a:?}"),
3074 }
3075 }
3076 }
3077
3078 #[test]
3094 fn verify_refuses_unreachable_source_with_typed_error() {
3095 let tmp = tempfile::tempdir().unwrap();
3096 let root = tmp.path();
3097 let mem_dir = root.join("mem");
3098 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3099 std::fs::write(
3100 mem_dir.join(".memstead").join("config.json"),
3101 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3102 )
3103 .unwrap();
3104 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3105 std::fs::write(
3106 root.join(".memstead").join("workspace.toml"),
3107 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3108 )
3109 .unwrap();
3110 let mount = Mount {
3111 mem: "engine".to_string(),
3112 schema: Some("default@1.0.0".parse().unwrap()),
3113 storage: MountStorage::Folder {
3114 path: mem_dir.clone(),
3115 },
3116 capability: MountCapability::Write,
3117 lifecycle: MountLifecycle::Eager,
3118 cross_linkable: false,
3119 migration_target: None,
3120 };
3121 crate::FileWorkspaceStore::new()
3122 .save_state(
3123 root,
3124 &Workspace {
3125 mounts: vec![mount],
3126 settings: WorkspaceSettings::default(),
3127 },
3128 )
3129 .unwrap();
3130
3131 write_binding(
3135 root,
3136 "engine",
3137 "gone",
3138 &Binding {
3139 version: BINDING_VERSION,
3140 intent: None,
3141 sources: vec![crate::pipeline::Source {
3142 name: "gone".to_string(),
3143 medium_type: MediumType::Codebase,
3144 pointer: "vanished-src".to_string(),
3145 change_detection: Some("git".to_string()),
3146 scope: vec![PatternEntry {
3147 path: "**/*.rs".to_string(),
3148 mode: PatternMode::Allow,
3149 }],
3150 engagement: None,
3151 preparation: None,
3152 }],
3153 reference_mems: Vec::new(),
3154 destination_mem: "engine".to_string(),
3155 deny_paths: Vec::new(),
3156 coverage_semantics: None,
3157 rules: None,
3158 prune: None,
3159 operations: Operations {
3160 build: None,
3161 sync: None,
3162 verify: Some(VerifyOperation {
3163 trigger: IngestTrigger::Manual,
3164 batch_size: 20,
3165 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
3166 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
3167 }),
3168 },
3169 },
3170 )
3171 .unwrap();
3172
3173 let engine = Engine::from_workspace_root(root).unwrap();
3174 let configs = load_pipeline_configs(root).unwrap();
3175 let binding = &configs.bindings[0].config;
3176 let resolved = resolve_binding_run("engine/gone", binding).unwrap();
3177
3178 match verify_binding(&engine, root, binding, &resolved) {
3179 Err(FindingsError::SourceUnreachable { source_name, path }) => {
3180 assert_eq!(source_name, "gone");
3181 assert!(
3182 path.ends_with("vanished-src"),
3183 "refusal must name the resolved missing path, got `{path}`",
3184 );
3185 }
3186 other => panic!("expected SourceUnreachable refusal, got {other:?}"),
3187 }
3188
3189 assert!(
3192 !engine
3193 .mem_config_for("engine")
3194 .unwrap()
3195 .sync_state
3196 .keys()
3197 .any(|k| k.ends_with("#verified")),
3198 "a refused verify must not leave any #verified token",
3199 );
3200 }
3201
3202 #[test]
3203 fn completed_verify_records_the_verified_baseline() {
3204 let tmp = tempfile::tempdir().unwrap();
3205 let root = tmp.path();
3206 let mem_dir = root.join("mem");
3207 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3208 std::fs::write(
3209 mem_dir.join(".memstead").join("config.json"),
3210 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3211 )
3212 .unwrap();
3213 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3214 std::fs::write(
3215 root.join(".memstead").join("workspace.toml"),
3216 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3217 )
3218 .unwrap();
3219 let mount = Mount {
3220 mem: "engine".to_string(),
3221 schema: Some("default@1.0.0".parse().unwrap()),
3222 storage: MountStorage::Folder {
3223 path: mem_dir.clone(),
3224 },
3225 capability: MountCapability::Write,
3226 lifecycle: MountLifecycle::Eager,
3227 cross_linkable: false,
3228 migration_target: None,
3229 };
3230 crate::FileWorkspaceStore::new()
3231 .save_state(
3232 root,
3233 &Workspace {
3234 mounts: vec![mount],
3235 settings: WorkspaceSettings::default(),
3236 },
3237 )
3238 .unwrap();
3239 let out = std::process::Command::new("git")
3240 .args(["init", "-q"])
3241 .current_dir(root)
3242 .output()
3243 .unwrap();
3244 assert!(out.status.success());
3245
3246 write_binding(
3247 root,
3248 "engine",
3249 "graph",
3250 &Binding {
3251 version: BINDING_VERSION,
3252 intent: None,
3253 sources: vec![crate::pipeline::Source {
3254 name: "graph".to_string(),
3255 medium_type: MediumType::Codebase,
3256 pointer: String::new(),
3257 change_detection: Some("git".to_string()),
3258 scope: vec![PatternEntry {
3259 path: "src/**/*.rs".to_string(),
3260 mode: PatternMode::Allow,
3261 }],
3262 engagement: None,
3263 preparation: None,
3264 }],
3265 reference_mems: Vec::new(),
3266 destination_mem: "engine".to_string(),
3267 deny_paths: Vec::new(),
3268 coverage_semantics: None,
3269 rules: None,
3270 prune: None,
3271 operations: Operations {
3272 build: Some(BuildOperation {
3273 mode: BuildMode::Discovery,
3274 trigger: IngestTrigger::Loop,
3275 batch_size: 20,
3276 post_actions: None,
3277 }),
3278 sync: None,
3279 verify: Some(VerifyOperation {
3280 trigger: IngestTrigger::Manual,
3281 batch_size: 20,
3282 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
3283 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
3284 }),
3285 },
3286 },
3287 )
3288 .unwrap();
3289
3290 let mut engine = Engine::from_workspace_root(root).unwrap();
3291 engine
3294 .set_mem_sync_state("engine", "engine/graph/graph#synced", "deadbeef", None)
3295 .unwrap();
3296
3297 let configs = load_pipeline_configs(root).unwrap();
3298 let binding = &configs.bindings[0].config;
3299 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
3300
3301 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
3302 assert_eq!(
3304 outcome.facet_heads.get("graph").map(String::as_str),
3305 Some("deadbeef")
3306 );
3307 assert_eq!(outcome.key.source_head, "graph=deadbeef");
3308 assert_eq!(
3309 join_facet_heads(&outcome.facet_heads),
3310 outcome.key.source_head
3311 );
3312
3313 assert!(
3315 !engine
3316 .mem_config_for("engine")
3317 .unwrap()
3318 .sync_state
3319 .contains_key("engine/graph/graph#verified")
3320 );
3321
3322 let written = record_verified_baseline(&mut engine, "engine", &outcome, None).unwrap();
3323 assert_eq!(written, vec!["engine/graph/graph#verified".to_string()]);
3324
3325 assert_eq!(
3327 engine
3328 .mem_config_for("engine")
3329 .unwrap()
3330 .sync_state
3331 .get("engine/graph/graph#verified")
3332 .map(String::as_str),
3333 Some("deadbeef")
3334 );
3335 let disk: serde_json::Value = serde_json::from_slice(
3337 &std::fs::read(mem_dir.join(".memstead").join("config.json")).unwrap(),
3338 )
3339 .unwrap();
3340 assert_eq!(
3341 disk["syncState"]["engine/graph/graph#verified"],
3342 serde_json::json!("deadbeef")
3343 );
3344 }
3345
3346 #[test]
3353 fn adjudication_cap_queues_the_remainder() {
3354 let k = key("h", "s");
3355 let mk = |art: &str| {
3356 let mut a = anchor(AnchorProvenanceClass::Anchored);
3357 a.artifact = art.to_string();
3358 a
3359 };
3360 let candidates = vec![
3361 (
3362 "engine--a".to_string(),
3363 mk("src/a.rs"),
3364 AnchorState::Drifted,
3365 ),
3366 (
3367 "engine--b".to_string(),
3368 mk("src/b.rs"),
3369 AnchorState::Drifted,
3370 ),
3371 (
3372 "engine--c".to_string(),
3373 mk("src/c.rs"),
3374 AnchorState::Drifted,
3375 ),
3376 ];
3377 let window: BTreeSet<String> = [candidate_key("engine--a", &mk("src/a.rs"))]
3379 .into_iter()
3380 .collect();
3381 let out = adjudicate_candidates(&k, "f", &candidates, Some(&window), "1");
3382 let drifted = out
3383 .iter()
3384 .filter(|f| f.class == FindingClass::Drifted)
3385 .count();
3386 let queued = out
3387 .iter()
3388 .filter(|f| f.class == FindingClass::QueuedForAdjudication)
3389 .count();
3390 assert_eq!(drifted, 1, "only the in-window candidate is adjudicated");
3391 assert_eq!(queued, 2, "the remainder is queued as the tier-3 backlog");
3392 assert!(
3394 out.iter()
3395 .any(|f| f.class == FindingClass::QueuedForAdjudication
3396 && f.detail.contains("cap reached")),
3397 "capped remainder states it was deferred by the cap"
3398 );
3399
3400 let uncapped = adjudicate_candidates(&k, "f", &candidates, None, "1");
3402 assert_eq!(
3403 uncapped
3404 .iter()
3405 .filter(|f| f.class == FindingClass::Drifted)
3406 .count(),
3407 3,
3408 "uncapped adjudicates every candidate"
3409 );
3410 assert_eq!(
3411 uncapped
3412 .iter()
3413 .filter(|f| f.class == FindingClass::QueuedForAdjudication)
3414 .count(),
3415 0
3416 );
3417 }
3418
3419 #[test]
3425 fn full_resync_schedule_disabled_notdue_due() {
3426 let codebase = FacetEnumerability {
3427 facet: "src".to_string(),
3428 medium_type: "codebase".to_string(),
3429 enumerable: true,
3430 };
3431 assert_eq!(
3432 schedule_full_resync(0, 5, std::slice::from_ref(&codebase)),
3433 FullResyncDecision::Disabled
3434 );
3435 match schedule_full_resync(3, 2, std::slice::from_ref(&codebase)) {
3436 FullResyncDecision::NotDue { runs_until_due, .. } => assert_eq!(runs_until_due, 1),
3437 other => panic!("expected NotDue, got {other:?}"),
3438 }
3439 match schedule_full_resync(3, 3, std::slice::from_ref(&codebase)) {
3440 FullResyncDecision::Due {
3441 walked_facets,
3442 refused,
3443 ..
3444 } => {
3445 assert_eq!(walked_facets, vec!["src".to_string()]);
3446 assert!(refused.is_empty(), "enumerable facet is not refused");
3447 }
3448 other => panic!("expected Due, got {other:?}"),
3449 }
3450 }
3451
3452 #[test]
3455 fn full_resync_refuses_non_enumerable_medium() {
3456 let web = FacetEnumerability {
3457 facet: "manual".to_string(),
3458 medium_type: "web".to_string(),
3459 enumerable: false,
3460 };
3461 let d = schedule_full_resync(1, 1, &[web]);
3462 assert!(
3463 d.is_full_walk(),
3464 "a due sweep is a full walk even when refused"
3465 );
3466 match d {
3467 FullResyncDecision::Due {
3468 walked_facets,
3469 refused,
3470 ..
3471 } => {
3472 assert!(walked_facets.is_empty(), "nothing enumerable to walk");
3473 assert_eq!(refused.len(), 1, "the non-enumerable facet is refused");
3474 assert_eq!(refused[0].facet, "manual");
3475 assert_eq!(refused[0].medium_type, "web");
3476 assert!(
3477 refused[0].reason.contains("non-enumerable"),
3478 "the refusal is typed and states why"
3479 );
3480 }
3481 other => panic!("expected Due with a refusal, got {other:?}"),
3482 }
3483 }
3484
3485 #[test]
3490 fn full_resync_full_walk_covers_whole_source() {
3491 let tmp = tempfile::tempdir().unwrap();
3492 let root = tmp.path();
3493 let mem_dir = root.join("mem");
3494 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3495 std::fs::write(
3496 mem_dir.join(".memstead").join("config.json"),
3497 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3498 )
3499 .unwrap();
3500 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3501 std::fs::write(
3502 root.join(".memstead").join("workspace.toml"),
3503 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3504 )
3505 .unwrap();
3506 let mount = Mount {
3507 mem: "engine".to_string(),
3508 schema: Some("default@1.0.0".parse().unwrap()),
3509 storage: MountStorage::Folder {
3510 path: mem_dir.clone(),
3511 },
3512 capability: MountCapability::Write,
3513 lifecycle: MountLifecycle::Eager,
3514 cross_linkable: false,
3515 migration_target: None,
3516 };
3517 crate::FileWorkspaceStore::new()
3518 .save_state(
3519 root,
3520 &Workspace {
3521 mounts: vec![mount],
3522 settings: WorkspaceSettings::default(),
3523 },
3524 )
3525 .unwrap();
3526 let out = std::process::Command::new("git")
3527 .args(["init", "-q"])
3528 .current_dir(root)
3529 .output()
3530 .unwrap();
3531 assert!(out.status.success());
3532 std::fs::create_dir_all(root.join("src")).unwrap();
3533 for f in ["a.rs", "b.rs", "c.rs"] {
3534 std::fs::write(root.join("src").join(f), "fn x() {}\n").unwrap();
3535 }
3536
3537 write_binding(
3538 root,
3539 "engine",
3540 "graph",
3541 &Binding {
3542 version: BINDING_VERSION,
3543 intent: None,
3544 sources: vec![crate::pipeline::Source {
3545 name: "graph".to_string(),
3546 medium_type: MediumType::Codebase,
3547 pointer: String::new(),
3548 change_detection: Some("git".to_string()),
3549 scope: vec![PatternEntry {
3550 path: "src/**/*.rs".to_string(),
3551 mode: PatternMode::Allow,
3552 }],
3553 engagement: None,
3554 preparation: None,
3555 }],
3556 reference_mems: Vec::new(),
3557 destination_mem: "engine".to_string(),
3558 deny_paths: Vec::new(),
3559 coverage_semantics: None,
3560 rules: None,
3561 prune: None,
3562 operations: Operations {
3563 build: Some(BuildOperation {
3564 mode: BuildMode::Discovery,
3565 trigger: IngestTrigger::Loop,
3566 batch_size: 20,
3567 post_actions: None,
3568 }),
3569 sync: None,
3570 verify: Some(VerifyOperation {
3571 trigger: IngestTrigger::Manual,
3572 batch_size: 1, adjudication_cap: DEFAULT_ADJUDICATION_CAP,
3574 full_resync_every: 1, }),
3576 },
3577 },
3578 )
3579 .unwrap();
3580
3581 let engine = Engine::from_workspace_root(root).unwrap();
3582 let configs = load_pipeline_configs(root).unwrap();
3583 let binding = &configs.bindings[0].config;
3584 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
3585
3586 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
3587 match &outcome.full_resync {
3589 FullResyncDecision::Due {
3590 walked_facets,
3591 refused,
3592 run_count,
3593 ..
3594 } => {
3595 assert_eq!(*run_count, 1);
3596 assert_eq!(walked_facets, &vec!["graph".to_string()]);
3597 assert!(refused.is_empty());
3598 }
3599 other => panic!("expected a due full walk, got {other:?}"),
3600 }
3601 let store = read_findings_store(root, "engine", "graph")
3603 .unwrap()
3604 .unwrap();
3605 let uncovered = store
3606 .current(&outcome.key)
3607 .iter()
3608 .filter(|f| f.class == FindingClass::Uncovered)
3609 .count();
3610 assert_eq!(
3611 uncovered, 3,
3612 "the scheduled full walk covers the whole source, not a batch of one"
3613 );
3614 }
3615
3616 #[test]
3623 fn scheduled_full_walk_demotes_partial_facet_to_refusal() {
3624 let tmp = tempfile::tempdir().unwrap();
3625 let root = tmp.path();
3626 let mem_dir = root.join("mem");
3627 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3628 std::fs::write(
3629 mem_dir.join(".memstead").join("config.json"),
3630 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3631 )
3632 .unwrap();
3633 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3634 std::fs::write(
3635 root.join(".memstead").join("workspace.toml"),
3636 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3637 )
3638 .unwrap();
3639 let mount = Mount {
3640 mem: "engine".to_string(),
3641 schema: Some("default@1.0.0".parse().unwrap()),
3642 storage: MountStorage::Folder {
3643 path: mem_dir.clone(),
3644 },
3645 capability: MountCapability::Write,
3646 lifecycle: MountLifecycle::Eager,
3647 cross_linkable: false,
3648 migration_target: None,
3649 };
3650 crate::FileWorkspaceStore::new()
3651 .save_state(
3652 root,
3653 &Workspace {
3654 mounts: vec![mount],
3655 settings: WorkspaceSettings::default(),
3656 },
3657 )
3658 .unwrap();
3659 let out = std::process::Command::new("git")
3660 .args(["init", "-q"])
3661 .current_dir(root)
3662 .output()
3663 .unwrap();
3664 assert!(out.status.success());
3665 std::fs::create_dir_all(root.join("src")).unwrap();
3666 std::fs::write(root.join("src").join("a.rs"), "fn x() {}\n").unwrap();
3667
3668 write_binding(
3669 root,
3670 "engine",
3671 "graph",
3672 &Binding {
3673 version: BINDING_VERSION,
3674 intent: None,
3675 sources: vec![crate::pipeline::Source {
3676 name: "graph".to_string(),
3677 medium_type: MediumType::Codebase,
3678 pointer: "src".to_string(),
3679 change_detection: Some("git".to_string()),
3680 scope: vec![
3684 PatternEntry {
3685 path: "**/*.rs".to_string(),
3686 mode: PatternMode::Allow,
3687 },
3688 PatternEntry {
3689 path: "src/nested.rs".to_string(),
3690 mode: PatternMode::Allow,
3691 },
3692 ],
3693 engagement: None,
3694 preparation: None,
3695 }],
3696 reference_mems: Vec::new(),
3697 destination_mem: "engine".to_string(),
3698 deny_paths: Vec::new(),
3699 coverage_semantics: None,
3700 rules: None,
3701 prune: None,
3702 operations: Operations {
3703 build: Some(BuildOperation {
3704 mode: BuildMode::Discovery,
3705 trigger: IngestTrigger::Loop,
3706 batch_size: 20,
3707 post_actions: None,
3708 }),
3709 sync: None,
3710 verify: Some(VerifyOperation {
3711 trigger: IngestTrigger::Manual,
3712 batch_size: 1,
3713 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
3714 full_resync_every: 1, }),
3716 },
3717 },
3718 )
3719 .unwrap();
3720
3721 let engine = Engine::from_workspace_root(root).unwrap();
3722 let configs = load_pipeline_configs(root).unwrap();
3723 let binding = &configs.bindings[0].config;
3724 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
3725
3726 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
3727 match &outcome.full_resync {
3728 FullResyncDecision::Due {
3729 walked_facets,
3730 refused,
3731 ..
3732 } => {
3733 assert!(
3734 walked_facets.is_empty(),
3735 "a partial facet must not be announced as walked-in-full: {walked_facets:?}"
3736 );
3737 assert_eq!(refused.len(), 1, "the partial facet is refused, typed");
3738 assert_eq!(refused[0].facet, "graph");
3739 assert!(
3740 refused[0].reason.contains("incomplete"),
3741 "the refusal names the partiality: {}",
3742 refused[0].reason
3743 );
3744 }
3745 other => panic!("expected a due full walk decision, got {other:?}"),
3746 }
3747 }
3748
3749 #[test]
3760 fn full_verify_uncaps_adjudication_and_walks_whole_source() {
3761 let tmp = tempfile::tempdir().unwrap();
3762 let root = tmp.path();
3763 let mem_dir = root.join("mem");
3764 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3765 std::fs::write(
3766 mem_dir.join(".memstead").join("config.json"),
3767 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3768 )
3769 .unwrap();
3770 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3771 std::fs::write(
3772 root.join(".memstead").join("workspace.toml"),
3773 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3774 )
3775 .unwrap();
3776 crate::FileWorkspaceStore::new()
3777 .save_state(
3778 root,
3779 &Workspace {
3780 mounts: vec![Mount {
3781 mem: "engine".to_string(),
3782 schema: Some("default@1.0.0".parse().unwrap()),
3783 storage: MountStorage::Folder {
3784 path: mem_dir.clone(),
3785 },
3786 capability: MountCapability::Write,
3787 lifecycle: MountLifecycle::Eager,
3788 cross_linkable: false,
3789 migration_target: None,
3790 }],
3791 settings: WorkspaceSettings::default(),
3792 },
3793 )
3794 .unwrap();
3795 let out = std::process::Command::new("git")
3796 .args(["init", "-q"])
3797 .current_dir(root)
3798 .output()
3799 .unwrap();
3800 assert!(out.status.success());
3801 std::fs::create_dir_all(root.join("src")).unwrap();
3802 for f in ["a.rs", "b.rs", "c.rs", "d.rs", "e.rs", "f.rs"] {
3804 std::fs::write(root.join("src").join(f), "fn x() {}\n").unwrap();
3805 }
3806 let mk = |art: &str| Anchor {
3807 artifact: art.to_string(),
3808 grain: AnchorGrain::File,
3809 class: AnchorProvenanceClass::Anchored,
3810 at_version: None,
3811 hash: Some("stale-recorded-hash".to_string()), hash_stability: AnchorHashStability::Stable,
3813 derived_from: Vec::new(),
3814 binding: None,
3815 source: None,
3816 span_unvalidated: false,
3817 hash_source: None,
3818 };
3819 std::fs::write(
3823 mem_dir.join("e.md"),
3824 "---\ntype: decision\n---\n\n# E\n\n## Decision\n\nBody.\n",
3825 )
3826 .unwrap();
3827 let mut sidecar = AnchorSidecar::default();
3828 sidecar.set(
3829 "engine--e",
3830 vec![mk("src/a.rs"), mk("src/b.rs"), mk("src/c.rs")],
3831 );
3832 std::fs::write(
3833 mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
3834 sidecar.to_bytes(),
3835 )
3836 .unwrap();
3837
3838 write_binding(
3839 root,
3840 "engine",
3841 "graph",
3842 &Binding {
3843 version: BINDING_VERSION,
3844 intent: None,
3845 sources: vec![crate::pipeline::Source {
3846 name: "graph".to_string(),
3847 medium_type: MediumType::Codebase,
3848 pointer: String::new(),
3849 change_detection: Some("git".to_string()),
3850 scope: vec![PatternEntry {
3851 path: "src/**/*.rs".to_string(),
3852 mode: PatternMode::Allow,
3853 }],
3854 engagement: None,
3855 preparation: None,
3856 }],
3857 reference_mems: Vec::new(),
3858 destination_mem: "engine".to_string(),
3859 deny_paths: Vec::new(),
3860 coverage_semantics: None,
3861 rules: None,
3862 prune: None,
3863 operations: Operations {
3864 build: None,
3865 sync: None,
3866 verify: Some(VerifyOperation {
3867 trigger: IngestTrigger::Manual,
3868 batch_size: 1, adjudication_cap: 1, full_resync_every: 0, }),
3872 },
3873 },
3874 )
3875 .unwrap();
3876
3877 let engine = Engine::from_workspace_root(root).unwrap();
3878 let configs = load_pipeline_configs(root).unwrap();
3879 let binding = &configs.bindings[0].config;
3880 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
3881
3882 let sampled = verify_binding(&engine, root, binding, &resolved).unwrap();
3886 assert_eq!(sampled.full_resync, FullResyncDecision::Disabled);
3887 let store = read_findings_store(root, "engine", "graph")
3888 .unwrap()
3889 .unwrap();
3890 let current = store.current(&sampled.key);
3891 let count = |c: FindingClass| current.iter().filter(|f| f.class == c).count();
3892 assert_eq!(count(FindingClass::Drifted), 1, "cap-1 adjudicates one");
3893 assert_eq!(
3894 count(FindingClass::QueuedForAdjudication),
3895 2,
3896 "the remainder queues"
3897 );
3898 assert!(
3899 current
3900 .iter()
3901 .any(|f| f.class == FindingClass::QueuedForAdjudication
3902 && f.detail.contains("cap reached")),
3903 "the sampled deferral states the cap"
3904 );
3905 assert!(
3906 count(FindingClass::Uncovered) <= 1,
3907 "batch-1 sample looks at one artifact"
3908 );
3909
3910 let full = verify_binding_full(&engine, root, binding, &resolved).unwrap();
3913 assert_eq!(
3914 full.full_resync,
3915 FullResyncDecision::Forced {
3916 walked_facets: vec!["graph".to_string()]
3917 }
3918 );
3919 assert_eq!(full.backlog, 0, "cap treated as unlimited — no backlog");
3920 let store = read_findings_store(root, "engine", "graph")
3921 .unwrap()
3922 .unwrap();
3923 let current = store.current(&full.key);
3924 let count = |c: FindingClass| current.iter().filter(|f| f.class == c).count();
3925 assert_eq!(
3926 count(FindingClass::Drifted),
3927 3,
3928 "every candidate adjudicated"
3929 );
3930 assert_eq!(count(FindingClass::QueuedForAdjudication), 0);
3931 assert_eq!(
3932 count(FindingClass::Uncovered),
3933 3,
3934 "the whole S(D) walked — every uncovered file flagged"
3935 );
3936 assert!(
3937 current.iter().all(|f| !f.detail.contains("cap reached")),
3938 "a full run's findings carry no cap-deferral caveat"
3939 );
3940 }
3941
3942 #[test]
3947 fn full_verify_refuses_non_enumerable_medium_typed() {
3948 let tmp = tempfile::tempdir().unwrap();
3949 let root = tmp.path();
3950 let mem_dir = root.join("mem");
3951 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3952 std::fs::write(
3953 mem_dir.join(".memstead").join("config.json"),
3954 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3955 )
3956 .unwrap();
3957 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3958 std::fs::write(
3959 root.join(".memstead").join("workspace.toml"),
3960 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3961 )
3962 .unwrap();
3963 crate::FileWorkspaceStore::new()
3964 .save_state(
3965 root,
3966 &Workspace {
3967 mounts: vec![Mount {
3968 mem: "engine".to_string(),
3969 schema: Some("default@1.0.0".parse().unwrap()),
3970 storage: MountStorage::Folder {
3971 path: mem_dir.clone(),
3972 },
3973 capability: MountCapability::Write,
3974 lifecycle: MountLifecycle::Eager,
3975 cross_linkable: false,
3976 migration_target: None,
3977 }],
3978 settings: WorkspaceSettings::default(),
3979 },
3980 )
3981 .unwrap();
3982
3983 write_binding(
3985 root,
3986 "engine",
3987 "manual",
3988 &Binding {
3989 version: BINDING_VERSION,
3990 intent: None,
3991 sources: vec![crate::pipeline::Source {
3992 name: "manual".to_string(),
3993 medium_type: MediumType::Web,
3994 pointer: "https://example.com/docs".to_string(),
3995 change_detection: None,
3996 scope: Vec::new(),
3997 engagement: None,
3998 preparation: None,
3999 }],
4000 reference_mems: Vec::new(),
4001 destination_mem: "engine".to_string(),
4002 deny_paths: Vec::new(),
4003 coverage_semantics: Some(CoverageSemantics::Curated),
4004 rules: None,
4005 prune: None,
4006 operations: Operations {
4007 build: None,
4008 sync: None,
4009 verify: Some(VerifyOperation {
4010 trigger: IngestTrigger::Manual,
4011 batch_size: 20,
4012 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
4013 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
4014 }),
4015 },
4016 },
4017 )
4018 .unwrap();
4019
4020 let engine = Engine::from_workspace_root(root).unwrap();
4021 let configs = load_pipeline_configs(root).unwrap();
4022 let binding = &configs.bindings[0].config;
4023 let resolved = resolve_binding_run("engine/manual", binding).unwrap();
4024
4025 let err = verify_binding_full(&engine, root, binding, &resolved).unwrap_err();
4027 match &err {
4028 FindingsError::FullWalkNonEnumerable(refusal) => {
4029 assert_eq!(refusal.facet, "manual");
4030 assert_eq!(refusal.medium_type, "web");
4031 assert!(refusal.reason.contains("non-enumerable"));
4032 }
4033 other => panic!("expected FullWalkNonEnumerable, got {other:?}"),
4034 }
4035 assert!(
4036 read_findings_store(root, "engine", "manual")
4037 .unwrap()
4038 .is_none(),
4039 "a refused full run records nothing"
4040 );
4041
4042 let sampled = verify_binding(&engine, root, binding, &resolved).unwrap();
4044 assert_eq!(sampled.binding, "engine/manual");
4045 }
4046}