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