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 mut s_d: BTreeSet<String> = BTreeSet::new();
1621 for source in &resolved.sources {
1622 if let ResolvedSource::Primary(p) = source
1623 && medium_capabilities(p.medium_type).enumerable
1624 {
1625 s_d.extend(enumerate_source_artifacts(
1626 engine,
1627 p,
1628 &resolved.deny_paths,
1629 workspace_root,
1630 ));
1631 }
1632 }
1633 let mut sample_files: Vec<String> = if full_resync.is_full_walk() {
1634 let mut all = full_walk_files;
1637 all.sort();
1638 all.dedup();
1639 all
1640 } else {
1641 next_batch(engine, resolved, workspace_root, &cache_root, sample_batch)
1642 .map(|b| b.files)
1643 .unwrap_or_default()
1644 };
1645 sample_files.retain(|f| s_d.contains(f));
1646 let this_binding = binding_hash_of(binding, resolved);
1654 let entity_end_reconciled = engine
1659 .entity_set_is_reconcilable(&resolved.destination_mem)
1660 .is_ok();
1661 let covered_now = |artifact: &str| {
1662 engine
1663 .anchors_referencing_artifact(artifact)
1664 .iter()
1665 .any(|(eid, a)| {
1666 eid.mem() == resolved.destination_mem.as_str()
1667 && a.binding
1668 .as_deref()
1669 .map(|b| b == this_binding.as_str())
1670 .unwrap_or(true)
1671 && (!entity_end_reconciled || !engine.entity_is_absent(eid))
1672 })
1673 };
1674 let excluded: BTreeSet<String> =
1684 crate::ingest::advance::reconcile_exclusions(engine, workspace_root, resolved)
1685 .map(|l| l.active.into_iter().map(|e| e.artifact).collect())
1686 .unwrap_or_default();
1687 for file in &sample_files {
1688 if !covered_now(file) && !excluded.contains(file) {
1689 findings.push(Finding {
1690 key: key.clone(),
1691 facet: facet.clone(),
1692 target: FindingTarget::Artifact {
1693 artifact: file.clone(),
1694 },
1695 class: FindingClass::Uncovered,
1696 detail: "source artifact in scope has no anchor in the destination mem".to_string(),
1697 created_at: now.clone(),
1698 });
1699 }
1700 }
1701
1702 let mut store = read_findings_store(workspace_root, &mem, &name)
1709 .map_err(FindingsError::Store)?
1710 .unwrap_or_else(|| FindingsStore {
1711 binding: binding_id.clone(),
1712 ..Default::default()
1713 });
1714 let obs = PassObservation {
1715 anchors_observed,
1716 anchors_existing,
1717 files_observed: sample_files.into_iter().collect(),
1718 s_d,
1719 };
1720 let prior = store.current(&key).to_vec();
1721 let findings = merge_with_prior(findings, &prior, &obs, |artifact: &str| {
1727 covered_now(artifact) || excluded.contains(artifact)
1728 });
1729
1730 let backlog = findings
1731 .iter()
1732 .filter(|f| f.class == FindingClass::QueuedForAdjudication)
1733 .count();
1734
1735 let recorded = findings.len();
1738 store.record(key.clone(), now, findings);
1739 let superseded = store.superseded(&key).len();
1740 write_findings_store(workspace_root, &mem, &name, &store).map_err(FindingsError::Store)?;
1741
1742 Ok(VerifyOutcome {
1743 binding: binding_id,
1744 key,
1745 recorded,
1746 superseded,
1747 backlog,
1748 full_resync,
1749 facet_heads,
1750 hash_backfill,
1751 })
1752}
1753
1754fn medium_type_wire(t: crate::pipeline::MediumType) -> String {
1757 serde_json::to_value(t)
1758 .ok()
1759 .and_then(|v| v.as_str().map(str::to_string))
1760 .unwrap_or_default()
1761}
1762
1763#[cfg(test)]
1764mod tests {
1765 use super::*;
1766 use crate::anchor::{Anchor, AnchorGrain, AnchorHashStability, AnchorProvenanceClass};
1767
1768 fn key(hash: &str, head: &str) -> FindingKey {
1769 FindingKey {
1770 binding_hash: hash.to_string(),
1771 source_head: head.to_string(),
1772 }
1773 }
1774
1775 fn anchor(class: AnchorProvenanceClass) -> Anchor {
1776 Anchor {
1777 artifact: "src/lib.rs".to_string(),
1778 grain: AnchorGrain::File,
1779 class,
1780 at_version: None,
1781 hash: if class.is_hash_bearing() {
1782 Some("h1".to_string())
1783 } else {
1784 None
1785 },
1786 hash_stability: AnchorHashStability::Stable,
1787 derived_from: Vec::new(),
1788 binding: None,
1789 source: None,
1790 span_unvalidated: false,
1791 hash_source: None,
1792 last_observed: None,
1793 }
1794 }
1795
1796 #[test]
1799 fn store_round_trips_on_disk_and_delete_is_idempotent() {
1800 let tmp = tempfile::tempdir().unwrap();
1801 let root = tmp.path();
1802 assert!(
1803 read_findings_store(root, "engine", "graph")
1804 .unwrap()
1805 .is_none()
1806 );
1807
1808 let mut store = FindingsStore {
1809 binding: "engine/graph".to_string(),
1810 ..Default::default()
1811 };
1812 let k = key("hashA", "head1");
1813 store.record(
1814 k.clone(),
1815 "1".to_string(),
1816 vec![Finding {
1817 key: k.clone(),
1818 facet: "src".to_string(),
1819 target: FindingTarget::Artifact {
1820 artifact: "src/a.rs".to_string(),
1821 },
1822 class: FindingClass::Uncovered,
1823 detail: "d".to_string(),
1824 created_at: "1".to_string(),
1825 }],
1826 );
1827 write_findings_store(root, "engine", "graph", &store).unwrap();
1828 assert!(findings_store_path(root, "engine", "graph").exists());
1829
1830 let ignore = root
1833 .join(WORKSPACE_STORE_DIR)
1834 .join(STATE_DIR)
1835 .join(FINDINGS_DIR)
1836 .join(".gitignore");
1837 assert_eq!(std::fs::read_to_string(&ignore).unwrap(), "*\n");
1838
1839 let back = read_findings_store(root, "engine", "graph")
1841 .unwrap()
1842 .unwrap();
1843 assert_eq!(back, store);
1844 assert_eq!(back.current(&k).len(), 1);
1845
1846 delete_findings_store(root, "engine", "graph").unwrap();
1847 assert!(
1848 read_findings_store(root, "engine", "graph")
1849 .unwrap()
1850 .is_none()
1851 );
1852 delete_findings_store(root, "engine", "graph").unwrap();
1854 }
1855
1856 #[test]
1859 fn changed_binding_hash_supersedes_prior_findings() {
1860 let mut store = FindingsStore::default();
1861 let old = key("hashOLD", "head1");
1862 let new = key("hashNEW", "head1");
1863 let f_old = Finding {
1864 key: old.clone(),
1865 facet: "src".to_string(),
1866 target: FindingTarget::Artifact {
1867 artifact: "src/old.rs".to_string(),
1868 },
1869 class: FindingClass::Uncovered,
1870 detail: "old".to_string(),
1871 created_at: "1".to_string(),
1872 };
1873 store.record(old.clone(), "1".to_string(), vec![f_old.clone()]);
1874
1875 store.record(new.clone(), "2".to_string(), Vec::new());
1877 assert!(store.current(&new).is_empty(), "new key has its own view");
1878 let superseded = store.superseded(&new);
1879 assert_eq!(superseded.len(), 1, "old batch is segregated as superseded");
1880 assert_eq!(superseded[0], &f_old);
1881 assert!(!store.current(&new).contains(&f_old));
1883 }
1884
1885 #[test]
1893 fn impl_version_bump_invalidates_findings_by_construction() {
1894 use crate::binding::{
1895 PREPARATION_IMPL_VERSION, ScaffoldParams, hash_binding, hash_binding_at_impl_version,
1896 scaffold_binding,
1897 };
1898 let binding = scaffold_binding(ScaffoldParams {
1899 destination_mem: "plugin",
1900 source_name: "source-tree",
1901 pointer: "../public",
1902 medium_type: crate::pipeline::MediumType::Codebase,
1903 intent: None,
1904 additional_deny_paths: Vec::new(),
1905 })
1906 .binding;
1907 assert!(binding.sources[0].preparation.is_none());
1908 let _ = PREPARATION_IMPL_VERSION;
1912 let old = key(&hash_binding_at_impl_version(&binding, 0), "head1");
1913 let live = key(&hash_binding(&binding), "head1");
1914 assert_ne!(old.binding_hash, live.binding_hash);
1915
1916 let mut store = FindingsStore::default();
1917 let f_old = Finding {
1918 key: old.clone(),
1919 facet: "source-tree".to_string(),
1920 target: FindingTarget::Artifact {
1921 artifact: "src/old.rs".to_string(),
1922 },
1923 class: FindingClass::Uncovered,
1924 detail: "recorded before the bump".to_string(),
1925 created_at: "1".to_string(),
1926 };
1927 store.record(old.clone(), "1".to_string(), vec![f_old.clone()]);
1928
1929 assert!(
1930 store.current(&live).is_empty(),
1931 "a finding keyed on the pre-bump hash is invalid under the live hash"
1932 );
1933 assert_eq!(store.superseded(&live), vec![&f_old]);
1934 assert_eq!(
1935 store.current(&old),
1936 &[f_old.clone()][..],
1937 "nothing is deleted"
1938 );
1939 }
1940
1941 #[test]
1948 fn moved_source_head_keeps_findings_current_until_superseded() {
1949 let mut store = FindingsStore::default();
1950 let before = key("hashA", "head1");
1951 let after = key("hashA", "head2");
1952 let f = Finding {
1953 key: before.clone(),
1954 facet: "src".to_string(),
1955 target: FindingTarget::Anchor {
1956 entity: "engine--e".to_string(),
1957 artifact: "src/x.rs".to_string(),
1958 },
1959 class: FindingClass::UnresolvableAnchor,
1960 detail: "gone".to_string(),
1961 created_at: "1".to_string(),
1962 };
1963 store.record(before.clone(), "1".to_string(), vec![f.clone()]);
1964
1965 assert_eq!(store.current(&after), std::slice::from_ref(&f));
1968 assert_eq!(store.current(&after)[0].key.source_head, "head1");
1969 assert!(store.superseded(&after).is_empty());
1970
1971 store.record(after.clone(), "2".to_string(), Vec::new());
1974 assert!(store.current(&after).is_empty());
1975 assert!(store.current(&before).is_empty(), "at the old head too");
1976 assert_eq!(store.batches.len(), 1, "one batch per hash(D)");
1977 }
1978
1979 #[test]
1987 fn legacy_per_head_store_loads_and_presents_head_agnostically() {
1988 let tmp = tempfile::tempdir().unwrap();
1989 let root = tmp.path();
1990 let path = findings_store_path(root, "engine", "graph");
1991 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1992 std::fs::write(
1997 &path,
1998 r#"{
1999 "binding": "engine/graph",
2000 "batches": [
2001 {
2002 "key": { "binding_hash": "hashOLD", "source_head": "src=aaa" },
2003 "recorded_at": "100",
2004 "findings": [
2005 {
2006 "key": { "binding_hash": "hashOLD", "source_head": "src=aaa" },
2007 "facet": "src",
2008 "target": { "kind": "artifact", "artifact": "src/old.rs" },
2009 "class": "uncovered",
2010 "detail": "old declaration",
2011 "created_at": "100"
2012 }
2013 ]
2014 },
2015 {
2016 "key": { "binding_hash": "hashCUR", "source_head": "src=bbb" },
2017 "recorded_at": "200",
2018 "findings": [
2019 {
2020 "key": { "binding_hash": "hashCUR", "source_head": "src=bbb" },
2021 "facet": "src",
2022 "target": { "kind": "artifact", "artifact": "src/resolved-at-ccc.rs" },
2023 "class": "uncovered",
2024 "detail": "was open at bbb, absent from the ccc batch",
2025 "created_at": "200"
2026 }
2027 ]
2028 },
2029 {
2030 "key": { "binding_hash": "hashCUR", "source_head": "src=ccc" },
2031 "recorded_at": "300",
2032 "findings": [
2033 {
2034 "key": { "binding_hash": "hashCUR", "source_head": "src=ccc" },
2035 "facet": "src",
2036 "target": { "kind": "anchor", "entity": "engine--e", "artifact": "src/x.rs" },
2037 "class": "unresolvable-anchor",
2038 "detail": "gone",
2039 "created_at": "300"
2040 }
2041 ]
2042 }
2043 ]
2044 }"#,
2045 )
2046 .unwrap();
2047
2048 let mut store = read_findings_store(root, "engine", "graph")
2049 .unwrap()
2050 .expect("the legacy on-disk format loads as-is");
2051 assert_eq!(store.binding, "engine/graph");
2052 assert_eq!(store.batches.len(), 3, "loaded without loss");
2053
2054 let now = key("hashCUR", "src=ddd");
2057 let current = store.current(&now);
2058 assert_eq!(current.len(), 1);
2059 assert_eq!(current[0].detail, "gone");
2060 assert_eq!(
2061 current[0].key.source_head, "src=ccc",
2062 "the finding keeps the head it was observed at"
2063 );
2064 let superseded = store.superseded(&now);
2067 assert_eq!(superseded.len(), 2);
2068 assert!(
2069 !current.iter().any(|f| f.detail.contains("was open at bbb")),
2070 "the older same-hash batch was superseded at write time and is not resurrected"
2071 );
2072
2073 store.record(now.clone(), "400".to_string(), Vec::new());
2076 assert_eq!(store.batches.len(), 2, "hashCUR collapsed, hashOLD kept");
2077 assert_eq!(store.superseded(&now).len(), 1);
2078 }
2079
2080 #[test]
2087 fn merge_closes_uncovered_findings_for_ledger_excluded_artifacts() {
2088 let k_old = key("h", "head1");
2089 let uncovered = |artifact: &str| Finding {
2090 key: k_old.clone(),
2091 facet: "src".to_string(),
2092 target: FindingTarget::Artifact {
2093 artifact: artifact.to_string(),
2094 },
2095 class: FindingClass::Uncovered,
2096 detail: "no anchor".to_string(),
2097 created_at: "1".to_string(),
2098 };
2099 let prior = vec![uncovered("src/excluded.rs"), uncovered("src/open.rs")];
2100 let obs = PassObservation {
2101 anchors_observed: BTreeSet::new(),
2102 anchors_existing: BTreeSet::new(),
2103 files_observed: BTreeSet::new(), s_d: ["src/excluded.rs".to_string(), "src/open.rs".to_string()].into(),
2105 };
2106 let excluded: BTreeSet<String> = ["src/excluded.rs".to_string()].into();
2107 let merged = merge_with_prior(Vec::new(), &prior, &obs, |artifact: &str| {
2108 excluded.contains(artifact)
2109 });
2110 assert_eq!(
2111 merged.len(),
2112 1,
2113 "the excluded finding closes, the open one carries: {merged:?}"
2114 );
2115 assert_eq!(
2116 merged[0].target,
2117 FindingTarget::Artifact {
2118 artifact: "src/open.rs".to_string()
2119 }
2120 );
2121 }
2122
2123 #[test]
2128 fn merge_carries_unobserved_open_findings_and_closes_departed() {
2129 let k_old = key("h", "head1");
2130 let mk_artifact = |artifact: &str, detail: &str| Finding {
2131 key: k_old.clone(),
2132 facet: "src".to_string(),
2133 target: FindingTarget::Artifact {
2134 artifact: artifact.to_string(),
2135 },
2136 class: FindingClass::Uncovered,
2137 detail: detail.to_string(),
2138 created_at: "1".to_string(),
2139 };
2140 let anchor_finding = Finding {
2141 key: k_old.clone(),
2142 facet: "src".to_string(),
2143 target: FindingTarget::Anchor {
2144 entity: "engine--gone".to_string(),
2145 artifact: "src/gone.rs".to_string(),
2146 },
2147 class: FindingClass::UnresolvableAnchor,
2148 detail: "anchor since removed from the mem".to_string(),
2149 created_at: "1".to_string(),
2150 };
2151 let prior = vec![
2152 mk_artifact("src/unsampled.rs", "still open, not in this window"),
2153 mk_artifact("src/departed.rs", "left S(D)"),
2154 mk_artifact("src/now-covered.rs", "gained an anchor since"),
2155 mk_artifact("src/observed-clean.rs", "re-sampled and now covered"),
2156 anchor_finding,
2157 ];
2158 let obs = PassObservation {
2159 anchors_observed: BTreeSet::new(),
2160 anchors_existing: BTreeSet::new(), files_observed: ["src/observed-clean.rs".to_string()].into(),
2162 s_d: [
2163 "src/unsampled.rs".to_string(),
2164 "src/now-covered.rs".to_string(),
2165 "src/observed-clean.rs".to_string(),
2166 ]
2167 .into(),
2168 };
2169 let merged = merge_with_prior(Vec::new(), &prior, &obs, |artifact| {
2170 artifact == "src/now-covered.rs" || artifact == "src/observed-clean.rs"
2171 });
2172 assert_eq!(merged.len(), 1, "only the still-open unsampled one carries");
2173 assert_eq!(
2174 merged[0].target,
2175 FindingTarget::Artifact {
2176 artifact: "src/unsampled.rs".to_string()
2177 }
2178 );
2179 assert_eq!(
2180 merged[0].key.source_head, "head1",
2181 "a carried finding keeps the head it was observed at"
2182 );
2183 }
2184
2185 #[test]
2190 fn merge_deferral_never_downgrades_prior_adjudication() {
2191 let k_old = key("h", "head1");
2192 let k_new = key("h", "head2");
2193 let target = FindingTarget::Anchor {
2194 entity: "engine--e".to_string(),
2195 artifact: "src/x.rs".to_string(),
2196 };
2197 let prior_drifted = Finding {
2198 key: k_old.clone(),
2199 facet: "src".to_string(),
2200 target: target.clone(),
2201 class: FindingClass::Drifted,
2202 detail: "adjudicated drifted at head1".to_string(),
2203 created_at: "1".to_string(),
2204 };
2205 let fresh_queued = Finding {
2206 key: k_new.clone(),
2207 facet: "src".to_string(),
2208 target: target.clone(),
2209 class: FindingClass::QueuedForAdjudication,
2210 detail: "deferred by the cap this run".to_string(),
2211 created_at: "2".to_string(),
2212 };
2213 let obs = PassObservation {
2214 anchors_observed: [target_key(&target)].into(),
2215 anchors_existing: [target_key(&target)].into(),
2216 files_observed: BTreeSet::new(),
2217 s_d: BTreeSet::new(),
2218 };
2219 let merged = merge_with_prior(
2220 vec![fresh_queued],
2221 std::slice::from_ref(&prior_drifted),
2222 &obs,
2223 |_| true,
2224 );
2225 assert_eq!(merged.len(), 1);
2226 assert_eq!(
2227 merged[0].class,
2228 FindingClass::Drifted,
2229 "the prior verdict stands over a deferral"
2230 );
2231 assert_eq!(merged[0].key.source_head, "head1");
2232 }
2233
2234 #[test]
2237 fn informed_by_anchor_never_drifts() {
2238 let k = key("h", "s");
2239 for class in [
2240 AnchorProvenanceClass::InformedBy,
2241 AnchorProvenanceClass::Authored,
2242 ] {
2243 let a = anchor(class);
2244 assert!(
2245 adjudicate_anchor(&k, "f", "engine--e", &a, AnchorState::Drifted, "1").is_none(),
2246 "{class:?} must not produce a drift finding"
2247 );
2248 assert!(
2249 adjudicate_anchor(&k, "f", "engine--e", &a, AnchorState::Recheck, "1").is_none(),
2250 "{class:?} must not produce a queued finding"
2251 );
2252 }
2253 }
2254
2255 #[test]
2258 fn hash_bearing_drifts_and_orphan_is_class_independent() {
2259 let k = key("h", "s");
2260 let anchored = anchor(AnchorProvenanceClass::Anchored);
2261 let drifted =
2262 adjudicate_anchor(&k, "f", "engine--e", &anchored, AnchorState::Drifted, "1").unwrap();
2263 assert_eq!(drifted.class, FindingClass::Drifted);
2264 assert_eq!(drifted.key, k, "the finding carries its recording key (A2)");
2265
2266 let queued =
2267 adjudicate_anchor(&k, "f", "engine--e", &anchored, AnchorState::Recheck, "1").unwrap();
2268 assert_eq!(queued.class, FindingClass::QueuedForAdjudication);
2269
2270 let informed = anchor(AnchorProvenanceClass::InformedBy);
2272 let orphan =
2273 adjudicate_anchor(&k, "f", "engine--e", &informed, AnchorState::Orphaned, "1").unwrap();
2274 assert_eq!(orphan.class, FindingClass::UnresolvableAnchor);
2275
2276 assert!(
2278 adjudicate_anchor(&k, "f", "engine--e", &anchored, AnchorState::Resolves, "1")
2279 .is_none()
2280 );
2281 }
2282
2283 #[test]
2285 fn finding_class_wire_round_trips() {
2286 for w in FindingClass::WIRE_VALUES {
2287 let c = FindingClass::from_wire(w).expect("known wire value");
2288 assert_eq!(c.as_wire(), *w);
2289 }
2290 assert!(FindingClass::from_wire("nonsense").is_none());
2291 }
2292
2293 #[test]
2295 fn malformed_binding_id_refuses() {
2296 assert!(matches!(
2297 split_binding_id("../escape"),
2298 Err(FindingsError::MalformedId(_))
2299 ));
2300 assert!(matches!(
2301 split_binding_id("no-slash"),
2302 Err(FindingsError::MalformedId(_))
2303 ));
2304 assert_eq!(
2305 split_binding_id("engine/graph").unwrap(),
2306 ("engine".to_string(), "graph".to_string())
2307 );
2308 }
2309
2310 use crate::anchor::AnchorSidecar;
2313 use crate::binding::{
2314 BINDING_VERSION, BuildMode, BuildOperation, CoverageSemantics, DEFAULT_ADJUDICATION_CAP,
2315 DEFAULT_FULL_RESYNC_EVERY, Operations, VerifyOperation,
2316 };
2317 use crate::ingest::resolve::resolve_binding_run;
2318 use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode};
2319 use crate::pipeline_store::{load_pipeline_configs, write_binding};
2320 use crate::workspace::{
2321 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
2322 };
2323 use crate::workspace_store::WorkspaceStoreAdapter;
2324
2325 #[test]
2333 fn verify_persists_findings_readable_fresh() {
2334 let tmp = tempfile::tempdir().unwrap();
2335 let root = tmp.path();
2336 let mem_dir = root.join("mem");
2337 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2338 std::fs::write(
2339 mem_dir.join(".memstead").join("config.json"),
2340 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2341 )
2342 .unwrap();
2343
2344 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2347 std::fs::write(
2348 root.join(".memstead").join("workspace.toml"),
2349 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2350 )
2351 .unwrap();
2352 let mount = Mount {
2353 mem: "engine".to_string(),
2354 schema: Some("default@1.0.0".parse().unwrap()),
2355 storage: MountStorage::Folder {
2356 path: mem_dir.clone(),
2357 },
2358 capability: MountCapability::Write,
2359 lifecycle: MountLifecycle::Eager,
2360 cross_linkable: false,
2361 migration_target: None,
2362 };
2363 crate::FileWorkspaceStore::new()
2364 .save_state(
2365 root,
2366 &Workspace {
2367 mounts: vec![mount],
2368 settings: WorkspaceSettings::default(),
2369 },
2370 )
2371 .unwrap();
2372
2373 let out = std::process::Command::new("git")
2377 .args(["init", "-q"])
2378 .current_dir(root)
2379 .output()
2380 .unwrap();
2381 assert!(out.status.success());
2382 std::fs::create_dir_all(root.join("src")).unwrap();
2383 std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
2384 std::fs::write(root.join("src").join("uncovered.rs"), "fn b() {}\n").unwrap();
2385
2386 let mk = |artifact: &str, class: AnchorProvenanceClass| Anchor {
2389 artifact: artifact.to_string(),
2390 grain: AnchorGrain::File,
2391 class,
2392 at_version: None,
2393 hash: class.is_hash_bearing().then(|| "recorded".to_string()),
2394 hash_stability: AnchorHashStability::Stable,
2395 derived_from: Vec::new(),
2396 binding: None,
2397 source: None,
2398 span_unvalidated: false,
2399 hash_source: None,
2400 last_observed: None,
2401 };
2402 std::fs::write(
2406 mem_dir.join("e.md"),
2407 "---\ntype: decision\n---\n\n# E\n\n## Decision\n\nBody.\n",
2408 )
2409 .unwrap();
2410 let mut sidecar = AnchorSidecar::default();
2411 sidecar.set(
2412 "engine--e",
2413 vec![
2414 mk("src/present.rs", AnchorProvenanceClass::Anchored), mk("src/gone.rs", AnchorProvenanceClass::Anchored), mk("src/present.rs", AnchorProvenanceClass::InformedBy), ],
2418 );
2419 std::fs::write(
2420 mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
2421 sidecar.to_bytes(),
2422 )
2423 .unwrap();
2424
2425 write_binding(
2427 root,
2428 "engine",
2429 "graph",
2430 &Binding {
2431 version: BINDING_VERSION,
2432 intent: None,
2433 sources: vec![crate::pipeline::Source {
2434 name: "graph".to_string(),
2435 medium_type: MediumType::Codebase,
2436 pointer: String::new(),
2437 change_detection: Some("git".to_string()),
2438 scope: vec![PatternEntry {
2439 path: "src/**/*.rs".to_string(),
2440 mode: PatternMode::Allow,
2441 }],
2442 engagement: None,
2443 preparation: None,
2444 }],
2445 reference_mems: Vec::new(),
2446 destination_mem: "engine".to_string(),
2447 deny_paths: Vec::new(),
2448 coverage_semantics: None,
2449 rules: None,
2450 prune: None,
2451 operations: Operations {
2452 build: Some(BuildOperation {
2453 mode: BuildMode::Discovery,
2454 trigger: IngestTrigger::Loop,
2455 batch_size: 20,
2456 post_actions: None,
2457 }),
2458 sync: None,
2459 verify: Some(VerifyOperation {
2460 trigger: IngestTrigger::Manual,
2461 batch_size: 20,
2462 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2463 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2464 }),
2465 },
2466 },
2467 )
2468 .unwrap();
2469
2470 let engine = Engine::from_workspace_root(root).unwrap();
2471
2472 let configs = load_pipeline_configs(root).unwrap();
2473 let binding = &configs.bindings[0].config;
2474 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
2475
2476 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2478 assert!(
2479 outcome.recorded >= 3,
2480 "orphan + drifted + uncovered at least"
2481 );
2482 assert_eq!(outcome.superseded, 0, "no prior key yet");
2483 assert_eq!(
2484 outcome.backlog, 0,
2485 "the mismatching hash adjudicated deterministically — nothing queued"
2486 );
2487 assert!(
2488 outcome.hash_backfill.is_empty(),
2489 "every hash-bearing anchor already carries a recorded hash — nothing to backfill"
2490 );
2491
2492 let store = read_findings_store(root, "engine", "graph")
2494 .unwrap()
2495 .unwrap();
2496 let current = store.current(&outcome.key);
2497 assert_eq!(current.len(), outcome.recorded);
2498
2499 let has = |c: FindingClass, art: &str| {
2500 current.iter().any(|f| {
2501 f.class == c
2502 && match &f.target {
2503 FindingTarget::Anchor { artifact, .. } => artifact == art,
2504 FindingTarget::Artifact { artifact } => artifact == art,
2505 }
2506 })
2507 };
2508 assert!(has(FindingClass::UnresolvableAnchor, "src/gone.rs"));
2509 assert!(
2510 has(FindingClass::Drifted, "src/present.rs"),
2511 "recorded-hash mismatch on a stable medium adjudicates drifted deterministically"
2512 );
2513 assert!(has(FindingClass::Uncovered, "src/uncovered.rs"));
2514 assert!(
2518 !current
2519 .iter()
2520 .any(|f| f.class == FindingClass::QueuedForAdjudication
2521 || f.class == FindingClass::Wrong),
2522 "deterministic adjudication leaves nothing queued"
2523 );
2524 assert!(!has(FindingClass::Uncovered, "src/present.rs"));
2526 }
2527
2528 #[test]
2534 fn finding_recorded_at_old_head_presents_in_brief_at_new_head() {
2535 use crate::ingest::render::render_sync_brief_for;
2536
2537 let tmp = tempfile::tempdir().unwrap();
2538 let root = tmp.path();
2539 let mem_dir = root.join("mem");
2540 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2541 std::fs::write(
2542 mem_dir.join(".memstead").join("config.json"),
2543 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2544 )
2545 .unwrap();
2546 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2547 std::fs::write(
2548 root.join(".memstead").join("workspace.toml"),
2549 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2550 )
2551 .unwrap();
2552 let mount = Mount {
2553 mem: "engine".to_string(),
2554 schema: Some("default@1.0.0".parse().unwrap()),
2555 storage: MountStorage::Folder {
2556 path: mem_dir.clone(),
2557 },
2558 capability: MountCapability::Write,
2559 lifecycle: MountLifecycle::Eager,
2560 cross_linkable: false,
2561 migration_target: None,
2562 };
2563 crate::FileWorkspaceStore::new()
2564 .save_state(
2565 root,
2566 &Workspace {
2567 mounts: vec![mount],
2568 settings: WorkspaceSettings::default(),
2569 },
2570 )
2571 .unwrap();
2572
2573 let git = |args: &[&str]| {
2575 let out = std::process::Command::new("git")
2576 .args(args)
2577 .current_dir(root)
2578 .env("GIT_AUTHOR_NAME", "t")
2579 .env("GIT_AUTHOR_EMAIL", "t@t")
2580 .env("GIT_COMMITTER_NAME", "t")
2581 .env("GIT_COMMITTER_EMAIL", "t@t")
2582 .output()
2583 .unwrap();
2584 assert!(
2585 out.status.success(),
2586 "git {args:?}: {}",
2587 String::from_utf8_lossy(&out.stderr)
2588 );
2589 };
2590 git(&["init", "-q"]);
2591 std::fs::create_dir_all(root.join("src")).unwrap();
2592 std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
2593 git(&["add", "-A"]);
2594 git(&["commit", "-qm", "head-a"]);
2595
2596 let mk = |artifact: &str| Anchor {
2599 artifact: artifact.to_string(),
2600 grain: AnchorGrain::File,
2601 class: AnchorProvenanceClass::InformedBy,
2602 at_version: None,
2603 hash: None,
2604 hash_stability: AnchorHashStability::Stable,
2605 derived_from: Vec::new(),
2606 binding: None,
2607 source: None,
2608 span_unvalidated: false,
2609 hash_source: None,
2610 last_observed: None,
2611 };
2612 std::fs::write(
2616 mem_dir.join("e.md"),
2617 "---\ntype: decision\n---\n\n# E\n\n## Decision\n\nBody.\n",
2618 )
2619 .unwrap();
2620 let mut sidecar = AnchorSidecar::default();
2621 sidecar.set("engine--e", vec![mk("src/present.rs"), mk("src/gone.rs")]);
2622 std::fs::write(
2623 mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
2624 sidecar.to_bytes(),
2625 )
2626 .unwrap();
2627
2628 write_binding(
2629 root,
2630 "engine",
2631 "graph",
2632 &Binding {
2633 version: BINDING_VERSION,
2634 intent: None,
2635 sources: vec![crate::pipeline::Source {
2636 name: "graph".to_string(),
2637 medium_type: MediumType::Codebase,
2638 pointer: String::new(),
2639 change_detection: Some("git".to_string()),
2640 scope: vec![PatternEntry {
2641 path: "src/**/*.rs".to_string(),
2642 mode: PatternMode::Allow,
2643 }],
2644 engagement: None,
2645 preparation: None,
2646 }],
2647 reference_mems: Vec::new(),
2648 destination_mem: "engine".to_string(),
2649 deny_paths: Vec::new(),
2650 coverage_semantics: None,
2651 rules: None,
2652 prune: None,
2653 operations: Operations {
2654 build: None,
2655 sync: Some(crate::binding::SyncOperation {
2656 trigger: IngestTrigger::Manual,
2657 batch_size: 20,
2658 }),
2659 verify: Some(VerifyOperation {
2660 trigger: IngestTrigger::Manual,
2661 batch_size: 20,
2662 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2663 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2664 }),
2665 },
2666 },
2667 )
2668 .unwrap();
2669
2670 let configs = load_pipeline_configs(root).unwrap();
2672 let binding = &configs.bindings[0].config;
2673 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
2674 let head_a_outcome = {
2675 let engine = Engine::from_workspace_root(root).unwrap();
2676 verify_binding(&engine, root, binding, &resolved).unwrap()
2677 };
2678 assert!(
2679 head_a_outcome.key.source_head.contains("graph="),
2680 "the run observed a facet head"
2681 );
2682
2683 std::fs::write(root.join("src").join("present.rs"), "fn a() {} // more\n").unwrap();
2685 git(&["add", "-A"]);
2686 git(&["commit", "-qm", "head-b"]);
2687
2688 {
2691 let engine = Engine::from_workspace_root(root).unwrap();
2692 let (key_b, findings) = current_findings(&engine, root, binding, &resolved).unwrap();
2693 assert_ne!(
2694 key_b.source_head, head_a_outcome.key.source_head,
2695 "the head really moved"
2696 );
2697 assert_eq!(findings.len(), 1);
2698 assert_eq!(findings[0].class, FindingClass::UnresolvableAnchor);
2699 assert_eq!(
2700 findings[0].key.source_head, head_a_outcome.key.source_head,
2701 "the finding still records the head it was observed at"
2702 );
2703
2704 let brief = render_sync_brief_for(&engine, root, "engine/graph").unwrap();
2705 assert!(brief.contains("## Open findings to repair"));
2706 assert!(brief.contains("src/gone.rs"));
2707 }
2708
2709 std::fs::write(root.join("src").join("gone.rs"), "fn g() {}\n").unwrap();
2712 git(&["add", "-A"]);
2713 git(&["commit", "-qm", "head-c"]);
2714 {
2715 let engine = Engine::from_workspace_root(root).unwrap();
2716 verify_binding(&engine, root, binding, &resolved).unwrap();
2717 }
2718 {
2720 let engine = Engine::from_workspace_root(root).unwrap();
2721 let (_key, findings) = current_findings(&engine, root, binding, &resolved).unwrap();
2722 assert!(
2723 findings
2724 .iter()
2725 .all(|f| f.class != FindingClass::UnresolvableAnchor),
2726 "the resolved orphan finding must not re-present: {findings:?}"
2727 );
2728 }
2729 }
2730
2731 #[test]
2749 fn hashless_anchor_backfills_once_then_drift_adjudicates_deterministically() {
2750 let tmp = tempfile::tempdir().unwrap();
2751 let root = tmp.path();
2752 let mem_dir = root.join("mem");
2753 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2754 std::fs::write(
2755 mem_dir.join(".memstead").join("config.json"),
2756 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2757 )
2758 .unwrap();
2759 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2760 std::fs::write(
2761 root.join(".memstead").join("workspace.toml"),
2762 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2763 )
2764 .unwrap();
2765 let mount = Mount {
2766 mem: "engine".to_string(),
2767 schema: Some("default@1.0.0".parse().unwrap()),
2768 storage: MountStorage::Folder {
2769 path: mem_dir.clone(),
2770 },
2771 capability: MountCapability::Write,
2772 lifecycle: MountLifecycle::Eager,
2773 cross_linkable: false,
2774 migration_target: None,
2775 };
2776 crate::FileWorkspaceStore::new()
2777 .save_state(
2778 root,
2779 &Workspace {
2780 mounts: vec![mount],
2781 settings: WorkspaceSettings::default(),
2782 },
2783 )
2784 .unwrap();
2785
2786 let git = |args: &[&str]| {
2788 let out = std::process::Command::new("git")
2789 .args(args)
2790 .current_dir(root)
2791 .env("GIT_AUTHOR_NAME", "t")
2792 .env("GIT_AUTHOR_EMAIL", "t@t")
2793 .env("GIT_COMMITTER_NAME", "t")
2794 .env("GIT_COMMITTER_EMAIL", "t@t")
2795 .output()
2796 .unwrap();
2797 assert!(
2798 out.status.success(),
2799 "git {args:?}: {}",
2800 String::from_utf8_lossy(&out.stderr)
2801 );
2802 };
2803 git(&["init", "-q"]);
2804 std::fs::create_dir_all(root.join("src")).unwrap();
2805 std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
2806 std::fs::write(root.join("src").join("other.rs"), "fn o() {}\n").unwrap();
2807 git(&["add", "-A"]);
2808 git(&["commit", "-qm", "head-a"]);
2809
2810 let mk = |artifact: &str, class: AnchorProvenanceClass, stab: AnchorHashStability| Anchor {
2814 artifact: artifact.to_string(),
2815 grain: AnchorGrain::File,
2816 class,
2817 at_version: None,
2818 hash: None,
2819 hash_stability: stab,
2820 derived_from: if class == AnchorProvenanceClass::Derived {
2821 vec!["src/present.rs".to_string()]
2822 } else {
2823 Vec::new()
2824 },
2825 binding: None,
2826 source: None,
2827 span_unvalidated: false,
2828 hash_source: None,
2829 last_observed: None,
2830 };
2831 use AnchorHashStability::{Stable, Unstable};
2832 std::fs::write(
2836 mem_dir.join("e.md"),
2837 "---\ntype: decision\n---\n\n# E\n\n## Decision\n\nBody.\n",
2838 )
2839 .unwrap();
2840 let mut sidecar = AnchorSidecar::default();
2841 sidecar.set(
2842 "engine--e",
2843 vec![
2844 mk("src/present.rs", AnchorProvenanceClass::Anchored, Stable),
2845 mk("src/present.rs", AnchorProvenanceClass::Derived, Stable),
2846 mk("src/other.rs", AnchorProvenanceClass::Anchored, Unstable),
2847 mk("src/present.rs", AnchorProvenanceClass::Authored, Stable),
2848 mk("src/present.rs", AnchorProvenanceClass::InformedBy, Stable),
2849 ],
2850 );
2851 std::fs::write(
2852 mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
2853 sidecar.to_bytes(),
2854 )
2855 .unwrap();
2856
2857 write_binding(
2858 root,
2859 "engine",
2860 "graph",
2861 &Binding {
2862 version: BINDING_VERSION,
2863 intent: None,
2864 sources: vec![crate::pipeline::Source {
2865 name: "graph".to_string(),
2866 medium_type: MediumType::Codebase,
2867 pointer: String::new(),
2868 change_detection: Some("git".to_string()),
2869 scope: vec![PatternEntry {
2870 path: "src/**/*.rs".to_string(),
2871 mode: PatternMode::Allow,
2872 }],
2873 engagement: None,
2874 preparation: None,
2875 }],
2876 reference_mems: Vec::new(),
2877 destination_mem: "engine".to_string(),
2878 deny_paths: Vec::new(),
2879 coverage_semantics: None,
2880 rules: None,
2881 prune: None,
2882 operations: Operations {
2883 build: None,
2884 sync: None,
2885 verify: Some(VerifyOperation {
2886 trigger: IngestTrigger::Manual,
2887 batch_size: 20,
2888 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2889 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2890 }),
2891 },
2892 },
2893 )
2894 .unwrap();
2895
2896 let configs = load_pipeline_configs(root).unwrap();
2897 let binding = &configs.bindings[0].config;
2898 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
2899
2900 {
2902 let mut engine = Engine::from_workspace_root(root).unwrap();
2903 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2904 let mut backfilled: Vec<(&str, &str)> = outcome
2907 .hash_backfill
2908 .iter()
2909 .map(|b| (b.entity.as_str(), b.artifact.as_str()))
2910 .collect();
2911 backfilled.sort();
2912 backfilled.dedup();
2913 assert_eq!(
2914 backfilled,
2915 vec![
2916 ("engine--e", "src/other.rs"),
2917 ("engine--e", "src/present.rs"),
2918 ],
2919 "hash-bearing anchors backfill; authored/informed-by never appear"
2920 );
2921 assert_eq!(
2924 outcome.backlog, 0,
2925 "no recheck queue for backfilled anchors"
2926 );
2927 let store = read_findings_store(root, "engine", "graph")
2928 .unwrap()
2929 .unwrap();
2930 assert!(
2931 store
2932 .current(&outcome.key)
2933 .iter()
2934 .all(|f| !matches!(f.target, FindingTarget::Anchor { .. })),
2935 "no anchor finding on the backfill pass: {:?}",
2936 store.current(&outcome.key)
2937 );
2938
2939 let written =
2941 record_anchor_hash_backfill(&mut engine, "engine", &outcome, None).unwrap();
2942 assert_eq!(
2943 written, 3,
2944 "anchored + derived + unstable-anchored gain hashes"
2945 );
2946 }
2947
2948 let expected_present = crate::anchor::prepared_content_hash(
2951 &std::fs::read(root.join("src").join("present.rs")).unwrap(),
2952 );
2953 {
2954 let sc = AnchorSidecar::from_bytes(
2955 &std::fs::read(mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH)).unwrap(),
2956 )
2957 .unwrap();
2958 for a in sc.get("engine--e") {
2959 if a.class.is_hash_bearing() {
2960 assert!(a.hash.is_some(), "hash-bearing anchor backfilled: {a:?}");
2961 } else {
2962 assert!(a.hash.is_none(), "non-hash class never gains a hash: {a:?}");
2963 }
2964 if a.artifact == "src/present.rs" && a.class.is_hash_bearing() {
2965 assert_eq!(a.hash.as_deref(), Some(expected_present.as_str()));
2966 }
2967 }
2968 }
2969
2970 {
2972 let mut engine = Engine::from_workspace_root(root).unwrap();
2973 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2974 assert!(
2975 outcome.hash_backfill.is_empty(),
2976 "backfill happens once — a re-verify observes an empty worklist"
2977 );
2978 assert_eq!(outcome.backlog, 0, "steady state: nothing re-queues");
2979 let store = read_findings_store(root, "engine", "graph")
2980 .unwrap()
2981 .unwrap();
2982 assert!(
2983 store
2984 .current(&outcome.key)
2985 .iter()
2986 .all(|f| !matches!(f.target, FindingTarget::Anchor { .. })),
2987 "recorded hashes match the source — no anchor finding"
2988 );
2989 let written =
2990 record_anchor_hash_backfill(&mut engine, "engine", &outcome, None).unwrap();
2991 assert_eq!(written, 0, "no write, no commit on the idempotent pass");
2992 }
2993
2994 std::fs::write(
2996 root.join("src").join("present.rs"),
2997 "fn a() { /* changed */ }\n",
2998 )
2999 .unwrap();
3000 std::fs::write(
3001 root.join("src").join("other.rs"),
3002 "fn o() { /* changed */ }\n",
3003 )
3004 .unwrap();
3005 git(&["add", "-A"]);
3006 git(&["commit", "-qm", "head-b"]);
3007
3008 {
3011 let engine = Engine::from_workspace_root(root).unwrap();
3012 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
3013 assert!(
3014 outcome.hash_backfill.is_empty(),
3015 "recorded hashes are never overwritten by observation"
3016 );
3017 let store = read_findings_store(root, "engine", "graph")
3018 .unwrap()
3019 .unwrap();
3020 let current = store.current(&outcome.key);
3021 let drifted: Vec<&Finding> = current
3022 .iter()
3023 .filter(|f| f.class == FindingClass::Drifted)
3024 .collect();
3025 assert_eq!(
3028 drifted.len(),
3029 2,
3030 "stable-medium mismatch → drifted: {current:?}"
3031 );
3032 assert!(drifted.iter().all(|f| matches!(
3033 &f.target,
3034 FindingTarget::Anchor { artifact, .. } if artifact == "src/present.rs"
3035 )));
3036 assert!(
3039 current
3040 .iter()
3041 .any(|f| f.class == FindingClass::QueuedForAdjudication
3042 && matches!(
3043 &f.target,
3044 FindingTarget::Anchor { artifact, .. } if artifact == "src/other.rs"
3045 )),
3046 "unstable medium resolves recheck (queued), not drifted: {current:?}"
3047 );
3048 assert!(
3049 !current.iter().any(|f| f.class == FindingClass::Drifted
3050 && matches!(
3051 &f.target,
3052 FindingTarget::Anchor { artifact, .. } if artifact == "src/other.rs"
3053 )),
3054 "an unstable hash break must never assert drift"
3055 );
3056 }
3057 }
3058
3059 #[test]
3068 fn plain_tree_anchor_backfills_then_adjudicates_deterministically() {
3069 let tmp = tempfile::tempdir().unwrap();
3070 let root = tmp.path();
3071 let mem_dir = root.join("mem");
3072 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3073 std::fs::write(
3074 mem_dir.join(".memstead").join("config.json"),
3075 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3076 )
3077 .unwrap();
3078 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3079 std::fs::write(
3080 root.join(".memstead").join("workspace.toml"),
3081 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3082 )
3083 .unwrap();
3084 crate::FileWorkspaceStore::new()
3085 .save_state(
3086 root,
3087 &Workspace {
3088 mounts: vec![Mount {
3089 mem: "engine".to_string(),
3090 schema: Some("default@1.0.0".parse().unwrap()),
3091 storage: MountStorage::Folder {
3092 path: mem_dir.clone(),
3093 },
3094 capability: MountCapability::Write,
3095 lifecycle: MountLifecycle::Eager,
3096 cross_linkable: false,
3097 migration_target: None,
3098 }],
3099 settings: WorkspaceSettings::default(),
3100 },
3101 )
3102 .unwrap();
3103
3104 let git = |args: &[&str]| {
3105 let out = std::process::Command::new("git")
3106 .args(args)
3107 .current_dir(root)
3108 .env("GIT_AUTHOR_NAME", "t")
3109 .env("GIT_AUTHOR_EMAIL", "t@t")
3110 .env("GIT_COMMITTER_NAME", "t")
3111 .env("GIT_COMMITTER_EMAIL", "t@t")
3112 .output()
3113 .unwrap();
3114 assert!(
3115 out.status.success(),
3116 "git {args:?}: {}",
3117 String::from_utf8_lossy(&out.stderr)
3118 );
3119 };
3120 git(&["init", "-q"]);
3121 std::fs::create_dir_all(root.join("src")).unwrap();
3122 std::fs::write(root.join("src").join("a.rs"), "fn a() {}\n").unwrap();
3123 std::fs::write(root.join("src").join("b.rs"), "fn b() {}\n").unwrap();
3124 git(&["add", "-A"]);
3125 git(&["commit", "-qm", "head-a"]);
3126
3127 std::fs::write(
3128 mem_dir.join("e.md"),
3129 "---\ntype: decision\n---\n\n# E\n\n## Decision\n\nBody.\n",
3130 )
3131 .unwrap();
3132 let mut sidecar = AnchorSidecar::default();
3136 sidecar.set(
3137 "engine--e",
3138 vec![Anchor {
3139 artifact: "src".to_string(),
3140 grain: AnchorGrain::Tree,
3141 class: AnchorProvenanceClass::Anchored,
3142 at_version: None,
3143 hash: None,
3144 hash_stability: AnchorHashStability::Stable,
3145 derived_from: Vec::new(),
3146 binding: None,
3147 source: Some("graph".to_string()),
3148 span_unvalidated: false,
3149 hash_source: None,
3150 last_observed: None,
3151 }],
3152 );
3153 std::fs::write(
3154 mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
3155 sidecar.to_bytes(),
3156 )
3157 .unwrap();
3158
3159 write_binding(
3160 root,
3161 "engine",
3162 "graph",
3163 &Binding {
3164 version: BINDING_VERSION,
3165 intent: None,
3166 sources: vec![crate::pipeline::Source {
3167 name: "graph".to_string(),
3168 medium_type: MediumType::Codebase,
3169 pointer: String::new(),
3170 change_detection: Some("git".to_string()),
3171 scope: vec![PatternEntry {
3172 path: "src/**/*.rs".to_string(),
3173 mode: PatternMode::Allow,
3174 }],
3175 engagement: None,
3176 preparation: None,
3177 }],
3178 reference_mems: Vec::new(),
3179 destination_mem: "engine".to_string(),
3180 deny_paths: Vec::new(),
3181 coverage_semantics: None,
3182 rules: None,
3183 prune: None,
3184 operations: Operations {
3185 build: None,
3186 sync: None,
3187 verify: Some(VerifyOperation {
3188 trigger: IngestTrigger::Manual,
3189 batch_size: 20,
3190 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
3191 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
3192 }),
3193 },
3194 },
3195 )
3196 .unwrap();
3197
3198 let configs = load_pipeline_configs(root).unwrap();
3199 let binding = &configs.bindings[0].config;
3200 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
3201
3202 let expected_digest = crate::anchor::prepared_content_hash(
3204 crate::preparation::plain_tree_digest(&[
3205 ("src/a.rs".to_string(), b"fn a() {}\n".to_vec()),
3206 ("src/b.rs".to_string(), b"fn b() {}\n".to_vec()),
3207 ])
3208 .as_bytes(),
3209 );
3210 {
3211 let mut engine = Engine::from_workspace_root(root).unwrap();
3212 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
3213 let backfilled: Vec<(&str, &str, &str)> = outcome
3214 .hash_backfill
3215 .iter()
3216 .map(|b| (b.entity.as_str(), b.artifact.as_str(), b.hash.as_str()))
3217 .collect();
3218 assert_eq!(
3219 backfilled,
3220 vec![("engine--e", "src", expected_digest.as_str())],
3221 "the tree anchor observes the plain digest and backfills"
3222 );
3223 assert_eq!(outcome.backlog, 0, "no recheck queue: the digest exists");
3224 let written =
3225 record_anchor_hash_backfill(&mut engine, "engine", &outcome, None).unwrap();
3226 assert_eq!(written, 1);
3227 }
3228
3229 {
3231 let engine = Engine::from_workspace_root(root).unwrap();
3232 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
3233 assert!(outcome.hash_backfill.is_empty(), "backfill happens once");
3234 assert_eq!(outcome.backlog, 0, "steady state: nothing re-queues");
3235 let store = read_findings_store(root, "engine", "graph")
3236 .unwrap()
3237 .unwrap();
3238 assert!(
3239 store
3240 .current(&outcome.key)
3241 .iter()
3242 .all(|f| !matches!(f.target, FindingTarget::Anchor { .. })),
3243 "unchanged tree resolves clean: {:?}",
3244 store.current(&outcome.key)
3245 );
3246 }
3247
3248 std::fs::write(root.join("src").join("c.rs"), "fn c() {}\n").unwrap();
3250 git(&["add", "-A"]);
3251 git(&["commit", "-qm", "head-b"]);
3252
3253 {
3255 let engine = Engine::from_workspace_root(root).unwrap();
3256 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
3257 assert!(outcome.hash_backfill.is_empty());
3258 assert_eq!(outcome.backlog, 0, "drift is asserted, never queued");
3259 let store = read_findings_store(root, "engine", "graph")
3260 .unwrap()
3261 .unwrap();
3262 let current = store.current(&outcome.key);
3263 assert!(
3264 current.iter().any(|f| f.class == FindingClass::Drifted
3265 && matches!(
3266 &f.target,
3267 FindingTarget::Anchor { artifact, .. } if artifact == "src"
3268 )),
3269 "a joined file drifts the tree anchor deterministically: {current:?}"
3270 );
3271 assert!(
3272 !current
3273 .iter()
3274 .any(|f| f.class == FindingClass::QueuedForAdjudication),
3275 "the perpetual recheck loop is sealed: {current:?}"
3276 );
3277 }
3278 }
3279
3280 #[test]
3285 fn backfill_writer_refuses_non_hash_classes_and_never_overwrites() {
3286 let tmp = tempfile::tempdir().unwrap();
3287 let root = tmp.path();
3288 let mem_dir = root.join("mem");
3289 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3290 std::fs::write(
3291 mem_dir.join(".memstead").join("config.json"),
3292 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3293 )
3294 .unwrap();
3295 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3296 std::fs::write(
3297 root.join(".memstead").join("workspace.toml"),
3298 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3299 )
3300 .unwrap();
3301 crate::FileWorkspaceStore::new()
3302 .save_state(
3303 root,
3304 &Workspace {
3305 mounts: vec![Mount {
3306 mem: "engine".to_string(),
3307 schema: Some("default@1.0.0".parse().unwrap()),
3308 storage: MountStorage::Folder {
3309 path: mem_dir.clone(),
3310 },
3311 capability: MountCapability::Write,
3312 lifecycle: MountLifecycle::Eager,
3313 cross_linkable: false,
3314 migration_target: None,
3315 }],
3316 settings: WorkspaceSettings::default(),
3317 },
3318 )
3319 .unwrap();
3320
3321 let anchor = |class: AnchorProvenanceClass, hash: Option<&str>| Anchor {
3322 artifact: "src/a.rs".to_string(),
3323 grain: AnchorGrain::File,
3324 class,
3325 at_version: None,
3326 hash: hash.map(str::to_string),
3327 hash_stability: AnchorHashStability::Stable,
3328 derived_from: Vec::new(),
3329 binding: None,
3330 source: None,
3331 span_unvalidated: false,
3332 hash_source: None,
3333 last_observed: None,
3334 };
3335 std::fs::write(
3339 mem_dir.join("e.md"),
3340 "---\ntype: decision\n---\n\n# E\n\n## Decision\n\nBody.\n",
3341 )
3342 .unwrap();
3343 let mut sidecar = AnchorSidecar::default();
3344 sidecar.set(
3345 "engine--e",
3346 vec![
3347 anchor(AnchorProvenanceClass::Authored, None),
3348 anchor(AnchorProvenanceClass::InformedBy, None),
3349 anchor(AnchorProvenanceClass::Anchored, Some("recorded")),
3350 ],
3351 );
3352 std::fs::write(
3353 mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
3354 sidecar.to_bytes(),
3355 )
3356 .unwrap();
3357
3358 let mut engine = Engine::from_workspace_root(root).unwrap();
3359 let written = engine
3360 .record_anchor_observed_hashes(
3361 "engine",
3362 &[crate::anchor::ObservedArtifactHash {
3363 entity: "engine--e".to_string(),
3364 artifact: "src/a.rs".to_string(),
3365 hash: "observed".to_string(),
3366 }],
3367 None,
3368 )
3369 .unwrap();
3370 assert_eq!(
3371 written, 0,
3372 "non-hash classes refuse the hash; a recorded hash is never overwritten"
3373 );
3374 let sc = AnchorSidecar::from_bytes(
3375 &std::fs::read(mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH)).unwrap(),
3376 )
3377 .unwrap();
3378 for a in sc.get("engine--e") {
3379 match a.class {
3380 AnchorProvenanceClass::Anchored => {
3381 assert_eq!(a.hash.as_deref(), Some("recorded"), "baseline stands")
3382 }
3383 _ => assert!(a.hash.is_none(), "non-hash class stays hash-less: {a:?}"),
3384 }
3385 }
3386 }
3387
3388 #[test]
3404 fn verify_refuses_unreachable_source_with_typed_error() {
3405 let tmp = tempfile::tempdir().unwrap();
3406 let root = tmp.path();
3407 let mem_dir = root.join("mem");
3408 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3409 std::fs::write(
3410 mem_dir.join(".memstead").join("config.json"),
3411 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3412 )
3413 .unwrap();
3414 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3415 std::fs::write(
3416 root.join(".memstead").join("workspace.toml"),
3417 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3418 )
3419 .unwrap();
3420 let mount = Mount {
3421 mem: "engine".to_string(),
3422 schema: Some("default@1.0.0".parse().unwrap()),
3423 storage: MountStorage::Folder {
3424 path: mem_dir.clone(),
3425 },
3426 capability: MountCapability::Write,
3427 lifecycle: MountLifecycle::Eager,
3428 cross_linkable: false,
3429 migration_target: None,
3430 };
3431 crate::FileWorkspaceStore::new()
3432 .save_state(
3433 root,
3434 &Workspace {
3435 mounts: vec![mount],
3436 settings: WorkspaceSettings::default(),
3437 },
3438 )
3439 .unwrap();
3440
3441 write_binding(
3445 root,
3446 "engine",
3447 "gone",
3448 &Binding {
3449 version: BINDING_VERSION,
3450 intent: None,
3451 sources: vec![crate::pipeline::Source {
3452 name: "gone".to_string(),
3453 medium_type: MediumType::Codebase,
3454 pointer: "vanished-src".to_string(),
3455 change_detection: Some("git".to_string()),
3456 scope: vec![PatternEntry {
3457 path: "**/*.rs".to_string(),
3458 mode: PatternMode::Allow,
3459 }],
3460 engagement: None,
3461 preparation: None,
3462 }],
3463 reference_mems: Vec::new(),
3464 destination_mem: "engine".to_string(),
3465 deny_paths: Vec::new(),
3466 coverage_semantics: None,
3467 rules: None,
3468 prune: None,
3469 operations: Operations {
3470 build: None,
3471 sync: None,
3472 verify: Some(VerifyOperation {
3473 trigger: IngestTrigger::Manual,
3474 batch_size: 20,
3475 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
3476 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
3477 }),
3478 },
3479 },
3480 )
3481 .unwrap();
3482
3483 let engine = Engine::from_workspace_root(root).unwrap();
3484 let configs = load_pipeline_configs(root).unwrap();
3485 let binding = &configs.bindings[0].config;
3486 let resolved = resolve_binding_run("engine/gone", binding).unwrap();
3487
3488 match verify_binding(&engine, root, binding, &resolved) {
3489 Err(FindingsError::SourceUnreachable { source_name, path }) => {
3490 assert_eq!(source_name, "gone");
3491 assert!(
3492 path.ends_with("vanished-src"),
3493 "refusal must name the resolved missing path, got `{path}`",
3494 );
3495 }
3496 other => panic!("expected SourceUnreachable refusal, got {other:?}"),
3497 }
3498
3499 assert!(
3502 !engine
3503 .mem_config_for("engine")
3504 .unwrap()
3505 .sync_state
3506 .keys()
3507 .any(|k| k.ends_with("#verified")),
3508 "a refused verify must not leave any #verified token",
3509 );
3510 }
3511
3512 #[test]
3513 fn completed_verify_records_the_verified_baseline() {
3514 let tmp = tempfile::tempdir().unwrap();
3515 let root = tmp.path();
3516 let mem_dir = root.join("mem");
3517 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3518 std::fs::write(
3519 mem_dir.join(".memstead").join("config.json"),
3520 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3521 )
3522 .unwrap();
3523 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3524 std::fs::write(
3525 root.join(".memstead").join("workspace.toml"),
3526 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3527 )
3528 .unwrap();
3529 let mount = Mount {
3530 mem: "engine".to_string(),
3531 schema: Some("default@1.0.0".parse().unwrap()),
3532 storage: MountStorage::Folder {
3533 path: mem_dir.clone(),
3534 },
3535 capability: MountCapability::Write,
3536 lifecycle: MountLifecycle::Eager,
3537 cross_linkable: false,
3538 migration_target: None,
3539 };
3540 crate::FileWorkspaceStore::new()
3541 .save_state(
3542 root,
3543 &Workspace {
3544 mounts: vec![mount],
3545 settings: WorkspaceSettings::default(),
3546 },
3547 )
3548 .unwrap();
3549 let out = std::process::Command::new("git")
3550 .args(["init", "-q"])
3551 .current_dir(root)
3552 .output()
3553 .unwrap();
3554 assert!(out.status.success());
3555
3556 write_binding(
3557 root,
3558 "engine",
3559 "graph",
3560 &Binding {
3561 version: BINDING_VERSION,
3562 intent: None,
3563 sources: vec![crate::pipeline::Source {
3564 name: "graph".to_string(),
3565 medium_type: MediumType::Codebase,
3566 pointer: String::new(),
3567 change_detection: Some("git".to_string()),
3568 scope: vec![PatternEntry {
3569 path: "src/**/*.rs".to_string(),
3570 mode: PatternMode::Allow,
3571 }],
3572 engagement: None,
3573 preparation: None,
3574 }],
3575 reference_mems: Vec::new(),
3576 destination_mem: "engine".to_string(),
3577 deny_paths: Vec::new(),
3578 coverage_semantics: None,
3579 rules: None,
3580 prune: None,
3581 operations: Operations {
3582 build: Some(BuildOperation {
3583 mode: BuildMode::Discovery,
3584 trigger: IngestTrigger::Loop,
3585 batch_size: 20,
3586 post_actions: None,
3587 }),
3588 sync: None,
3589 verify: Some(VerifyOperation {
3590 trigger: IngestTrigger::Manual,
3591 batch_size: 20,
3592 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
3593 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
3594 }),
3595 },
3596 },
3597 )
3598 .unwrap();
3599
3600 let mut engine = Engine::from_workspace_root(root).unwrap();
3601 engine
3604 .set_mem_sync_state("engine", "engine/graph/graph#synced", "deadbeef", None)
3605 .unwrap();
3606
3607 let configs = load_pipeline_configs(root).unwrap();
3608 let binding = &configs.bindings[0].config;
3609 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
3610
3611 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
3612 assert_eq!(
3614 outcome.facet_heads.get("graph").map(String::as_str),
3615 Some("deadbeef")
3616 );
3617 assert_eq!(outcome.key.source_head, "graph=deadbeef");
3618 assert_eq!(
3619 join_facet_heads(&outcome.facet_heads),
3620 outcome.key.source_head
3621 );
3622
3623 assert!(
3625 !engine
3626 .mem_config_for("engine")
3627 .unwrap()
3628 .sync_state
3629 .contains_key("engine/graph/graph#verified")
3630 );
3631
3632 let written = record_verified_baseline(&mut engine, "engine", &outcome, None).unwrap();
3633 assert_eq!(written, vec!["engine/graph/graph#verified".to_string()]);
3634
3635 assert_eq!(
3637 engine
3638 .mem_config_for("engine")
3639 .unwrap()
3640 .sync_state
3641 .get("engine/graph/graph#verified")
3642 .map(String::as_str),
3643 Some("deadbeef")
3644 );
3645 let disk: serde_json::Value = serde_json::from_slice(
3647 &std::fs::read(mem_dir.join(".memstead").join("config.json")).unwrap(),
3648 )
3649 .unwrap();
3650 assert_eq!(
3651 disk["syncState"]["engine/graph/graph#verified"],
3652 serde_json::json!("deadbeef")
3653 );
3654 }
3655
3656 #[test]
3663 fn adjudication_cap_queues_the_remainder() {
3664 let k = key("h", "s");
3665 let mk = |art: &str| {
3666 let mut a = anchor(AnchorProvenanceClass::Anchored);
3667 a.artifact = art.to_string();
3668 a
3669 };
3670 let candidates = vec![
3671 (
3672 "engine--a".to_string(),
3673 mk("src/a.rs"),
3674 AnchorState::Drifted,
3675 ),
3676 (
3677 "engine--b".to_string(),
3678 mk("src/b.rs"),
3679 AnchorState::Drifted,
3680 ),
3681 (
3682 "engine--c".to_string(),
3683 mk("src/c.rs"),
3684 AnchorState::Drifted,
3685 ),
3686 ];
3687 let window: BTreeSet<String> = [candidate_key("engine--a", &mk("src/a.rs"))]
3689 .into_iter()
3690 .collect();
3691 let out = adjudicate_candidates(&k, "f", &candidates, Some(&window), "1");
3692 let drifted = out
3693 .iter()
3694 .filter(|f| f.class == FindingClass::Drifted)
3695 .count();
3696 let queued = out
3697 .iter()
3698 .filter(|f| f.class == FindingClass::QueuedForAdjudication)
3699 .count();
3700 assert_eq!(drifted, 1, "only the in-window candidate is adjudicated");
3701 assert_eq!(queued, 2, "the remainder is queued as the tier-3 backlog");
3702 assert!(
3704 out.iter()
3705 .any(|f| f.class == FindingClass::QueuedForAdjudication
3706 && f.detail.contains("cap reached")),
3707 "capped remainder states it was deferred by the cap"
3708 );
3709
3710 let uncapped = adjudicate_candidates(&k, "f", &candidates, None, "1");
3712 assert_eq!(
3713 uncapped
3714 .iter()
3715 .filter(|f| f.class == FindingClass::Drifted)
3716 .count(),
3717 3,
3718 "uncapped adjudicates every candidate"
3719 );
3720 assert_eq!(
3721 uncapped
3722 .iter()
3723 .filter(|f| f.class == FindingClass::QueuedForAdjudication)
3724 .count(),
3725 0
3726 );
3727 }
3728
3729 #[test]
3735 fn full_resync_schedule_disabled_notdue_due() {
3736 let codebase = FacetEnumerability {
3737 facet: "src".to_string(),
3738 medium_type: "codebase".to_string(),
3739 enumerable: true,
3740 };
3741 assert_eq!(
3742 schedule_full_resync(0, 5, std::slice::from_ref(&codebase)),
3743 FullResyncDecision::Disabled
3744 );
3745 match schedule_full_resync(3, 2, std::slice::from_ref(&codebase)) {
3746 FullResyncDecision::NotDue { runs_until_due, .. } => assert_eq!(runs_until_due, 1),
3747 other => panic!("expected NotDue, got {other:?}"),
3748 }
3749 match schedule_full_resync(3, 3, std::slice::from_ref(&codebase)) {
3750 FullResyncDecision::Due {
3751 walked_facets,
3752 refused,
3753 ..
3754 } => {
3755 assert_eq!(walked_facets, vec!["src".to_string()]);
3756 assert!(refused.is_empty(), "enumerable facet is not refused");
3757 }
3758 other => panic!("expected Due, got {other:?}"),
3759 }
3760 }
3761
3762 #[test]
3765 fn full_resync_refuses_non_enumerable_medium() {
3766 let web = FacetEnumerability {
3767 facet: "manual".to_string(),
3768 medium_type: "web".to_string(),
3769 enumerable: false,
3770 };
3771 let d = schedule_full_resync(1, 1, &[web]);
3772 assert!(
3773 d.is_full_walk(),
3774 "a due sweep is a full walk even when refused"
3775 );
3776 match d {
3777 FullResyncDecision::Due {
3778 walked_facets,
3779 refused,
3780 ..
3781 } => {
3782 assert!(walked_facets.is_empty(), "nothing enumerable to walk");
3783 assert_eq!(refused.len(), 1, "the non-enumerable facet is refused");
3784 assert_eq!(refused[0].facet, "manual");
3785 assert_eq!(refused[0].medium_type, "web");
3786 assert!(
3787 refused[0].reason.contains("non-enumerable"),
3788 "the refusal is typed and states why"
3789 );
3790 }
3791 other => panic!("expected Due with a refusal, got {other:?}"),
3792 }
3793 }
3794
3795 #[test]
3800 fn full_resync_full_walk_covers_whole_source() {
3801 let tmp = tempfile::tempdir().unwrap();
3802 let root = tmp.path();
3803 let mem_dir = root.join("mem");
3804 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3805 std::fs::write(
3806 mem_dir.join(".memstead").join("config.json"),
3807 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3808 )
3809 .unwrap();
3810 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3811 std::fs::write(
3812 root.join(".memstead").join("workspace.toml"),
3813 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3814 )
3815 .unwrap();
3816 let mount = Mount {
3817 mem: "engine".to_string(),
3818 schema: Some("default@1.0.0".parse().unwrap()),
3819 storage: MountStorage::Folder {
3820 path: mem_dir.clone(),
3821 },
3822 capability: MountCapability::Write,
3823 lifecycle: MountLifecycle::Eager,
3824 cross_linkable: false,
3825 migration_target: None,
3826 };
3827 crate::FileWorkspaceStore::new()
3828 .save_state(
3829 root,
3830 &Workspace {
3831 mounts: vec![mount],
3832 settings: WorkspaceSettings::default(),
3833 },
3834 )
3835 .unwrap();
3836 let out = std::process::Command::new("git")
3837 .args(["init", "-q"])
3838 .current_dir(root)
3839 .output()
3840 .unwrap();
3841 assert!(out.status.success());
3842 std::fs::create_dir_all(root.join("src")).unwrap();
3843 for f in ["a.rs", "b.rs", "c.rs"] {
3844 std::fs::write(root.join("src").join(f), "fn x() {}\n").unwrap();
3845 }
3846
3847 write_binding(
3848 root,
3849 "engine",
3850 "graph",
3851 &Binding {
3852 version: BINDING_VERSION,
3853 intent: None,
3854 sources: vec![crate::pipeline::Source {
3855 name: "graph".to_string(),
3856 medium_type: MediumType::Codebase,
3857 pointer: String::new(),
3858 change_detection: Some("git".to_string()),
3859 scope: vec![PatternEntry {
3860 path: "src/**/*.rs".to_string(),
3861 mode: PatternMode::Allow,
3862 }],
3863 engagement: None,
3864 preparation: None,
3865 }],
3866 reference_mems: Vec::new(),
3867 destination_mem: "engine".to_string(),
3868 deny_paths: Vec::new(),
3869 coverage_semantics: None,
3870 rules: None,
3871 prune: None,
3872 operations: Operations {
3873 build: Some(BuildOperation {
3874 mode: BuildMode::Discovery,
3875 trigger: IngestTrigger::Loop,
3876 batch_size: 20,
3877 post_actions: None,
3878 }),
3879 sync: None,
3880 verify: Some(VerifyOperation {
3881 trigger: IngestTrigger::Manual,
3882 batch_size: 1, adjudication_cap: DEFAULT_ADJUDICATION_CAP,
3884 full_resync_every: 1, }),
3886 },
3887 },
3888 )
3889 .unwrap();
3890
3891 let engine = Engine::from_workspace_root(root).unwrap();
3892 let configs = load_pipeline_configs(root).unwrap();
3893 let binding = &configs.bindings[0].config;
3894 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
3895
3896 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
3897 match &outcome.full_resync {
3899 FullResyncDecision::Due {
3900 walked_facets,
3901 refused,
3902 run_count,
3903 ..
3904 } => {
3905 assert_eq!(*run_count, 1);
3906 assert_eq!(walked_facets, &vec!["graph".to_string()]);
3907 assert!(refused.is_empty());
3908 }
3909 other => panic!("expected a due full walk, got {other:?}"),
3910 }
3911 let store = read_findings_store(root, "engine", "graph")
3913 .unwrap()
3914 .unwrap();
3915 let uncovered = store
3916 .current(&outcome.key)
3917 .iter()
3918 .filter(|f| f.class == FindingClass::Uncovered)
3919 .count();
3920 assert_eq!(
3921 uncovered, 3,
3922 "the scheduled full walk covers the whole source, not a batch of one"
3923 );
3924 }
3925
3926 #[test]
3933 fn scheduled_full_walk_demotes_partial_facet_to_refusal() {
3934 let tmp = tempfile::tempdir().unwrap();
3935 let root = tmp.path();
3936 let mem_dir = root.join("mem");
3937 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3938 std::fs::write(
3939 mem_dir.join(".memstead").join("config.json"),
3940 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3941 )
3942 .unwrap();
3943 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3944 std::fs::write(
3945 root.join(".memstead").join("workspace.toml"),
3946 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3947 )
3948 .unwrap();
3949 let mount = Mount {
3950 mem: "engine".to_string(),
3951 schema: Some("default@1.0.0".parse().unwrap()),
3952 storage: MountStorage::Folder {
3953 path: mem_dir.clone(),
3954 },
3955 capability: MountCapability::Write,
3956 lifecycle: MountLifecycle::Eager,
3957 cross_linkable: false,
3958 migration_target: None,
3959 };
3960 crate::FileWorkspaceStore::new()
3961 .save_state(
3962 root,
3963 &Workspace {
3964 mounts: vec![mount],
3965 settings: WorkspaceSettings::default(),
3966 },
3967 )
3968 .unwrap();
3969 let out = std::process::Command::new("git")
3970 .args(["init", "-q"])
3971 .current_dir(root)
3972 .output()
3973 .unwrap();
3974 assert!(out.status.success());
3975 std::fs::create_dir_all(root.join("src")).unwrap();
3976 std::fs::write(root.join("src").join("a.rs"), "fn x() {}\n").unwrap();
3977
3978 write_binding(
3979 root,
3980 "engine",
3981 "graph",
3982 &Binding {
3983 version: BINDING_VERSION,
3984 intent: None,
3985 sources: vec![crate::pipeline::Source {
3986 name: "graph".to_string(),
3987 medium_type: MediumType::Codebase,
3988 pointer: "src".to_string(),
3989 change_detection: Some("git".to_string()),
3990 scope: vec![
3994 PatternEntry {
3995 path: "**/*.rs".to_string(),
3996 mode: PatternMode::Allow,
3997 },
3998 PatternEntry {
3999 path: "src/nested.rs".to_string(),
4000 mode: PatternMode::Allow,
4001 },
4002 ],
4003 engagement: None,
4004 preparation: None,
4005 }],
4006 reference_mems: Vec::new(),
4007 destination_mem: "engine".to_string(),
4008 deny_paths: Vec::new(),
4009 coverage_semantics: None,
4010 rules: None,
4011 prune: None,
4012 operations: Operations {
4013 build: Some(BuildOperation {
4014 mode: BuildMode::Discovery,
4015 trigger: IngestTrigger::Loop,
4016 batch_size: 20,
4017 post_actions: None,
4018 }),
4019 sync: None,
4020 verify: Some(VerifyOperation {
4021 trigger: IngestTrigger::Manual,
4022 batch_size: 1,
4023 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
4024 full_resync_every: 1, }),
4026 },
4027 },
4028 )
4029 .unwrap();
4030
4031 let engine = Engine::from_workspace_root(root).unwrap();
4032 let configs = load_pipeline_configs(root).unwrap();
4033 let binding = &configs.bindings[0].config;
4034 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
4035
4036 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
4037 match &outcome.full_resync {
4038 FullResyncDecision::Due {
4039 walked_facets,
4040 refused,
4041 ..
4042 } => {
4043 assert!(
4044 walked_facets.is_empty(),
4045 "a partial facet must not be announced as walked-in-full: {walked_facets:?}"
4046 );
4047 assert_eq!(refused.len(), 1, "the partial facet is refused, typed");
4048 assert_eq!(refused[0].facet, "graph");
4049 assert!(
4050 refused[0].reason.contains("incomplete"),
4051 "the refusal names the partiality: {}",
4052 refused[0].reason
4053 );
4054 }
4055 other => panic!("expected a due full walk decision, got {other:?}"),
4056 }
4057 }
4058
4059 #[test]
4070 fn full_verify_uncaps_adjudication_and_walks_whole_source() {
4071 let tmp = tempfile::tempdir().unwrap();
4072 let root = tmp.path();
4073 let mem_dir = root.join("mem");
4074 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
4075 std::fs::write(
4076 mem_dir.join(".memstead").join("config.json"),
4077 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
4078 )
4079 .unwrap();
4080 std::fs::create_dir_all(root.join(".memstead")).unwrap();
4081 std::fs::write(
4082 root.join(".memstead").join("workspace.toml"),
4083 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
4084 )
4085 .unwrap();
4086 crate::FileWorkspaceStore::new()
4087 .save_state(
4088 root,
4089 &Workspace {
4090 mounts: vec![Mount {
4091 mem: "engine".to_string(),
4092 schema: Some("default@1.0.0".parse().unwrap()),
4093 storage: MountStorage::Folder {
4094 path: mem_dir.clone(),
4095 },
4096 capability: MountCapability::Write,
4097 lifecycle: MountLifecycle::Eager,
4098 cross_linkable: false,
4099 migration_target: None,
4100 }],
4101 settings: WorkspaceSettings::default(),
4102 },
4103 )
4104 .unwrap();
4105 let out = std::process::Command::new("git")
4106 .args(["init", "-q"])
4107 .current_dir(root)
4108 .output()
4109 .unwrap();
4110 assert!(out.status.success());
4111 std::fs::create_dir_all(root.join("src")).unwrap();
4112 for f in ["a.rs", "b.rs", "c.rs", "d.rs", "e.rs", "f.rs"] {
4114 std::fs::write(root.join("src").join(f), "fn x() {}\n").unwrap();
4115 }
4116 let mk = |art: &str| Anchor {
4117 artifact: art.to_string(),
4118 grain: AnchorGrain::File,
4119 class: AnchorProvenanceClass::Anchored,
4120 at_version: None,
4121 hash: Some("stale-recorded-hash".to_string()), hash_stability: AnchorHashStability::Stable,
4123 derived_from: Vec::new(),
4124 binding: None,
4125 source: None,
4126 span_unvalidated: false,
4127 hash_source: None,
4128 last_observed: None,
4129 };
4130 std::fs::write(
4134 mem_dir.join("e.md"),
4135 "---\ntype: decision\n---\n\n# E\n\n## Decision\n\nBody.\n",
4136 )
4137 .unwrap();
4138 let mut sidecar = AnchorSidecar::default();
4139 sidecar.set(
4140 "engine--e",
4141 vec![mk("src/a.rs"), mk("src/b.rs"), mk("src/c.rs")],
4142 );
4143 std::fs::write(
4144 mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
4145 sidecar.to_bytes(),
4146 )
4147 .unwrap();
4148
4149 write_binding(
4150 root,
4151 "engine",
4152 "graph",
4153 &Binding {
4154 version: BINDING_VERSION,
4155 intent: None,
4156 sources: vec![crate::pipeline::Source {
4157 name: "graph".to_string(),
4158 medium_type: MediumType::Codebase,
4159 pointer: String::new(),
4160 change_detection: Some("git".to_string()),
4161 scope: vec![PatternEntry {
4162 path: "src/**/*.rs".to_string(),
4163 mode: PatternMode::Allow,
4164 }],
4165 engagement: None,
4166 preparation: None,
4167 }],
4168 reference_mems: Vec::new(),
4169 destination_mem: "engine".to_string(),
4170 deny_paths: Vec::new(),
4171 coverage_semantics: None,
4172 rules: None,
4173 prune: None,
4174 operations: Operations {
4175 build: None,
4176 sync: None,
4177 verify: Some(VerifyOperation {
4178 trigger: IngestTrigger::Manual,
4179 batch_size: 1, adjudication_cap: 1, full_resync_every: 0, }),
4183 },
4184 },
4185 )
4186 .unwrap();
4187
4188 let engine = Engine::from_workspace_root(root).unwrap();
4189 let configs = load_pipeline_configs(root).unwrap();
4190 let binding = &configs.bindings[0].config;
4191 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
4192
4193 let sampled = verify_binding(&engine, root, binding, &resolved).unwrap();
4197 assert_eq!(sampled.full_resync, FullResyncDecision::Disabled);
4198 let store = read_findings_store(root, "engine", "graph")
4199 .unwrap()
4200 .unwrap();
4201 let current = store.current(&sampled.key);
4202 let count = |c: FindingClass| current.iter().filter(|f| f.class == c).count();
4203 assert_eq!(count(FindingClass::Drifted), 1, "cap-1 adjudicates one");
4204 assert_eq!(
4205 count(FindingClass::QueuedForAdjudication),
4206 2,
4207 "the remainder queues"
4208 );
4209 assert!(
4210 current
4211 .iter()
4212 .any(|f| f.class == FindingClass::QueuedForAdjudication
4213 && f.detail.contains("cap reached")),
4214 "the sampled deferral states the cap"
4215 );
4216 assert!(
4217 count(FindingClass::Uncovered) <= 1,
4218 "batch-1 sample looks at one artifact"
4219 );
4220
4221 let full = verify_binding_full(&engine, root, binding, &resolved).unwrap();
4224 assert_eq!(
4225 full.full_resync,
4226 FullResyncDecision::Forced {
4227 walked_facets: vec!["graph".to_string()]
4228 }
4229 );
4230 assert_eq!(full.backlog, 0, "cap treated as unlimited — no backlog");
4231 let store = read_findings_store(root, "engine", "graph")
4232 .unwrap()
4233 .unwrap();
4234 let current = store.current(&full.key);
4235 let count = |c: FindingClass| current.iter().filter(|f| f.class == c).count();
4236 assert_eq!(
4237 count(FindingClass::Drifted),
4238 3,
4239 "every candidate adjudicated"
4240 );
4241 assert_eq!(count(FindingClass::QueuedForAdjudication), 0);
4242 assert_eq!(
4243 count(FindingClass::Uncovered),
4244 3,
4245 "the whole S(D) walked — every uncovered file flagged"
4246 );
4247 assert!(
4248 current.iter().all(|f| !f.detail.contains("cap reached")),
4249 "a full run's findings carry no cap-deferral caveat"
4250 );
4251 }
4252
4253 #[test]
4258 fn full_verify_refuses_non_enumerable_medium_typed() {
4259 let tmp = tempfile::tempdir().unwrap();
4260 let root = tmp.path();
4261 let mem_dir = root.join("mem");
4262 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
4263 std::fs::write(
4264 mem_dir.join(".memstead").join("config.json"),
4265 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
4266 )
4267 .unwrap();
4268 std::fs::create_dir_all(root.join(".memstead")).unwrap();
4269 std::fs::write(
4270 root.join(".memstead").join("workspace.toml"),
4271 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
4272 )
4273 .unwrap();
4274 crate::FileWorkspaceStore::new()
4275 .save_state(
4276 root,
4277 &Workspace {
4278 mounts: vec![Mount {
4279 mem: "engine".to_string(),
4280 schema: Some("default@1.0.0".parse().unwrap()),
4281 storage: MountStorage::Folder {
4282 path: mem_dir.clone(),
4283 },
4284 capability: MountCapability::Write,
4285 lifecycle: MountLifecycle::Eager,
4286 cross_linkable: false,
4287 migration_target: None,
4288 }],
4289 settings: WorkspaceSettings::default(),
4290 },
4291 )
4292 .unwrap();
4293
4294 write_binding(
4296 root,
4297 "engine",
4298 "manual",
4299 &Binding {
4300 version: BINDING_VERSION,
4301 intent: None,
4302 sources: vec![crate::pipeline::Source {
4303 name: "manual".to_string(),
4304 medium_type: MediumType::Web,
4305 pointer: "https://example.com/docs".to_string(),
4306 change_detection: None,
4307 scope: Vec::new(),
4308 engagement: None,
4309 preparation: None,
4310 }],
4311 reference_mems: Vec::new(),
4312 destination_mem: "engine".to_string(),
4313 deny_paths: Vec::new(),
4314 coverage_semantics: Some(CoverageSemantics::Curated),
4315 rules: None,
4316 prune: None,
4317 operations: Operations {
4318 build: None,
4319 sync: None,
4320 verify: Some(VerifyOperation {
4321 trigger: IngestTrigger::Manual,
4322 batch_size: 20,
4323 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
4324 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
4325 }),
4326 },
4327 },
4328 )
4329 .unwrap();
4330
4331 let engine = Engine::from_workspace_root(root).unwrap();
4332 let configs = load_pipeline_configs(root).unwrap();
4333 let binding = &configs.bindings[0].config;
4334 let resolved = resolve_binding_run("engine/manual", binding).unwrap();
4335
4336 let err = verify_binding_full(&engine, root, binding, &resolved).unwrap_err();
4338 match &err {
4339 FindingsError::FullWalkNonEnumerable(refusal) => {
4340 assert_eq!(refusal.facet, "manual");
4341 assert_eq!(refusal.medium_type, "web");
4342 assert!(refusal.reason.contains("non-enumerable"));
4343 }
4344 other => panic!("expected FullWalkNonEnumerable, got {other:?}"),
4345 }
4346 assert!(
4347 read_findings_store(root, "engine", "manual")
4348 .unwrap()
4349 .is_none(),
4350 "a refused full run records nothing"
4351 );
4352
4353 let sampled = verify_binding(&engine, root, binding, &resolved).unwrap();
4355 assert_eq!(sampled.binding, "engine/manual");
4356 }
4357
4358 fn sourceless_binding() -> crate::binding::Binding {
4359 crate::binding::Binding {
4360 version: crate::binding::BINDING_VERSION,
4361 intent: None,
4362 sources: Vec::new(),
4363 reference_mems: Vec::new(),
4364 destination_mem: "m".to_string(),
4365 deny_paths: Vec::new(),
4366 coverage_semantics: None,
4367 rules: None,
4368 prune: None,
4369 operations: crate::binding::Operations {
4370 build: None,
4371 sync: None,
4372 verify: None,
4373 },
4374 }
4375 }
4376
4377 fn uncovered(key: &FindingKey, artifact: &str) -> Finding {
4378 Finding {
4379 key: key.clone(),
4380 facet: "src".to_string(),
4381 target: FindingTarget::Artifact {
4382 artifact: artifact.to_string(),
4383 },
4384 class: FindingClass::Uncovered,
4385 detail: "source artifact in scope has no anchor in the destination mem".to_string(),
4386 created_at: "1".to_string(),
4387 }
4388 }
4389
4390 #[test]
4396 fn current_findings_drops_ledger_excluded_uncovered_without_a_verify() {
4397 let ws = tempfile::tempdir().unwrap();
4398 let root = ws.path();
4399 let engine = crate::engine::Engine::from_mounts(Vec::new()).unwrap();
4400 let binding = sourceless_binding();
4401 let resolved = resolve_binding_run("m/s", &binding).unwrap();
4402
4403 let key = FindingKey {
4404 binding_hash: crate::binding::hash_binding(&binding),
4405 source_head: String::new(),
4406 };
4407 let mut store = FindingsStore {
4408 binding: "m/s".to_string(),
4409 ..Default::default()
4410 };
4411 store.record(
4412 key.clone(),
4413 "1".to_string(),
4414 vec![uncovered(&key, "docs/a.md"), uncovered(&key, "docs/b.md")],
4415 );
4416 write_findings_store(root, "m", "s", &store).unwrap();
4417
4418 let (_, before) = current_findings(&engine, root, &binding, &resolved).unwrap();
4420 assert_eq!(before.len(), 2);
4421
4422 let state = crate::ingest::advance::AdvanceState {
4425 binding: "m/s".to_string(),
4426 exclusions: [("docs/a.md".to_string(), "generated; no entity".to_string())]
4427 .into_iter()
4428 .collect(),
4429 ..Default::default()
4430 };
4431 crate::ingest::advance::write_advance_store(root, "m", "s", &state).unwrap();
4432
4433 let (_, after) = current_findings(&engine, root, &binding, &resolved).unwrap();
4434 assert_eq!(after.len(), 1);
4435 assert!(matches!(
4436 &after[0].target,
4437 FindingTarget::Artifact { artifact } if artifact == "docs/b.md"
4438 ));
4439 }
4440
4441 #[test]
4445 fn current_findings_never_serves_superseded_batches() {
4446 let ws = tempfile::tempdir().unwrap();
4447 let root = ws.path();
4448 let engine = crate::engine::Engine::from_mounts(Vec::new()).unwrap();
4449 let binding = sourceless_binding();
4450 let resolved = resolve_binding_run("m/s", &binding).unwrap();
4451
4452 let old_key = key("a-prior-binding-hash", "head0");
4453 let cur_key = FindingKey {
4454 binding_hash: crate::binding::hash_binding(&binding),
4455 source_head: String::new(),
4456 };
4457 let mut store = FindingsStore {
4458 binding: "m/s".to_string(),
4459 ..Default::default()
4460 };
4461 store.record(
4462 old_key.clone(),
4463 "1".to_string(),
4464 vec![uncovered(&old_key, "docs/stale.md")],
4465 );
4466 store.record(
4467 cur_key.clone(),
4468 "2".to_string(),
4469 vec![uncovered(&cur_key, "docs/live.md")],
4470 );
4471 write_findings_store(root, "m", "s", &store).unwrap();
4472
4473 let (_, current) = current_findings(&engine, root, &binding, &resolved).unwrap();
4474 assert_eq!(current.len(), 1);
4475 assert!(matches!(
4476 ¤t[0].target,
4477 FindingTarget::Artifact { artifact } if artifact == "docs/live.md"
4478 ));
4479 }
4480
4481 fn a3_workspace(root: &std::path::Path, files: &[&str]) {
4485 let mem_dir = root.join("mem");
4486 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
4487 std::fs::write(
4488 mem_dir.join(".memstead").join("config.json"),
4489 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
4490 )
4491 .unwrap();
4492 std::fs::create_dir_all(root.join(".memstead")).unwrap();
4493 std::fs::write(
4494 root.join(".memstead").join("workspace.toml"),
4495 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
4496 )
4497 .unwrap();
4498 let mount = crate::workspace::Mount {
4499 mem: "engine".to_string(),
4500 schema: Some("default@1.0.0".parse().unwrap()),
4501 storage: crate::workspace::MountStorage::Folder {
4502 path: mem_dir.clone(),
4503 },
4504 capability: crate::workspace::MountCapability::Write,
4505 lifecycle: crate::workspace::MountLifecycle::Eager,
4506 cross_linkable: false,
4507 migration_target: None,
4508 };
4509 crate::workspace_store::WorkspaceStoreAdapter::save_state(
4510 &crate::FileWorkspaceStore::new(),
4511 root,
4512 &crate::workspace::Workspace {
4513 mounts: vec![mount],
4514 settings: crate::workspace::WorkspaceSettings::default(),
4515 },
4516 )
4517 .unwrap();
4518 let out = std::process::Command::new("git")
4519 .args(["init", "-q"])
4520 .current_dir(root)
4521 .output()
4522 .unwrap();
4523 assert!(out.status.success());
4524 for f in files {
4525 let p = root.join(f);
4526 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
4527 std::fs::write(p, "fn x() {}\n").unwrap();
4528 }
4529 }
4530
4531 fn a3_binding(
4532 sources: &[(&str, &str)],
4533 deny: &[&str],
4534 batch_size: u32,
4535 ) -> crate::binding::Binding {
4536 crate::binding::Binding {
4537 version: crate::binding::BINDING_VERSION,
4538 intent: None,
4539 sources: sources
4540 .iter()
4541 .map(|(name, glob)| crate::pipeline::Source {
4542 name: name.to_string(),
4543 medium_type: crate::pipeline::MediumType::Codebase,
4544 pointer: String::new(),
4545 change_detection: Some("git".to_string()),
4546 scope: vec![crate::pipeline::PatternEntry {
4547 path: glob.to_string(),
4548 mode: crate::pipeline::PatternMode::Allow,
4549 }],
4550 engagement: None,
4551 preparation: None,
4552 })
4553 .collect(),
4554 reference_mems: Vec::new(),
4555 destination_mem: "engine".to_string(),
4556 deny_paths: deny.iter().map(|d| d.to_string()).collect(),
4557 coverage_semantics: None,
4558 rules: None,
4559 prune: None,
4560 operations: crate::binding::Operations {
4561 build: Some(crate::binding::BuildOperation {
4562 mode: crate::binding::BuildMode::Discovery,
4563 trigger: crate::pipeline::IngestTrigger::Loop,
4564 batch_size,
4565 post_actions: None,
4566 }),
4567 sync: None,
4568 verify: Some(crate::binding::VerifyOperation {
4569 trigger: crate::pipeline::IngestTrigger::Manual,
4570 batch_size,
4571 adjudication_cap: crate::binding::DEFAULT_ADJUDICATION_CAP,
4572 full_resync_every: 0,
4574 }),
4575 },
4576 }
4577 }
4578
4579 #[test]
4585 fn sampled_verify_records_nothing_for_denied_artifacts() {
4586 let tmp = tempfile::tempdir().unwrap();
4587 let root = tmp.path();
4588 let mut files: Vec<String> = (0..30).map(|i| format!("src/keep/k{i:02}.rs")).collect();
4589 files.extend((0..10).map(|i| format!("src/deny/d{i:02}.rs")));
4590 let refs: Vec<&str> = files.iter().map(String::as_str).collect();
4591 a3_workspace(root, &refs);
4592 let engine = Engine::from_workspace_root(root).unwrap();
4593
4594 let run = |binding: &Binding| -> (VerifyOutcome, Vec<String>) {
4595 let resolved = resolve_binding_run("engine/graph", binding).unwrap();
4596 let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
4597 let store = read_findings_store(root, "engine", "graph")
4598 .unwrap()
4599 .expect("store written");
4600 let uncovered: Vec<String> = store
4601 .current(&outcome.key)
4602 .iter()
4603 .filter(|f| f.class == FindingClass::Uncovered)
4604 .filter_map(|f| match &f.target {
4605 FindingTarget::Artifact { artifact } => Some(artifact.clone()),
4606 _ => None,
4607 })
4608 .collect();
4609 (outcome, uncovered)
4610 };
4611
4612 let open = a3_binding(&[("graph", "src/**/*.rs")], &[], 8);
4615 write_binding(root, "engine", "graph", &open).unwrap();
4616 let (_, first) = run(&open);
4617 assert!(!first.is_empty(), "the sample records uncovered files");
4618
4619 let denied = a3_binding(&[("graph", "src/**/*.rs")], &["src/deny/**"], 8);
4621 write_binding(root, "engine", "graph", &denied).unwrap();
4622 let resolved = resolve_binding_run("engine/graph", &denied).unwrap();
4623 let ResolvedSource::Primary(p) = &resolved.sources[0] else {
4624 panic!("primary source")
4625 };
4626 assert_eq!(
4627 enumerate_source_artifacts(&engine, p, &resolved.deny_paths, root).len(),
4628 30,
4629 "the denominator honours the deny"
4630 );
4631 for pass in 0..5 {
4632 let (_, uncovered) = run(&denied);
4633 assert!(
4634 uncovered.iter().all(|a| !a.starts_with("src/deny/")),
4635 "pass {pass} recorded a denied artifact: {uncovered:?}"
4636 );
4637 }
4638
4639 write_binding(root, "engine", "graph", &open).unwrap();
4641 let mut seen_denied = false;
4642 for _ in 0..8 {
4643 let (_, uncovered) = run(&open);
4644 if uncovered.iter().any(|a| a.starts_with("src/deny/")) {
4645 seen_denied = true;
4646 break;
4647 }
4648 }
4649 assert!(
4650 seen_denied,
4651 "with the deny lifted the files are sampled again"
4652 );
4653 }
4654}