1use super::{
4 ScanRuntimePlan, SourceVerificationWork, WatchChangeSet,
5 open_atlas_store_read_only_for_project, open_exact_fresh_atlas_store_for_project_controlled,
6 open_exact_saved_source_matches_index_controlled, publication_input_error,
7 source_changed_during_derivation, source_inspection_error,
8 verify_saved_source_matches_index_controlled,
9};
10use crate::CliError;
11use blake3::Hasher;
12use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
13use projectatlas_core::graph::ProjectInstanceId;
14use projectatlas_core::{CanonicalProjectRoot, IndexGeneration, IndexWorkControl, IndexWorkStage};
15use projectatlas_db::{AtlasStore, CapturedProjectBinding, IndexPublicationState};
16use std::collections::HashMap;
17#[cfg(windows)]
18use std::ffi::{OsStr, OsString};
19use std::fmt;
20use std::fs;
21use std::fs::File;
22use std::hash::Hash;
23use std::io::{Read, Take};
24#[cfg(windows)]
25use std::os::windows::ffi::{OsStrExt, OsStringExt};
26use std::path::{Path, PathBuf};
27use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
28use std::sync::mpsc::{Receiver, SyncSender, TryRecvError, TrySendError, sync_channel};
29use std::sync::{Arc, Mutex};
30use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
31
32const SOURCE_OBSERVATION_CAPACITY: usize = 16;
34const SOURCE_OBSERVATION_QUEUE_CAPACITY: usize = 1_024;
36const VERIFIED_READ_ATTEMPTS: usize = 3;
38const MAX_POLICY_INPUT_BYTES: u64 = 16 * 1_024 * 1_024;
40
41#[derive(Clone, Debug, Eq, PartialEq)]
43pub(crate) struct VerifiedReadStamp {
44 pub(crate) process_nonce: [u8; 16],
46 pub(crate) epoch: u64,
48 pub(crate) generation: IndexGeneration,
50 pub(crate) project_instance_id: ProjectInstanceId,
52}
53
54#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
56pub(crate) struct VerifiedReadWork {
57 pub(crate) exact_verifications: u64,
59 pub(crate) filesystem_entries: u64,
61 pub(crate) filesystem_bytes: u64,
63 pub(crate) sqlite_read_statements: u64,
65 pub(crate) decoded_nodes: u64,
67 pub(crate) retries: u64,
69 pub(crate) elapsed: Duration,
71 pub(crate) output_bytes: u64,
73}
74
75impl VerifiedReadWork {
76 fn add_exact(&mut self, work: SourceVerificationWork) {
78 self.exact_verifications = self.exact_verifications.saturating_add(1);
79 self.filesystem_entries = self
80 .filesystem_entries
81 .saturating_add(work.filesystem_entries);
82 self.filesystem_bytes = self.filesystem_bytes.saturating_add(work.filesystem_bytes);
83 self.sqlite_read_statements = self
84 .sqlite_read_statements
85 .saturating_add(work.sqlite_read_statements);
86 self.decoded_nodes = self.decoded_nodes.saturating_add(work.decoded_nodes);
87 }
88}
89
90#[derive(Clone, Debug, Eq, PartialEq)]
92pub(crate) struct VerifiedReadOutcome<T> {
93 pub(crate) value: T,
95 pub(crate) stamp: VerifiedReadStamp,
97 pub(crate) work: VerifiedReadWork,
99}
100
101impl<T> VerifiedReadOutcome<T> {
102 pub(crate) fn with_output_bytes(mut self, output_bytes: usize) -> Self {
104 self.work.output_bytes = u64::try_from(output_bytes).unwrap_or(u64::MAX);
105 self
106 }
107}
108
109enum MutationSourceWitness {
111 Observed {
113 entry: Arc<SourceObservationEntry>,
115 epoch: VerifiedSourceEpoch,
117 },
118 Exact {
120 binding: SourceBinding,
122 stamp: VerifiedReadStamp,
124 contract_fingerprint: String,
126 policy_witness: String,
128 },
129}
130
131pub(crate) struct VerifiedMutationAdmission {
133 witness: MutationSourceWitness,
135 control: IndexWorkControl,
137}
138
139impl VerifiedMutationAdmission {
140 pub(crate) fn verify(&self) -> Result<(), CliError> {
142 match &self.witness {
143 MutationSourceWitness::Observed { entry, epoch } => {
144 Self::verify_observed(entry, epoch, &self.control, || {
145 verify_saved_source_matches_index_controlled(
146 &entry.binding.database,
147 &entry.binding.root,
148 entry.binding.config.as_deref(),
149 &self.control,
150 )
151 })
152 }
153 MutationSourceWitness::Exact {
154 binding,
155 stamp,
156 contract_fingerprint,
157 policy_witness,
158 } => Self::verify_exact(
159 binding,
160 stamp,
161 contract_fingerprint,
162 policy_witness,
163 &self.control,
164 ),
165 }
166 }
167
168 fn verify_observed(
170 entry: &SourceObservationEntry,
171 epoch: &VerifiedSourceEpoch,
172 control: &IndexWorkControl,
173 verify_source: impl FnOnce() -> Result<(), CliError>,
174 ) -> Result<(), CliError> {
175 if let Err(error) = Self::verify_observation(entry, epoch, control) {
176 entry.invalidate_epoch(epoch);
177 return Err(error);
178 }
179 if let Err(error) = verify_source() {
180 if matches!(error, CliError::RefreshRequired(_)) {
181 entry.invalidate_after_proven_source_mismatch()?;
182 } else {
183 entry.invalidate_epoch(epoch);
184 }
185 return Err(error);
186 }
187 if let Err(error) = Self::verify_observation(entry, epoch, control) {
188 entry.invalidate_epoch(epoch);
189 return Err(error);
190 }
191 Ok(())
192 }
193
194 fn verify_observation(
196 entry: &SourceObservationEntry,
197 epoch: &VerifiedSourceEpoch,
198 control: &IndexWorkControl,
199 ) -> Result<(), CliError> {
200 match SourceObservationRegistry::accepts_observed_result(entry, epoch, control) {
201 Ok(ObservedAcceptance::Accepted) => Ok(()),
202 Ok(ObservedAcceptance::Superseded | ObservedAcceptance::Invalidated) => {
203 Err(source_changed_during_derivation(&entry.binding.root, "."))
204 }
205 Err(error) => Err(error),
206 }
207 }
208
209 fn verify_exact(
211 binding: &SourceBinding,
212 stamp: &VerifiedReadStamp,
213 contract_fingerprint: &str,
214 policy_witness: &str,
215 control: &IndexWorkControl,
216 ) -> Result<(), CliError> {
217 let before = exact_source_policy(binding, control)?;
218 if before.0 != contract_fingerprint || before.1 != policy_witness {
219 return Err(source_changed_during_derivation(&binding.root, "."));
220 }
221 let exact = open_exact_saved_source_matches_index_controlled(
222 &binding.database,
223 &binding.root,
224 binding.config.as_deref(),
225 control,
226 )?;
227 let verification = (|| {
228 let publication = exact
229 .store
230 .index_publication()?
231 .ok_or_else(|| source_changed_during_derivation(&binding.root, "."))?;
232 let captured = exact.store.captured_project_binding()?;
233 let after = exact_source_policy(binding, control)?;
234 Ok::<_, CliError>((publication, captured, after))
235 })();
236 let finished = exact.store.finish_index_read_snapshot();
237 let (publication, captured, after) = verification?;
238 finished?;
239 if before == after
240 && after.0 == contract_fingerprint
241 && after.1 == policy_witness
242 && publication.generation == stamp.generation
243 && captured.project_instance_id == stamp.project_instance_id
244 {
245 Ok(())
246 } else {
247 Err(source_changed_during_derivation(&binding.root, "."))
248 }
249 }
250}
251
252#[derive(Clone, Copy, Debug, Eq, PartialEq)]
254enum ObservedAcceptance {
255 Accepted,
257 Superseded,
259 Invalidated,
261}
262
263#[derive(Clone, Debug, Eq)]
265struct SourceBinding {
266 root: PathBuf,
268 database: PathBuf,
270 config: Option<PathBuf>,
272}
273
274impl PartialEq for SourceBinding {
275 fn eq(&self, other: &Self) -> bool {
276 self.root == other.root && self.database == other.database && self.config == other.config
277 }
278}
279
280impl Hash for SourceBinding {
281 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
282 self.root.hash(state);
283 self.database.hash(state);
284 self.config.hash(state);
285 }
286}
287
288impl SourceBinding {
289 fn external_config_parent(&self) -> Option<&Path> {
291 self.config
292 .as_deref()
293 .filter(|config| !config.starts_with(&self.root))
294 .and_then(Path::parent)
295 }
296
297 fn new(database: &Path, root: &Path, config: Option<&Path>) -> Result<Self, CliError> {
299 let root = root.canonicalize().map_err(|source| CliError::Io {
300 path: root.to_path_buf(),
301 source,
302 })?;
303 let database = absolute_path_from(&root, database);
304 let config = config.map(|path| absolute_path_from(&root, path));
305 Ok(Self {
306 root,
307 database,
308 config,
309 })
310 }
311}
312
313#[derive(Clone, Debug, Eq, PartialEq)]
315struct VerifiedSourceEpoch {
316 stamp: VerifiedReadStamp,
318 ingress_sequence: u64,
320 contract_fingerprint: String,
322 policy_witness: String,
324}
325
326#[derive(Debug, Default)]
328struct ObservationState {
329 next_epoch: u64,
331 verified: Option<VerifiedSourceEpoch>,
333}
334
335struct SourceObservationEntry {
337 binding: SourceBinding,
339 watcher: RecommendedWatcher,
341 receiver: Arc<Mutex<Receiver<Event>>>,
343 ingress_sequence: Arc<AtomicU64>,
345 continuity_lost: Arc<AtomicBool>,
347 reconcile: Mutex<()>,
349 state: Mutex<ObservationState>,
351 #[cfg(test)]
352 test_sender: SyncSender<Event>,
354 #[cfg(test)]
355 acceptance_event: Mutex<Option<Event>>,
357 #[cfg(test)]
358 drain_continuity_invalidations: AtomicU64,
360}
361
362#[cfg(windows)]
363impl Drop for SourceObservationEntry {
364 fn drop(&mut self) {
365 drop(self.watcher.unwatch(&self.binding.root));
366 if let Some(parent) = self.binding.external_config_parent() {
367 drop(self.watcher.unwatch(parent));
368 }
369 drop(self.watcher.configure(notify::Config::default()));
373 }
374}
375
376impl fmt::Debug for SourceObservationEntry {
377 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
378 formatter
379 .debug_struct("SourceObservationEntry")
380 .field("binding", &self.binding)
381 .field(
382 "ingress_sequence",
383 &self.ingress_sequence.load(Ordering::Acquire),
384 )
385 .field(
386 "continuity_lost",
387 &self.continuity_lost.load(Ordering::Acquire),
388 )
389 .finish_non_exhaustive()
390 }
391}
392
393impl SourceObservationEntry {
394 fn start(binding: SourceBinding) -> Result<Self, CliError> {
396 let (sender, receiver) = sync_channel(SOURCE_OBSERVATION_QUEUE_CAPACITY);
397 let receiver = Arc::new(Mutex::new(receiver));
398 #[cfg(test)]
399 let test_sender = sender.clone();
400 let ingress_sequence = Arc::new(AtomicU64::new(0));
401 let continuity_lost = Arc::new(AtomicBool::new(false));
402 let watcher = notify::recommended_watcher(watcher_callback(
403 sender,
404 Arc::clone(&receiver),
405 Arc::clone(&ingress_sequence),
406 Arc::clone(&continuity_lost),
407 ))
408 .map_err(|source| observer_error(&binding.root, &source))?;
409 let mut entry = Self {
410 binding,
411 watcher,
412 receiver,
413 ingress_sequence,
414 continuity_lost,
415 reconcile: Mutex::new(()),
416 state: Mutex::new(ObservationState::default()),
417 #[cfg(test)]
418 test_sender,
419 #[cfg(test)]
420 acceptance_event: Mutex::new(None),
421 #[cfg(test)]
422 drain_continuity_invalidations: AtomicU64::new(0),
423 };
424 entry
425 .watcher
426 .watch(&entry.binding.root, RecursiveMode::Recursive)
427 .map_err(|source| observer_error(&entry.binding.root, &source))?;
428 if let Some(parent) = entry.binding.external_config_parent() {
429 entry
430 .watcher
431 .watch(parent, RecursiveMode::NonRecursive)
432 .map_err(|source| observer_error(&entry.binding.root, &source))?;
433 }
434 Ok(entry)
435 }
436
437 #[cfg(test)]
439 fn publish_test_event(&self, event: Event) -> Result<(), CliError> {
440 let _receiver = self
441 .receiver
442 .lock()
443 .map_err(|_poisoned| lock_error(&self.binding.root, "source observation receiver"))?;
444 self.test_sender.try_send(event).map_err(|source| {
445 CliError::InvalidInput(format!(
446 "deterministic source observer event injection failed: {source}"
447 ))
448 })?;
449 self.ingress_sequence.fetch_add(1, Ordering::AcqRel);
450 Ok(())
451 }
452
453 fn invalidate(&self) {
455 if let Ok(mut state) = self.state.lock() {
456 state.verified = None;
457 }
458 }
459
460 fn invalidate_epoch(&self, epoch: &VerifiedSourceEpoch) {
462 if let Ok(mut state) = self.state.lock()
463 && state
464 .verified
465 .as_ref()
466 .is_some_and(|current| current.stamp == epoch.stamp)
467 {
468 state.verified = None;
469 }
470 }
471
472 fn invalidate_after_proven_source_mismatch(&self) -> Result<(), CliError> {
474 let _receiver = self
475 .receiver
476 .lock()
477 .map_err(|_poisoned| lock_error(&self.binding.root, "source observation receiver"))?;
478 self.continuity_lost.store(true, Ordering::Release);
479 self.invalidate();
480 Ok(())
481 }
482
483 fn clear_before_exact_verification(&self) -> Result<(), CliError> {
485 let receiver = self
486 .receiver
487 .lock()
488 .map_err(|_poisoned| lock_error(&self.binding.root, "source observation receiver"))?;
489 self.invalidate();
490 self.continuity_lost.store(false, Ordering::Release);
491 loop {
492 match receiver.try_recv() {
493 Ok(_event) => {}
494 Err(TryRecvError::Empty) => return Ok(()),
495 Err(TryRecvError::Disconnected) => {
496 self.continuity_lost.store(true, Ordering::Release);
497 return Ok(());
498 }
499 }
500 }
501 }
502
503 fn changed_since_exact_verification(
505 &self,
506 scan_options: &projectatlas_fs::ScanOptions,
507 ) -> Result<bool, CliError> {
508 let receiver = self
509 .receiver
510 .lock()
511 .map_err(|_poisoned| lock_error(&self.binding.root, "source observation receiver"))?;
512 self.drain_source_events(&receiver, scan_options)
513 }
514
515 fn drain_source_events(
517 &self,
518 receiver: &Receiver<Event>,
519 scan_options: &projectatlas_fs::ScanOptions,
520 ) -> Result<bool, CliError> {
521 #[cfg(test)]
522 if self
523 .drain_continuity_invalidations
524 .fetch_update(Ordering::AcqRel, Ordering::Acquire, |remaining| {
525 remaining.checked_sub(1)
526 })
527 .is_ok()
528 {
529 self.continuity_lost.store(true, Ordering::Release);
530 }
531 if self.continuity_lost.load(Ordering::Acquire) {
532 self.invalidate();
533 return Ok(true);
534 }
535 let mut changes = WatchChangeSet::default();
536 loop {
537 match receiver.try_recv() {
538 Ok(event) => {
539 let event_changes = observer_event_changes(&self.binding, scan_options, &event);
540 changes.requires_full_scan |= event_changes.requires_full_scan;
541 changes.paths.extend(event_changes.paths);
542 if self
543 .binding
544 .config
545 .as_ref()
546 .is_some_and(|config| event.paths.iter().any(|path| path == config))
547 {
548 changes.requires_full_scan = true;
549 }
550 }
551 Err(TryRecvError::Empty) => break,
552 Err(TryRecvError::Disconnected) => {
553 self.continuity_lost.store(true, Ordering::Release);
554 self.invalidate();
555 return Ok(true);
556 }
557 }
558 }
559 let changed = changes.requires_full_scan || !changes.paths.is_empty();
560 if changed {
561 self.continuity_lost.store(true, Ordering::Release);
563 self.invalidate();
564 } else {
565 let acknowledged = self.ingress_sequence.load(Ordering::Acquire);
566 let mut state = self
567 .state
568 .lock()
569 .map_err(|_poisoned| lock_error(&self.binding.root, "source observation state"))?;
570 if let Some(epoch) = state.verified.as_mut() {
571 epoch.ingress_sequence = acknowledged;
572 }
573 }
574 Ok(changed)
575 }
576
577 fn drain_epoch_events(
579 &self,
580 receiver: &Receiver<Event>,
581 epoch: &VerifiedSourceEpoch,
582 scan_options: &projectatlas_fs::ScanOptions,
583 ) -> Result<ObservedAcceptance, CliError> {
584 let Some(current) = self.current_epoch()? else {
585 return Ok(ObservedAcceptance::Invalidated);
586 };
587 if current.stamp != epoch.stamp {
588 return Ok(ObservedAcceptance::Superseded);
589 }
590 if self.drain_source_events(receiver, scan_options)? {
591 return Ok(ObservedAcceptance::Invalidated);
592 }
593 Ok(ObservedAcceptance::Accepted)
594 }
595
596 fn install_epoch(
598 &self,
599 process_nonce: [u8; 16],
600 binding: &CapturedProjectBinding,
601 generation: IndexGeneration,
602 ingress_sequence: u64,
603 contract_fingerprint: String,
604 policy_witness: String,
605 ) -> Result<VerifiedSourceEpoch, CliError> {
606 let mut state = self
607 .state
608 .lock()
609 .map_err(|_poisoned| lock_error(&self.binding.root, "source observation state"))?;
610 state.next_epoch = state.next_epoch.saturating_add(1);
611 let epoch = VerifiedSourceEpoch {
612 stamp: VerifiedReadStamp {
613 process_nonce,
614 epoch: state.next_epoch,
615 generation,
616 project_instance_id: binding.project_instance_id,
617 },
618 ingress_sequence,
619 contract_fingerprint,
620 policy_witness,
621 };
622 state.verified = Some(epoch.clone());
623 Ok(epoch)
624 }
625
626 fn current_epoch(&self) -> Result<Option<VerifiedSourceEpoch>, CliError> {
628 self.state
629 .lock()
630 .map(|state| state.verified.clone())
631 .map_err(|_poisoned| lock_error(&self.binding.root, "source observation state"))
632 }
633}
634
635fn observer_event_changes(
637 binding: &SourceBinding,
638 scan_options: &projectatlas_fs::ScanOptions,
639 event: &Event,
640) -> WatchChangeSet {
641 let mut filtered = event.clone();
642 let metadata_directory = binding.root.join(".projectatlas");
643 filtered.paths.retain(|path| {
644 let candidate = super::absolute_watch_path(&binding.root, path);
645 !same_native_path(&candidate, &metadata_directory)
646 && !is_database_runtime_path(&candidate, &binding.database)
647 });
648 if filtered.paths.is_empty() && !event.need_rescan() {
649 return WatchChangeSet::default();
650 }
651 super::notify_event_changes(&binding.root, scan_options, &filtered)
652}
653
654fn is_database_runtime_path(candidate: &Path, database: &Path) -> bool {
656 if same_native_path(candidate, database) {
657 return true;
658 }
659 ["-wal", "-shm", "-journal"].into_iter().any(|suffix| {
660 let mut sidecar = database.as_os_str().to_os_string();
661 sidecar.push(suffix);
662 let sidecar = Path::new(&sidecar);
663 same_native_path(candidate, sidecar) || {
664 #[cfg(windows)]
665 {
666 missing_windows_sidecar_matches(candidate, sidecar, database, suffix)
667 }
668 #[cfg(not(windows))]
669 {
670 false
671 }
672 }
673 })
674}
675
676#[cfg(windows)]
680fn missing_windows_sidecar_matches(
681 candidate: &Path,
682 sidecar: &Path,
683 database: &Path,
684 suffix: &str,
685) -> bool {
686 if candidate.exists() || sidecar.exists() {
687 return false;
688 }
689 let (Some(parent), Some(leaf)) = (candidate.parent(), candidate.file_name()) else {
690 return false;
691 };
692 let suffix_units: Vec<u16> = OsStr::new(suffix).encode_wide().collect();
693 let leaf_units: Vec<u16> = leaf.encode_wide().collect();
694 if leaf_units.len() <= suffix_units.len()
695 || !leaf_units[leaf_units.len() - suffix_units.len()..]
696 .iter()
697 .zip(&suffix_units)
698 .all(|(actual, expected)| {
699 let actual = *actual;
700 let expected = *expected;
701 let fold_ascii = |unit: u16| {
702 if (u16::from(b'A')..=u16::from(b'Z')).contains(&unit) {
703 unit + (u16::from(b'a') - u16::from(b'A'))
704 } else {
705 unit
706 }
707 };
708 actual == expected || fold_ascii(actual) == fold_ascii(expected)
709 })
710 {
711 return false;
712 }
713 let base = parent.join(OsString::from_wide(
714 &leaf_units[..leaf_units.len() - suffix_units.len()],
715 ));
716 base != database && same_native_path(&base, database)
720}
721
722fn same_native_path(left: &Path, right: &Path) -> bool {
724 if left == right {
725 return true;
726 }
727 let canonical = |path: &Path| -> Option<PathBuf> {
728 if let Ok(path) = fs::canonicalize(path) {
729 return Some(path);
730 }
731 let parent = path.parent()?;
732 let identity = CanonicalProjectRoot::from_path(parent).ok()?;
733 Some(identity.as_path().join(path.file_name()?))
734 };
735 match (canonical(left), canonical(right)) {
736 (Some(left), Some(right)) => left == right,
737 _ => false,
738 }
739}
740
741pub(crate) struct SourceObservationRegistry {
743 process_nonce: [u8; 16],
745 entries: Mutex<HashMap<SourceBinding, Arc<SourceObservationEntry>>>,
747 #[cfg(test)]
749 mutation_acceptance_invalidations: AtomicU64,
750 #[cfg(test)]
752 preparation_invalidations: AtomicU64,
753}
754
755impl fmt::Debug for SourceObservationRegistry {
756 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
757 let entries = self.entries.lock().map_or(0, |entries| entries.len());
758 formatter
759 .debug_struct("SourceObservationRegistry")
760 .field("entries", &entries)
761 .field("capacity", &SOURCE_OBSERVATION_CAPACITY)
762 .finish_non_exhaustive()
763 }
764}
765
766impl Default for SourceObservationRegistry {
767 fn default() -> Self {
768 Self {
769 process_nonce: process_nonce(),
770 entries: Mutex::new(HashMap::new()),
771 #[cfg(test)]
772 mutation_acceptance_invalidations: AtomicU64::new(0),
773 #[cfg(test)]
774 preparation_invalidations: AtomicU64::new(0),
775 }
776 }
777}
778
779impl SourceObservationRegistry {
780 pub(crate) fn with_verified_read<T, F>(
782 &self,
783 database: &Path,
784 root: &Path,
785 config: Option<&Path>,
786 control: &IndexWorkControl,
787 query: F,
788 ) -> Result<VerifiedReadOutcome<T>, CliError>
789 where
790 F: FnMut(&AtlasStore, VerifiedReadStamp) -> Result<T, CliError>,
791 {
792 let binding = SourceBinding::new(database, root, config)?;
793 let Some(entry) = self.entry(binding.clone())? else {
794 return self.with_exact_fallback(&binding, control, query);
795 };
796 self.with_observed_read(&entry, control, query)
797 }
798
799 pub(crate) fn admit_mutation(
801 &self,
802 database: &Path,
803 root: &Path,
804 config: Option<&Path>,
805 control: &IndexWorkControl,
806 ) -> Result<VerifiedMutationAdmission, CliError> {
807 let binding = SourceBinding::new(database, root, config)?;
808 let Some(entry) = self.entry(binding.clone())? else {
809 return self.admit_exact_mutation(binding, control);
810 };
811 let mut work = VerifiedReadWork::default();
812 for attempt in 0..VERIFIED_READ_ATTEMPTS {
813 if let Err(error) = control.check(IndexWorkStage::Publication) {
814 entry.invalidate();
815 return Err(error.into());
816 }
817 let (store, epoch) =
818 match self.prepare_observed_store(&entry, control, &mut work, attempt == 0) {
819 Ok(Some(prepared)) => prepared,
820 Ok(None) => {
821 entry.invalidate();
822 return self.admit_exact_mutation(binding, control);
823 }
824 Err(error) => {
825 entry.invalidate();
826 return Err(error);
827 }
828 };
829 #[cfg(test)]
830 if self
831 .mutation_acceptance_invalidations
832 .fetch_update(Ordering::AcqRel, Ordering::Acquire, |remaining| {
833 remaining.checked_sub(1)
834 })
835 .is_ok()
836 {
837 entry.continuity_lost.store(true, Ordering::Release);
838 }
839 match Self::accepts_observed_result(&entry, &epoch, control) {
840 Ok(ObservedAcceptance::Accepted) => {
841 if let Err(error) = store.finish_index_read_snapshot() {
842 entry.invalidate_epoch(&epoch);
843 return Err(error.into());
844 }
845 return Ok(VerifiedMutationAdmission {
846 witness: MutationSourceWitness::Observed { entry, epoch },
847 control: control.clone(),
848 });
849 }
850 Ok(ObservedAcceptance::Superseded | ObservedAcceptance::Invalidated) => {}
851 Err(error) => {
852 entry.invalidate_epoch(&epoch);
853 drop(store.finish_index_read_snapshot());
854 return Err(error);
855 }
856 }
857 drop(store.finish_index_read_snapshot());
858 }
859 self.admit_exact_mutation(binding, control)
860 }
861
862 fn admit_exact_mutation(
864 &self,
865 binding: SourceBinding,
866 control: &IndexWorkControl,
867 ) -> Result<VerifiedMutationAdmission, CliError> {
868 for _attempt in 0..VERIFIED_READ_ATTEMPTS {
869 control.check(IndexWorkStage::Publication)?;
870 let before = exact_source_policy(&binding, control)?;
871 let exact = open_exact_fresh_atlas_store_for_project_controlled(
872 &binding.database,
873 &binding.root,
874 binding.config.as_deref(),
875 control,
876 )?;
877 let after = match exact_source_policy(&binding, control) {
878 Ok(after) => after,
879 Err(error) => {
880 drop(exact.store.finish_index_read_snapshot());
881 return Err(error);
882 }
883 };
884 if before != after {
885 drop(exact.store.finish_index_read_snapshot());
886 continue;
887 }
888 let stamp = match self.exact_stamp(&exact.store, &binding.root) {
889 Ok(stamp) => stamp,
890 Err(error) => {
891 drop(exact.store.finish_index_read_snapshot());
892 return Err(error);
893 }
894 };
895 exact.store.finish_index_read_snapshot()?;
896 return Ok(VerifiedMutationAdmission {
897 witness: MutationSourceWitness::Exact {
898 binding,
899 stamp,
900 contract_fingerprint: after.0,
901 policy_witness: after.1,
902 },
903 control: control.clone(),
904 });
905 }
906 Err(source_changed_during_derivation(&binding.root, "."))
907 }
908
909 #[cfg(test)]
911 pub(crate) fn inject_test_event(
912 &self,
913 database: &Path,
914 root: &Path,
915 config: Option<&Path>,
916 event: Event,
917 ) -> Result<(), CliError> {
918 let binding = SourceBinding::new(database, root, config)?;
919 let entry = self
920 .entries
921 .lock()
922 .map_err(|_poisoned| lock_error(&binding.root, "source observation registry"))?
923 .get(&binding)
924 .cloned()
925 .ok_or_else(|| {
926 CliError::InvalidInput(format!(
927 "source observer test entry is missing for '{}'",
928 binding.root.display()
929 ))
930 })?;
931 entry.publish_test_event(event)
932 }
933
934 fn entry(
936 &self,
937 binding: SourceBinding,
938 ) -> Result<Option<Arc<SourceObservationEntry>>, CliError> {
939 let mut entries = self
940 .entries
941 .lock()
942 .map_err(|_poisoned| lock_error(&binding.root, "source observation registry"))?;
943 if let Some(entry) = entries.get(&binding) {
944 return Ok(Some(Arc::clone(entry)));
945 }
946 if entries.len() >= SOURCE_OBSERVATION_CAPACITY {
947 return Ok(None);
948 }
949 let entry = match SourceObservationEntry::start(binding.clone()) {
950 Ok(entry) => Arc::new(entry),
951 Err(_observer_unavailable) => return Ok(None),
952 };
953 entries.insert(binding, Arc::clone(&entry));
954 Ok(Some(entry))
955 }
956
957 fn with_observed_read<T, F>(
959 &self,
960 entry: &SourceObservationEntry,
961 control: &IndexWorkControl,
962 mut query: F,
963 ) -> Result<VerifiedReadOutcome<T>, CliError>
964 where
965 F: FnMut(&AtlasStore, VerifiedReadStamp) -> Result<T, CliError>,
966 {
967 let started = Instant::now();
968 let mut work = VerifiedReadWork::default();
969 for attempt in 0..VERIFIED_READ_ATTEMPTS {
970 if let Err(error) = control.check(IndexWorkStage::Publication) {
971 entry.invalidate();
972 return Err(error.into());
973 }
974 let (store, epoch) = match self.prepare_observed_store(entry, control, &mut work, false)
975 {
976 Ok(Some(prepared)) => prepared,
977 Ok(None) => {
978 entry.invalidate();
979 return Err(source_changed_during_derivation(&entry.binding.root, "."));
980 }
981 Err(error) => {
982 entry.invalidate();
983 return Err(error);
984 }
985 };
986 let value = match query(&store, epoch.stamp.clone()) {
987 Ok(value) => value,
988 Err(error) => {
989 if matches!(error, CliError::IndexWork(_)) {
990 entry.invalidate_epoch(&epoch);
991 }
992 drop(store.finish_index_read_snapshot());
993 return Err(error);
994 }
995 };
996 match Self::accepts_observed_result(entry, &epoch, control) {
997 Ok(ObservedAcceptance::Accepted) => {
998 store.finish_index_read_snapshot()?;
999 work.elapsed = started.elapsed();
1000 return Ok(VerifiedReadOutcome {
1001 value,
1002 stamp: epoch.stamp,
1003 work,
1004 });
1005 }
1006 Ok(ObservedAcceptance::Superseded | ObservedAcceptance::Invalidated) => {}
1007 Err(error) => {
1008 entry.invalidate_epoch(&epoch);
1009 drop(store.finish_index_read_snapshot());
1010 return Err(error);
1011 }
1012 }
1013 drop(store.finish_index_read_snapshot());
1014 if attempt + 1 < VERIFIED_READ_ATTEMPTS {
1015 work.retries = work.retries.saturating_add(1);
1016 }
1017 }
1018 Err(source_changed_during_derivation(&entry.binding.root, "."))
1019 }
1020
1021 fn prepare_observed_store(
1023 &self,
1024 entry: &SourceObservationEntry,
1025 control: &IndexWorkControl,
1026 work: &mut VerifiedReadWork,
1027 force_exact: bool,
1028 ) -> Result<Option<(AtlasStore, VerifiedSourceEpoch)>, CliError> {
1029 let _reconcile = entry
1030 .reconcile
1031 .lock()
1032 .map_err(|_poisoned| lock_error(&entry.binding.root, "source reconciliation"))?;
1033 if force_exact {
1035 entry.invalidate();
1036 }
1037 let plan = ScanRuntimePlan::for_path_controlled(
1038 entry.binding.config.as_deref(),
1039 &entry.binding.root,
1040 None,
1041 control,
1042 )
1043 .map_err(|source| publication_input_error(&entry.binding.root, source))?;
1044 entry.changed_since_exact_verification(&plan.scan_options)?;
1045 let contract_fingerprint = plan.publication_contract_fingerprint();
1046 let policy_witness = source_policy_witness(&plan, control)?;
1047 if let Some(epoch) = entry.current_epoch()?
1048 && epoch.contract_fingerprint == contract_fingerprint
1049 && epoch.policy_witness == policy_witness
1050 && !entry.continuity_lost.load(Ordering::Acquire)
1051 {
1052 let store = open_atlas_store_read_only_for_project(
1053 &entry.binding.database,
1054 &entry.binding.root,
1055 )?;
1056 let publication = store.index_publication()?.filter(|publication| {
1057 publication.state == IndexPublicationState::Complete
1058 && publication.contract_fingerprint.as_deref()
1059 == Some(contract_fingerprint.as_str())
1060 });
1061 let captured = store.captured_project_binding()?;
1062 work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
1063 if publication
1064 .is_some_and(|publication| publication.generation == epoch.stamp.generation)
1065 && captured.project_instance_id == epoch.stamp.project_instance_id
1066 {
1067 return Ok(Some((store, epoch)));
1068 }
1069 entry.invalidate();
1070 drop(store.finish_index_read_snapshot());
1071 }
1072
1073 for _attempt in 0..VERIFIED_READ_ATTEMPTS {
1074 entry.clear_before_exact_verification()?;
1075 let before_plan = ScanRuntimePlan::for_path_controlled(
1076 entry.binding.config.as_deref(),
1077 &entry.binding.root,
1078 None,
1079 control,
1080 )
1081 .map_err(|source| publication_input_error(&entry.binding.root, source))?;
1082 let before_contract = before_plan.publication_contract_fingerprint();
1083 let before_policy = source_policy_witness(&before_plan, control)?;
1084 let exact = open_exact_fresh_atlas_store_for_project_controlled(
1085 &entry.binding.database,
1086 &entry.binding.root,
1087 entry.binding.config.as_deref(),
1088 control,
1089 )?;
1090 work.add_exact(exact.work);
1091 let after_plan = ScanRuntimePlan::for_path_controlled(
1092 entry.binding.config.as_deref(),
1093 &entry.binding.root,
1094 None,
1095 control,
1096 )
1097 .map_err(|source| publication_input_error(&entry.binding.root, source))?;
1098 let after_contract = after_plan.publication_contract_fingerprint();
1099 let after_policy = source_policy_witness(&after_plan, control)?;
1100 #[cfg(test)]
1101 if self
1102 .preparation_invalidations
1103 .fetch_update(Ordering::AcqRel, Ordering::Acquire, |remaining| {
1104 remaining.checked_sub(1)
1105 })
1106 .is_ok()
1107 {
1108 entry.invalidate_after_proven_source_mismatch()?;
1109 }
1110 if before_contract != after_contract || before_policy != after_policy {
1111 drop(exact.store.finish_index_read_snapshot());
1112 continue;
1113 }
1114 let publication = exact
1115 .store
1116 .index_publication()?
1117 .ok_or_else(|| source_changed_during_derivation(&entry.binding.root, "."))?;
1118 let captured = exact.store.captured_project_binding()?;
1119 work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
1120 let receiver = entry.receiver.lock().map_err(|_poisoned| {
1121 lock_error(&entry.binding.root, "source observation receiver")
1122 })?;
1123 if entry.drain_source_events(&receiver, &after_plan.scan_options)? {
1124 drop(receiver);
1125 drop(exact.store.finish_index_read_snapshot());
1126 continue;
1127 }
1128 let ingress_sequence = entry.ingress_sequence.load(Ordering::Acquire);
1129 let epoch = entry.install_epoch(
1130 self.process_nonce,
1131 &captured,
1132 publication.generation,
1133 ingress_sequence,
1134 after_contract,
1135 after_policy,
1136 )?;
1137 return Ok(Some((exact.store, epoch)));
1138 }
1139 Ok(None)
1140 }
1141
1142 fn accepts_observed_result(
1144 entry: &SourceObservationEntry,
1145 epoch: &VerifiedSourceEpoch,
1146 control: &IndexWorkControl,
1147 ) -> Result<ObservedAcceptance, CliError> {
1148 for _attempt in 0..VERIFIED_READ_ATTEMPTS {
1149 control.check(IndexWorkStage::Publication)?;
1150 let Some(sampled) = entry.current_epoch()? else {
1151 return Ok(ObservedAcceptance::Invalidated);
1152 };
1153 let plan = ScanRuntimePlan::for_path_controlled(
1154 entry.binding.config.as_deref(),
1155 &entry.binding.root,
1156 None,
1157 control,
1158 )
1159 .map_err(|source| publication_input_error(&entry.binding.root, source))?;
1160 let contract = plan.publication_contract_fingerprint();
1161 let policy = source_policy_witness(&plan, control)?;
1162 {
1163 let receiver = entry.receiver.lock().map_err(|_poisoned| {
1164 lock_error(&entry.binding.root, "source observation receiver")
1165 })?;
1166 match entry.drain_epoch_events(&receiver, &sampled, &plan.scan_options)? {
1167 ObservedAcceptance::Accepted => {}
1168 ObservedAcceptance::Superseded => continue,
1169 ObservedAcceptance::Invalidated => return Ok(ObservedAcceptance::Invalidated),
1170 }
1171 }
1172 #[cfg(test)]
1173 if let Some(event) = entry
1174 .acceptance_event
1175 .lock()
1176 .map_err(|_poisoned| lock_error(&entry.binding.root, "acceptance test event"))?
1177 .take()
1178 {
1179 entry.publish_test_event(event)?;
1180 }
1181 let receiver = entry.receiver.lock().map_err(|_poisoned| {
1182 lock_error(&entry.binding.root, "source observation receiver")
1183 })?;
1184 match entry.drain_epoch_events(&receiver, &sampled, &plan.scan_options)? {
1185 ObservedAcceptance::Accepted => {}
1186 ObservedAcceptance::Superseded => continue,
1187 ObservedAcceptance::Invalidated => return Ok(ObservedAcceptance::Invalidated),
1188 }
1189 let mut state = entry
1190 .state
1191 .lock()
1192 .map_err(|_poisoned| lock_error(&entry.binding.root, "source observation state"))?;
1193 let Some(current) = state.verified.as_ref() else {
1194 return Ok(ObservedAcceptance::Invalidated);
1195 };
1196 if current.stamp != sampled.stamp {
1198 continue;
1199 }
1200 if current.contract_fingerprint != contract || current.policy_witness != policy {
1201 state.verified = None;
1202 return Ok(ObservedAcceptance::Invalidated);
1203 }
1204 if current.stamp != epoch.stamp {
1205 return Ok(ObservedAcceptance::Superseded);
1206 }
1207 if current.ingress_sequence == entry.ingress_sequence.load(Ordering::Acquire) {
1208 return Ok(ObservedAcceptance::Accepted);
1209 }
1210 }
1211 Ok(ObservedAcceptance::Invalidated)
1212 }
1213
1214 fn with_exact_fallback<T, F>(
1216 &self,
1217 binding: &SourceBinding,
1218 control: &IndexWorkControl,
1219 mut query: F,
1220 ) -> Result<VerifiedReadOutcome<T>, CliError>
1221 where
1222 F: FnMut(&AtlasStore, VerifiedReadStamp) -> Result<T, CliError>,
1223 {
1224 let started = Instant::now();
1225 let mut work = VerifiedReadWork::default();
1226 for attempt in 0..VERIFIED_READ_ATTEMPTS {
1227 let exact = open_exact_fresh_atlas_store_for_project_controlled(
1228 &binding.database,
1229 &binding.root,
1230 binding.config.as_deref(),
1231 control,
1232 )?;
1233 work.add_exact(exact.work);
1234 let stamp = self.exact_stamp(&exact.store, &binding.root)?;
1235 let value = query(&exact.store, stamp.clone())?;
1236 exact.store.finish_index_read_snapshot()?;
1237
1238 let post = open_exact_fresh_atlas_store_for_project_controlled(
1239 &binding.database,
1240 &binding.root,
1241 binding.config.as_deref(),
1242 control,
1243 )?;
1244 work.add_exact(post.work);
1245 let post_publication = post.store.index_publication()?;
1246 let post_binding = post.store.captured_project_binding()?;
1247 post.store.finish_index_read_snapshot()?;
1248 if post_publication.is_some_and(|candidate| {
1249 candidate.generation == stamp.generation
1250 && post_binding.project_instance_id == stamp.project_instance_id
1251 }) {
1252 work.elapsed = started.elapsed();
1253 return Ok(VerifiedReadOutcome { value, stamp, work });
1254 }
1255 if attempt + 1 < VERIFIED_READ_ATTEMPTS {
1256 work.retries = work.retries.saturating_add(1);
1257 }
1258 }
1259 Err(source_changed_during_derivation(&binding.root, "."))
1260 }
1261
1262 fn exact_stamp(&self, store: &AtlasStore, root: &Path) -> Result<VerifiedReadStamp, CliError> {
1264 let publication = store
1265 .index_publication()?
1266 .ok_or_else(|| source_changed_during_derivation(root, "."))?;
1267 let captured = store.captured_project_binding()?;
1268 Ok(VerifiedReadStamp {
1269 process_nonce: self.process_nonce,
1270 epoch: 0,
1271 generation: publication.generation,
1272 project_instance_id: captured.project_instance_id,
1273 })
1274 }
1275}
1276
1277fn watcher_callback(
1279 sender: SyncSender<Event>,
1280 receiver: Arc<Mutex<Receiver<Event>>>,
1281 ingress_sequence: Arc<AtomicU64>,
1282 continuity_lost: Arc<AtomicBool>,
1283) -> impl FnMut(notify::Result<Event>) + Send + 'static {
1284 move |result| match result {
1285 Ok(event) if matches!(event.kind, EventKind::Access(_)) => {}
1286 result => {
1287 let _receiver = match receiver.lock() {
1288 Ok(receiver) => receiver,
1289 Err(_poisoned) => {
1290 continuity_lost.store(true, Ordering::Release);
1291 ingress_sequence.fetch_add(1, Ordering::AcqRel);
1292 return;
1293 }
1294 };
1295 match result {
1296 Ok(event) => {
1297 let needs_rescan = event.need_rescan();
1298 match sender.try_send(event) {
1299 Ok(()) if !needs_rescan => {}
1300 Ok(()) | Err(TrySendError::Full(_) | TrySendError::Disconnected(_)) => {
1301 continuity_lost.store(true, Ordering::Release);
1302 }
1303 }
1304 }
1305 Err(_source) => {
1306 continuity_lost.store(true, Ordering::Release);
1307 }
1308 }
1309 ingress_sequence.fetch_add(1, Ordering::AcqRel);
1310 }
1311 }
1312}
1313
1314fn source_policy_witness(
1316 plan: &ScanRuntimePlan,
1317 control: &IndexWorkControl,
1318) -> Result<String, CliError> {
1319 let mut hasher = Hasher::new();
1320 hash_field(
1321 &mut hasher,
1322 "contract",
1323 plan.publication_contract_fingerprint().as_bytes(),
1324 );
1325 let root_identity = CanonicalProjectRoot::from_path(&plan.root)
1326 .map_err(|error| CliError::InvalidInput(error.to_string()))?;
1327 let root_identity_bytes = root_identity
1328 .encode()
1329 .map_err(|error| CliError::InvalidInput(error.to_string()))?;
1330 hash_field(&mut hasher, "root", &root_identity_bytes);
1331 for path in source_policy_paths(plan, control)? {
1332 control.check(IndexWorkStage::Publication)?;
1333 hash_field(&mut hasher, "path", path.as_os_str().as_encoded_bytes());
1334 match path.metadata() {
1335 Ok(metadata) if metadata.is_file() => {
1336 let file = File::open(&path).map_err(|source| CliError::Io {
1337 path: path.clone(),
1338 source,
1339 })?;
1340 hash_policy_file(&mut hasher, path.as_path(), file, control)?;
1341 }
1342 Ok(metadata) if metadata.is_dir() => {
1343 hash_field(&mut hasher, "state", b"directory");
1344 }
1345 Ok(_metadata) => {
1346 hash_field(&mut hasher, "state", b"other");
1347 }
1348 Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
1349 hash_field(&mut hasher, "state", b"absent");
1350 }
1351 Err(source) => {
1352 return Err(CliError::Io { path, source });
1353 }
1354 }
1355 }
1356 Ok(hasher.finalize().to_hex().to_string())
1357}
1358
1359fn exact_source_policy(
1361 binding: &SourceBinding,
1362 control: &IndexWorkControl,
1363) -> Result<(String, String), CliError> {
1364 let plan = ScanRuntimePlan::for_path_controlled(
1365 binding.config.as_deref(),
1366 &binding.root,
1367 None,
1368 control,
1369 )
1370 .map_err(|source| publication_input_error(&binding.root, source))?;
1371 let contract_fingerprint = plan.publication_contract_fingerprint();
1372 let policy_witness = source_policy_witness(&plan, control)?;
1373 Ok((contract_fingerprint, policy_witness))
1374}
1375
1376fn source_policy_paths(
1378 plan: &ScanRuntimePlan,
1379 control: &IndexWorkControl,
1380) -> Result<Vec<PathBuf>, CliError> {
1381 let mut paths = projectatlas_fs::source_selection_policy_paths_controlled(&plan.root, control)
1382 .map_err(|source| source_inspection_error(&plan.root, source))?;
1383 if let Some(config) = plan.selected_config_path.as_ref() {
1384 paths.push(config.clone());
1385 } else {
1386 paths.push(plan.root.join(".projectatlas").join("config.toml"));
1387 paths.push(plan.root.join("projectatlas.toml"));
1388 }
1389 paths.sort();
1390 paths.dedup();
1391 Ok(paths)
1392}
1393
1394fn hash_policy_file(
1396 hasher: &mut Hasher,
1397 path: &Path,
1398 file: File,
1399 control: &IndexWorkControl,
1400) -> Result<(), CliError> {
1401 let mut reader = file.take(MAX_POLICY_INPUT_BYTES.saturating_add(1));
1402 let mut buffer = [0_u8; 8_192];
1403 let mut observed = 0_u64;
1404 loop {
1405 control.check(IndexWorkStage::Publication)?;
1406 let read = read_policy_chunk(&mut reader, &mut buffer, path)?;
1407 if read == 0 {
1408 break;
1409 }
1410 observed = observed.saturating_add(u64::try_from(read).unwrap_or(u64::MAX));
1411 if observed > MAX_POLICY_INPUT_BYTES {
1412 return Err(CliError::InvalidInput(format!(
1413 "source-selection policy input '{}' exceeds the {} byte limit",
1414 path.display(),
1415 MAX_POLICY_INPUT_BYTES
1416 )));
1417 }
1418 hasher.update(&buffer[..read]);
1419 }
1420 hash_field(hasher, "state", b"present");
1421 Ok(())
1422}
1423
1424fn read_policy_chunk(
1426 reader: &mut Take<File>,
1427 buffer: &mut [u8],
1428 path: &Path,
1429) -> Result<usize, CliError> {
1430 reader.read(buffer).map_err(|source| CliError::Io {
1431 path: path.to_path_buf(),
1432 source,
1433 })
1434}
1435
1436fn hash_field(hasher: &mut Hasher, name: &str, value: &[u8]) {
1438 hasher.update(name.as_bytes());
1439 hasher.update(&[0]);
1440 hasher.update(value);
1441 hasher.update(&[0xff]);
1442}
1443
1444fn absolute_path_from(root: &Path, path: &Path) -> PathBuf {
1446 if path.is_absolute() {
1447 path.to_path_buf()
1448 } else {
1449 root.join(path)
1450 }
1451}
1452
1453fn process_nonce() -> [u8; 16] {
1455 let mut nonce = [0_u8; 16];
1456 if getrandom::fill(&mut nonce).is_ok() {
1457 return nonce;
1458 }
1459 let mut hasher = Hasher::new();
1460 hasher.update(&std::process::id().to_le_bytes());
1461 let time = SystemTime::now()
1462 .duration_since(UNIX_EPOCH)
1463 .map_or(0, |duration| duration.as_nanos());
1464 hasher.update(&time.to_le_bytes());
1465 nonce.copy_from_slice(&hasher.finalize().as_bytes()[..16]);
1466 nonce
1467}
1468
1469fn observer_error(root: &Path, source: ¬ify::Error) -> CliError {
1471 CliError::InvalidInput(format!(
1472 "source observation is unavailable for '{}': {source}",
1473 root.display()
1474 ))
1475}
1476
1477fn lock_error(root: &Path, owner: &str) -> CliError {
1479 CliError::InvalidInput(format!(
1480 "{owner} lock is unavailable for project root '{}'",
1481 root.display()
1482 ))
1483}
1484
1485#[cfg(test)]
1486mod tests {
1487 use super::*;
1488 use notify::event::{ModifyKind, RenameMode};
1489 use notify::{EventKind, event::AccessKind};
1490 use projectatlas_core::{IndexCancellation, PurposeSource};
1491 use std::error::Error;
1492 #[cfg(windows)]
1493 use std::process::Command;
1494
1495 fn indexed_project(root: &Path) -> Result<(PathBuf, PathBuf), Box<dyn Error>> {
1497 fs::create_dir_all(root.join(".projectatlas"))?;
1498 let source = root.join("source.rs");
1499 fs::write(&source, "fn original() {}\n")?;
1500 let database = root.join(".projectatlas").join("projectatlas.db");
1501 let mut store = super::super::open_atlas_store_for_project(&database, root)?;
1502 let plan = ScanRuntimePlan::for_path(None, root, None)?;
1503 super::super::run_scan_pipeline(
1504 &mut store,
1505 &plan,
1506 &super::super::SymbolBuildOptions::new(
1507 super::super::MAX_SYMBOL_FILE_BYTES,
1508 Some(1),
1509 None,
1510 ),
1511 )?;
1512 drop(store);
1513 Ok((database, source))
1514 }
1515
1516 fn test_control() -> IndexWorkControl {
1518 IndexWorkControl::new(IndexCancellation::new(), Some(Duration::from_secs(30)))
1519 }
1520
1521 fn require(condition: bool, message: &str) -> Result<(), Box<dyn Error>> {
1523 if condition {
1524 Ok(())
1525 } else {
1526 Err(std::io::Error::other(message).into())
1527 }
1528 }
1529
1530 #[test]
1531 fn native_observer_teardown_releases_root_and_external_config() -> Result<(), Box<dyn Error>> {
1532 let temp = tempfile::tempdir()?;
1533 let root = temp.path().join("repo");
1534 let external = temp.path().join("settings");
1535 fs::create_dir_all(&root)?;
1536 fs::create_dir_all(&external)?;
1537 let config = external.join("config.toml");
1538 fs::write(&config, "")?;
1539 let database = root.join("atlas.db");
1540 let entry =
1541 SourceObservationEntry::start(SourceBinding::new(&database, &root, Some(&config))?)?;
1542 fs::write(root.join("source.rs"), "fn observed() {}")?;
1543 drop(entry);
1544 require(!database.exists(), "observer cleanup created an index")?;
1545 fs::remove_dir_all(&root)?;
1546 fs::remove_dir_all(&external)?;
1547 require(
1548 !root.exists() && !external.exists(),
1549 "watch directories remain",
1550 )
1551 }
1552
1553 #[test]
1554 fn native_observer_failed_registration_releases_admitted_root() -> Result<(), Box<dyn Error>> {
1555 let temp = tempfile::tempdir()?;
1556 let root = temp.path().join("repo");
1557 fs::create_dir_all(&root)?;
1558 let database = root.join("atlas.db");
1559 let config = temp.path().join("missing-parent").join("config.toml");
1560 let binding = SourceBinding::new(&database, &root, Some(&config))?;
1561 let result = SourceObservationEntry::start(binding.clone());
1562 require(result.is_err(), "missing configuration parent was accepted")?;
1563 require(
1564 !database.exists(),
1565 "failed observer startup created an index",
1566 )?;
1567 fs::remove_dir_all(&root)?;
1568 require(!root.exists(), "failed startup retained its root")?;
1569 require(
1570 SourceObservationEntry::start(binding).is_err(),
1571 "missing root was accepted",
1572 )
1573 }
1574
1575 #[test]
1576 fn watcher_callback_is_bounded_and_marks_overflow_and_rescan() {
1577 let (sender, receiver) = sync_channel(1);
1578 let receiver = Arc::new(Mutex::new(receiver));
1579 let sequence = Arc::new(AtomicU64::new(0));
1580 let lost = Arc::new(AtomicBool::new(false));
1581 let mut callback = watcher_callback(
1582 sender,
1583 Arc::clone(&receiver),
1584 Arc::clone(&sequence),
1585 Arc::clone(&lost),
1586 );
1587 callback(Ok(Event::new(EventKind::Modify(ModifyKind::Name(
1588 RenameMode::Any,
1589 )))));
1590 callback(Ok(Event::new(EventKind::Modify(ModifyKind::Any))));
1591
1592 assert_eq!(sequence.load(Ordering::Acquire), 2);
1593 assert!(lost.load(Ordering::Acquire));
1594 assert!(
1595 receiver
1596 .lock()
1597 .is_ok_and(|receiver| receiver.try_recv().is_ok())
1598 );
1599 }
1600
1601 #[test]
1602 fn watcher_callback_ignores_access_events_without_advancing_epoch() {
1603 let (sender, receiver) = sync_channel(1);
1604 let receiver = Arc::new(Mutex::new(receiver));
1605 let sequence = Arc::new(AtomicU64::new(0));
1606 let lost = Arc::new(AtomicBool::new(false));
1607 let mut callback =
1608 watcher_callback(sender, receiver, Arc::clone(&sequence), Arc::clone(&lost));
1609 callback(Ok(Event::new(EventKind::Access(AccessKind::Any))));
1610
1611 assert_eq!(sequence.load(Ordering::Acquire), 0);
1612 assert!(!lost.load(Ordering::Acquire));
1613 }
1614
1615 #[test]
1616 fn verified_epoch_avoids_repeat_tree_and_node_table_work() -> Result<(), Box<dyn Error>> {
1617 let temp = tempfile::tempdir()?;
1618 let (database, _source) = indexed_project(temp.path())?;
1619 let registry = SourceObservationRegistry::default();
1620
1621 let first = registry.with_verified_read(
1622 &database,
1623 temp.path(),
1624 None,
1625 &test_control(),
1626 |store, stamp| Ok((store.overview()?, stamp)),
1627 )?;
1628 require(
1629 (1..=u64::try_from(VERIFIED_READ_ATTEMPTS)?).contains(&first.work.exact_verifications),
1630 "initial read did not perform a bounded exact verification",
1631 )?;
1632 require(
1633 first.work.filesystem_entries > 0,
1634 "initial read did not inspect filesystem entries",
1635 )?;
1636 require(
1637 first.work.decoded_nodes > 0,
1638 "initial read did not decode indexed nodes",
1639 )?;
1640
1641 let second = registry.with_verified_read(
1642 &database,
1643 temp.path(),
1644 None,
1645 &test_control(),
1646 |store, stamp| Ok((store.overview()?, stamp)),
1647 )?;
1648 require(
1649 second.work.exact_verifications == 0,
1650 "warm read unexpectedly repeated exact verification",
1651 )?;
1652 require(
1653 second.work.filesystem_entries == 0,
1654 "warm read unexpectedly inspected filesystem entries",
1655 )?;
1656 require(
1657 second.work.filesystem_bytes == 0,
1658 "warm read unexpectedly hashed source bytes",
1659 )?;
1660 require(
1661 second.work.decoded_nodes == 0,
1662 "warm read unexpectedly decoded the full node table",
1663 )?;
1664 require(
1665 second.work.sqlite_read_statements == 1,
1666 "warm freshness check used an unexpected SQLite statement count",
1667 )?;
1668 require(
1669 first.stamp == second.stamp,
1670 "warm read did not reuse the verified epoch",
1671 )?;
1672 Ok(())
1673 }
1674
1675 #[test]
1676 fn ignore_policy_changes_invalidate_the_epoch_and_refresh_source_truth()
1677 -> Result<(), Box<dyn Error>> {
1678 let temp = tempfile::tempdir()?;
1679 let (database, _source) = indexed_project(temp.path())?;
1680 let registry = SourceObservationRegistry::default();
1681 let first = registry.with_verified_read(
1682 &database,
1683 temp.path(),
1684 None,
1685 &test_control(),
1686 |store, _stamp| Ok(store.load_node_by_path("source.rs")?.is_some()),
1687 )?;
1688 require(first.value, "initial indexed source was missing")?;
1689
1690 fs::write(temp.path().join(".ignore"), "source.rs\n")?;
1691 let ignored = registry.with_verified_read(
1692 &database,
1693 temp.path(),
1694 None,
1695 &test_control(),
1696 |store, _stamp| Ok(store.load_node_by_path("source.rs")?.is_some()),
1697 );
1698
1699 require(
1700 matches!(ignored, Err(CliError::RefreshRequired(_))),
1701 "ignore policy change did not require refresh",
1702 )?;
1703 let binding = SourceBinding::new(&database, temp.path(), None)?;
1704 let entry = registry
1705 .entries
1706 .lock()
1707 .map_err(|_poisoned| std::io::Error::other("registry lock poisoned"))?
1708 .get(&binding)
1709 .cloned()
1710 .ok_or_else(|| std::io::Error::other("observer entry missing"))?;
1711 require(
1712 entry.current_epoch()?.is_none(),
1713 "ignore policy change left a verified epoch installed",
1714 )?;
1715 Ok(())
1716 }
1717
1718 #[test]
1719 fn git_exclude_changes_are_witnessed_with_a_git_directory() -> Result<(), Box<dyn Error>> {
1720 let temp = tempfile::tempdir()?;
1721 let (database, _source) = indexed_project(temp.path())?;
1722 let git = temp.path().join(".git");
1723 let git_info = git.join("info");
1724 fs::create_dir_all(&git_info)?;
1725 fs::create_dir_all(git.join("objects"))?;
1726 fs::create_dir_all(git.join("refs"))?;
1727 fs::write(git.join("HEAD"), "ref: refs/heads/main\n")?;
1728 fs::write(git.join("config"), "[core]\n")?;
1729 fs::write(git_info.join("exclude"), "")?;
1730 let registry = SourceObservationRegistry::default();
1731 let first = registry.with_verified_read(
1732 &database,
1733 temp.path(),
1734 None,
1735 &test_control(),
1736 |store, _stamp| Ok(store.load_node_by_path("source.rs")?.is_some()),
1737 )?;
1738 require(first.value, "initial indexed source was missing")?;
1739
1740 fs::write(git_info.join("exclude"), "source.rs\n")?;
1741 let ignored = registry.with_verified_read(
1742 &database,
1743 temp.path(),
1744 None,
1745 &test_control(),
1746 |store, _stamp| Ok(store.load_node_by_path("source.rs")?.is_some()),
1747 )?;
1748
1749 require(
1750 !ignored.value,
1751 "Git exclude change left the excluded source indexed",
1752 )?;
1753 require(
1754 ignored.work.exact_verifications >= 1,
1755 "Git exclude change reused the stale source epoch",
1756 )?;
1757 require(
1758 ignored.stamp.epoch > first.stamp.epoch,
1759 "Git exclude change did not advance the verified source epoch",
1760 )?;
1761 Ok(())
1762 }
1763
1764 #[cfg(unix)]
1765 #[test]
1766 fn symlinked_git_exclude_target_changes_invalidate_the_epoch() -> Result<(), Box<dyn Error>> {
1767 let temp = tempfile::tempdir()?;
1768 let policy = tempfile::tempdir()?;
1769 let (database, _source) = indexed_project(temp.path())?;
1770 let git = temp.path().join(".git");
1771 let git_info = git.join("info");
1772 let exclude_target = policy.path().join("exclude");
1773 fs::create_dir_all(&git_info)?;
1774 fs::create_dir_all(git.join("objects"))?;
1775 fs::create_dir_all(git.join("refs"))?;
1776 fs::write(git.join("HEAD"), "ref: refs/heads/main\n")?;
1777 fs::write(git.join("config"), "[core]\n")?;
1778 fs::write(&exclude_target, "")?;
1779 std::os::unix::fs::symlink(&exclude_target, git_info.join("exclude"))?;
1780 let registry = SourceObservationRegistry::default();
1781 let first = registry.with_verified_read(
1782 &database,
1783 temp.path(),
1784 None,
1785 &test_control(),
1786 |store, _stamp| Ok(store.load_node_by_path("source.rs")?.is_some()),
1787 )?;
1788 require(first.value, "initial indexed source was missing")?;
1789
1790 fs::write(&exclude_target, "source.rs\n")?;
1791 let ignored = registry.with_verified_read(
1792 &database,
1793 temp.path(),
1794 None,
1795 &test_control(),
1796 |store, _stamp| Ok(store.load_node_by_path("source.rs")?.is_some()),
1797 )?;
1798
1799 require(
1800 !ignored.value,
1801 "symlinked Git exclude target change left the source indexed",
1802 )?;
1803 require(
1804 ignored.work.exact_verifications >= 1,
1805 "symlinked Git exclude target change reused the stale source epoch",
1806 )?;
1807 require(
1808 ignored.stamp.epoch > first.stamp.epoch,
1809 "symlinked Git exclude target change did not advance the verified source epoch",
1810 )?;
1811 Ok(())
1812 }
1813
1814 #[test]
1815 fn mid_query_edit_discards_provisional_result_and_reconciles() -> Result<(), Box<dyn Error>> {
1816 let temp = tempfile::tempdir()?;
1817 let (database, source) = indexed_project(temp.path())?;
1818 let registry = SourceObservationRegistry::default();
1819 let _initial = registry.with_verified_read(
1820 &database,
1821 temp.path(),
1822 None,
1823 &test_control(),
1824 |store, _stamp| Ok(store.overview()?),
1825 )?;
1826 let binding = SourceBinding::new(&database, temp.path(), None)?;
1827 let entry = registry
1828 .entries
1829 .lock()
1830 .map_err(|_poisoned| std::io::Error::other("registry lock poisoned"))?
1831 .get(&binding)
1832 .cloned()
1833 .ok_or_else(|| std::io::Error::other("observer entry missing"))?;
1834 let mut calls = 0_u64;
1835 let revised = "fn revised() {}\n";
1836
1837 let outcome = registry.with_verified_read(
1838 &database,
1839 temp.path(),
1840 None,
1841 &test_control(),
1842 |store, _stamp| {
1843 calls = calls.saturating_add(1);
1844 let hash = store
1845 .load_node_by_path("source.rs")?
1846 .and_then(|node| node.node.content_hash)
1847 .ok_or_else(|| CliError::InvalidInput("source hash missing".to_string()))?;
1848 if calls == 1 {
1849 fs::write(&source, revised).map_err(|source_error| CliError::Io {
1850 path: source.clone(),
1851 source: source_error,
1852 })?;
1853 entry.publish_test_event(
1854 Event::new(EventKind::Modify(ModifyKind::Any)).add_path(source.clone()),
1855 )?;
1856 }
1857 Ok(hash)
1858 },
1859 )?;
1860
1861 require(calls >= 2, "mid-query edit did not retry the query")?;
1862 require(
1863 outcome.work.retries >= 1,
1864 "mid-query edit was not reported as a retry",
1865 )?;
1866 require(
1867 outcome.work.exact_verifications >= 1,
1868 "mid-query edit did not trigger exact verification",
1869 )?;
1870 require(
1871 outcome.value == blake3::hash(revised.as_bytes()).to_hex().to_string(),
1872 "accepted result did not reflect the revised source",
1873 )?;
1874 Ok(())
1875 }
1876
1877 #[test]
1878 fn observed_acceptance_does_not_wait_for_exact_reconciliation() -> Result<(), Box<dyn Error>> {
1879 let temp = tempfile::tempdir()?;
1880 let (database, _source) = indexed_project(temp.path())?;
1881 let registry = SourceObservationRegistry::default();
1882 let control = test_control();
1883 let entry = registry
1884 .entry(SourceBinding::new(&database, temp.path(), None)?)?
1885 .ok_or_else(|| std::io::Error::other("source observer unavailable"))?;
1886 let (store, epoch) = registry
1887 .prepare_observed_store(&entry, &control, &mut VerifiedReadWork::default(), false)?
1888 .ok_or_else(|| std::io::Error::other("read epoch unavailable"))?;
1889 let reconcile = entry
1890 .reconcile
1891 .lock()
1892 .map_err(|_poisoned| std::io::Error::other("source reconciliation lock poisoned"))?;
1893 let (sender, receiver) = std::sync::mpsc::channel();
1894 let accepted = std::thread::scope(|scope| {
1895 scope.spawn(|| {
1896 let accepted = matches!(
1897 SourceObservationRegistry::accepts_observed_result(&entry, &epoch, &control),
1898 Ok(ObservedAcceptance::Accepted)
1899 );
1900 let _sent = sender.send(accepted);
1901 });
1902 let result = receiver.recv_timeout(Duration::from_secs(5));
1903 drop(reconcile);
1905 result
1906 });
1907 require(
1908 accepted == Ok(true),
1909 "observed acceptance waited for exact reconciliation or rejected valid evidence",
1910 )?;
1911 store.finish_index_read_snapshot()?;
1912 Ok(())
1913 }
1914
1915 #[test]
1916 fn older_read_acceptance_preserves_newer_mutation_witness() -> Result<(), Box<dyn Error>> {
1917 let temp = tempfile::tempdir()?;
1918 let (database, source) = indexed_project(temp.path())?;
1919 let before = fs::read(&source)?;
1920 let registry = SourceObservationRegistry::default();
1921 let control = test_control();
1922 let mut admission = None;
1923 registry.with_verified_read(&database, temp.path(), None, &control, |store, _stamp| {
1924 if admission.is_none() {
1925 admission =
1926 Some(registry.admit_mutation(&database, temp.path(), None, &control)?);
1927 }
1928 Ok(store.overview()?)
1929 })?;
1930 require(
1931 fs::read(&source)? == before,
1932 "source changed during read admission",
1933 )?;
1934 let store = super::super::open_atlas_store_for_project(&database, temp.path())?;
1935 let transaction = store.begin_purpose_mutation()?;
1936 store.set_purpose(
1937 "source.rs",
1938 "Accepted concurrent purpose",
1939 PurposeSource::Agent,
1940 )?;
1941 admission
1942 .ok_or_else(|| std::io::Error::other("mutation admission missing"))?
1943 .verify()?;
1944 transaction.commit()?;
1945 require(
1946 store.load_node_by_path("source.rs")?.is_some_and(|node| {
1947 node.purpose.purpose.as_deref() == Some("Accepted concurrent purpose")
1948 }),
1949 "valid concurrent mutation did not commit its purpose",
1950 )?;
1951 Ok(())
1952 }
1953
1954 #[test]
1955 fn superseded_mutation_cleanup_preserves_successor_witness() -> Result<(), Box<dyn Error>> {
1956 let temp = tempfile::tempdir()?;
1957 let (database, _source) = indexed_project(temp.path())?;
1958 let registry = SourceObservationRegistry::default();
1959 let control = test_control();
1960 let previous = registry.admit_mutation(&database, temp.path(), None, &control)?;
1961 let current = registry.admit_mutation(&database, temp.path(), None, &control)?;
1962 require(
1963 matches!(previous.verify(), Err(CliError::RefreshRequired(_))),
1964 "superseded mutation witness was unexpectedly accepted",
1965 )?;
1966 current.verify()?;
1967 Ok(())
1968 }
1969
1970 #[test]
1971 fn exact_source_mismatch_invalidates_successor_without_observer_delivery()
1972 -> Result<(), Box<dyn Error>> {
1973 let temp = tempfile::tempdir()?;
1974 let (database, source) = indexed_project(temp.path())?;
1975 let registry = SourceObservationRegistry::default();
1976 let binding = SourceBinding::new(&database, temp.path(), None)?;
1977 let mut observer = SourceObservationEntry::start(binding.clone())?;
1978 observer.watcher.unwatch(&binding.root)?;
1979 observer.watcher.configure(notify::Config::default())?;
1980 let entry = Arc::new(observer);
1981 registry
1982 .entries
1983 .lock()
1984 .map_err(|_poisoned| std::io::Error::other("observer registry lock poisoned"))?
1985 .insert(binding, Arc::clone(&entry));
1986 let control = test_control();
1987 let previous = registry.admit_mutation(&database, temp.path(), None, &control)?;
1988 let MutationSourceWitness::Observed { epoch, .. } = &previous.witness else {
1989 return Err(std::io::Error::other("observed mutation missing").into());
1990 };
1991 let store = super::super::open_atlas_store_for_project(&database, temp.path())?;
1992 let before_revision = store.authored_purpose_revision()?;
1993 let transaction = store.begin_purpose_mutation()?;
1994 let mut successor = None;
1995 let result = VerifiedMutationAdmission::verify_observed(&entry, epoch, &control, || {
1996 successor = Some(registry.admit_mutation(&database, temp.path(), None, &control)?);
1997 store.set_purpose("source.rs", "Uncommitted purpose", PurposeSource::Agent)?;
1998 fs::write(&source, "fn changed_after_successor() {}\n").map_err(|error| {
1999 CliError::Io {
2000 path: source.clone(),
2001 source: error,
2002 }
2003 })?;
2004 verify_saved_source_matches_index_controlled(&database, temp.path(), None, &control)
2005 });
2006 drop(transaction);
2007 require(
2008 matches!(result, Err(CliError::RefreshRequired(_))),
2009 "exact source mismatch did not reject the previous mutation",
2010 )?;
2011 require(
2012 store.authored_purpose_revision()? == before_revision,
2013 "rejected mutation changed the authored-purpose revision",
2014 )?;
2015 require(
2016 entry.current_epoch()?.is_none(),
2017 "proven saved-source mismatch left successor evidence reusable",
2018 )?;
2019 let successor =
2020 successor.ok_or_else(|| std::io::Error::other("successor admission missing"))?;
2021 require(
2022 matches!(successor.verify(), Err(CliError::RefreshRequired(_))),
2023 "successor survived a proven saved-source mismatch",
2024 )?;
2025 Ok(())
2026 }
2027
2028 #[test]
2029 fn cancelled_exact_verification_preserves_successor_witness() -> Result<(), Box<dyn Error>> {
2030 let temp = tempfile::tempdir()?;
2031 let (database, _source) = indexed_project(temp.path())?;
2032 let registry = SourceObservationRegistry::default();
2033 let cancellation = IndexCancellation::new();
2034 let control = IndexWorkControl::new(cancellation.clone(), Some(Duration::from_secs(30)));
2035 let previous = registry.admit_mutation(&database, temp.path(), None, &control)?;
2036 let MutationSourceWitness::Observed { entry, epoch } = &previous.witness else {
2037 return Err(std::io::Error::other("observed mutation missing").into());
2038 };
2039 let mut successor = None;
2040 let result = VerifiedMutationAdmission::verify_observed(entry, epoch, &control, || {
2041 successor =
2042 Some(registry.admit_mutation(&database, temp.path(), None, &test_control())?);
2043 cancellation.cancel();
2044 verify_saved_source_matches_index_controlled(&database, temp.path(), None, &control)
2045 });
2046 require(
2047 matches!(result, Err(CliError::IndexWork(_))),
2048 "exact verification did not return cancellation",
2049 )?;
2050 successor
2051 .ok_or_else(|| std::io::Error::other("successor admission missing"))?
2052 .verify()?;
2053 Ok(())
2054 }
2055
2056 #[test]
2057 fn stale_read_source_event_invalidates_newer_mutation_and_rolls_back()
2058 -> Result<(), Box<dyn Error>> {
2059 let temp = tempfile::tempdir()?;
2060 let (database, source) = indexed_project(temp.path())?;
2061 let registry = SourceObservationRegistry::default();
2062 let control = test_control();
2063 let entry = registry
2064 .entry(SourceBinding::new(&database, temp.path(), None)?)?
2065 .ok_or_else(|| std::io::Error::other("source observer unavailable"))?;
2066 let (read_store, old_epoch) = registry
2067 .prepare_observed_store(&entry, &control, &mut VerifiedReadWork::default(), false)?
2068 .ok_or_else(|| std::io::Error::other("read epoch unavailable"))?;
2069 let admission = registry.admit_mutation(&database, temp.path(), None, &control)?;
2070 let store = super::super::open_atlas_store_for_project(&database, temp.path())?;
2071 let before_revision = store.authored_purpose_revision()?;
2072 let before_purpose = store
2073 .load_node_by_path("source.rs")?
2074 .ok_or_else(|| std::io::Error::other("indexed source missing"))?
2075 .purpose;
2076 let transaction = store.begin_purpose_mutation()?;
2077 store.set_purpose(
2078 "source.rs",
2079 "Rejected concurrent purpose",
2080 PurposeSource::Agent,
2081 )?;
2082 let event = Event::new(EventKind::Modify(ModifyKind::Any)).add_path(source);
2083 let previous_options = projectatlas_fs::ScanOptions {
2084 exclude_path_prefixes: vec!["source.rs".to_owned()],
2085 ..projectatlas_fs::ScanOptions::default()
2086 };
2087 let ignored = observer_event_changes(&entry.binding, &previous_options, &event);
2088 require(
2089 ignored.paths.is_empty() && !ignored.requires_full_scan,
2090 "previous policy did not exclude the event in the regression fixture",
2091 )?;
2092 entry.publish_test_event(event)?;
2093 {
2094 let receiver = entry.receiver.lock().map_err(|_poisoned| {
2095 std::io::Error::other("source observation receiver lock poisoned")
2096 })?;
2097 require(
2098 entry.drain_epoch_events(&receiver, &old_epoch, &previous_options)?
2099 == ObservedAcceptance::Superseded,
2100 "older policy consumed an event belonging to a successor epoch",
2101 )?;
2102 }
2103 require(
2104 SourceObservationRegistry::accepts_observed_result(&entry, &old_epoch, &control)?
2105 == ObservedAcceptance::Invalidated,
2106 "stale reader treated a source event as mere supersession",
2107 )?;
2108 require(
2109 entry.current_epoch()?.is_none(),
2110 "source event left newer evidence installed",
2111 )?;
2112 let plan = ScanRuntimePlan::for_path_controlled(None, temp.path(), None, &control)?;
2113 require(
2114 entry.changed_since_exact_verification(&plan.scan_options)?,
2115 "consumed source event was lost to concurrent exact verification",
2116 )?;
2117 read_store.finish_index_read_snapshot()?;
2118 let verification = admission.verify();
2119 transaction.rollback()?;
2120 require(
2121 matches!(verification, Err(CliError::RefreshRequired(_))),
2122 "consumed source event did not reject the mutation witness",
2123 )?;
2124 require(
2125 store.authored_purpose_revision()? == before_revision
2126 && store
2127 .load_node_by_path("source.rs")?
2128 .is_some_and(|node| node.purpose == before_purpose),
2129 "rejected mutation changed authored purpose state",
2130 )?;
2131 Ok(())
2132 }
2133
2134 #[test]
2135 fn mutation_admission_reconciles_saved_source_without_waiting_for_observer_delivery()
2136 -> Result<(), Box<dyn Error>> {
2137 let temp = tempfile::tempdir()?;
2138 let (database, source) = indexed_project(temp.path())?;
2139 let registry = SourceObservationRegistry::default();
2140 let control = test_control();
2141 let _warm = registry.with_verified_read(
2142 &database,
2143 temp.path(),
2144 None,
2145 &control,
2146 |store, _stamp| Ok(store.overview()?),
2147 )?;
2148 let revised = "fn revised_before_admission() {}\n";
2149 fs::write(&source, revised)?;
2150
2151 let admission = registry.admit_mutation(&database, temp.path(), None, &control)?;
2152 let store = super::super::open_atlas_store_for_project(&database, temp.path())?;
2153 let content_hash = store
2154 .load_node_by_path("source.rs")?
2155 .and_then(|node| node.node.content_hash)
2156 .ok_or_else(|| std::io::Error::other("reconciled source hash missing"))?;
2157 require(
2158 content_hash == blake3::hash(revised.as_bytes()).to_hex().to_string(),
2159 "mutation admission reused the warm source epoch",
2160 )?;
2161 admission.verify()?;
2162 Ok(())
2163 }
2164
2165 #[test]
2166 fn mutation_admission_retries_transient_invalidation_and_falls_back_to_exact_source()
2167 -> Result<(), Box<dyn Error>> {
2168 let temp = tempfile::tempdir()?;
2169 let (database, source) = indexed_project(temp.path())?;
2170 let registry = SourceObservationRegistry::default();
2171 let control = test_control();
2172 let store = super::super::open_atlas_store_for_project(&database, temp.path())?;
2173 let before_source = fs::read(&source)?;
2174 let before_revision = store.authored_purpose_revision()?;
2175 let before_purpose = store
2176 .load_node_by_path("source.rs")?
2177 .ok_or_else(|| std::io::Error::other("indexed source missing"))?
2178 .purpose;
2179
2180 registry
2181 .mutation_acceptance_invalidations
2182 .store(1, Ordering::Release);
2183 let admission = registry.admit_mutation(&database, temp.path(), None, &control)?;
2184 require(
2185 registry
2186 .mutation_acceptance_invalidations
2187 .load(Ordering::Acquire)
2188 == 0,
2189 "transient mutation invalidation was not consumed",
2190 )?;
2191 admission.verify()?;
2192
2193 registry
2194 .mutation_acceptance_invalidations
2195 .store(u64::try_from(VERIFIED_READ_ATTEMPTS)?, Ordering::Release);
2196 let exact = registry.admit_mutation(&database, temp.path(), None, &control)?;
2197 require(
2198 matches!(&exact.witness, MutationSourceWitness::Exact { .. }),
2199 "persistent mutation invalidation did not fall back to exact source",
2200 )?;
2201 require(
2202 registry
2203 .mutation_acceptance_invalidations
2204 .load(Ordering::Acquire)
2205 == 0,
2206 "mutation invalidation attempts were not bounded",
2207 )?;
2208 exact.verify()?;
2209 require(
2210 fs::read(&source)? == before_source,
2211 "mutation admission changed saved source",
2212 )?;
2213 require(
2214 store.authored_purpose_revision()? == before_revision,
2215 "mutation admission changed authored-purpose revision",
2216 )?;
2217 require(
2218 store
2219 .load_node_by_path("source.rs")?
2220 .is_some_and(|node| node.purpose == before_purpose),
2221 "mutation admission changed the purpose row",
2222 )?;
2223 Ok(())
2224 }
2225
2226 #[test]
2227 fn exact_mismatch_during_preparation_prevents_observed_installation()
2228 -> Result<(), Box<dyn Error>> {
2229 let temp = tempfile::tempdir()?;
2230 let (database, _source) = indexed_project(temp.path())?;
2231 let registry = SourceObservationRegistry::default();
2232 registry
2233 .preparation_invalidations
2234 .store(u64::try_from(VERIFIED_READ_ATTEMPTS)?, Ordering::Release);
2235
2236 let admission = registry.admit_mutation(&database, temp.path(), None, &test_control())?;
2237
2238 require(
2239 matches!(&admission.witness, MutationSourceWitness::Exact { .. }),
2240 "preparation continuity loss did not fall back to exact source",
2241 )?;
2242 require(
2243 registry.preparation_invalidations.load(Ordering::Acquire) == 0,
2244 "preparation invalidation attempts were not bounded",
2245 )?;
2246 admission.verify()?;
2247
2248 registry
2249 .preparation_invalidations
2250 .store(u64::try_from(VERIFIED_READ_ATTEMPTS)?, Ordering::Release);
2251 let read = registry.with_verified_read(
2252 &database,
2253 temp.path(),
2254 None,
2255 &test_control(),
2256 |store, _stamp| Ok(store.overview()?),
2257 );
2258 require(
2259 matches!(read, Err(CliError::RefreshRequired(_))),
2260 "observer read did not retain continuity-loss recovery",
2261 )?;
2262 Ok(())
2263 }
2264
2265 #[test]
2266 fn mutation_verification_redrains_delayed_observer_events() -> Result<(), Box<dyn Error>> {
2267 let temp = tempfile::tempdir()?;
2268 let (database, source) = indexed_project(temp.path())?;
2269 let registry = SourceObservationRegistry::default();
2270 let admission = registry.admit_mutation(&database, temp.path(), None, &test_control())?;
2271 let MutationSourceWitness::Observed { entry, .. } = &admission.witness else {
2272 return Err(
2273 std::io::Error::other("mutation did not retain an observed witness").into(),
2274 );
2275 };
2276 *entry
2277 .acceptance_event
2278 .lock()
2279 .map_err(|_poisoned| std::io::Error::other("acceptance event lock poisoned"))? =
2280 Some(Event::new(EventKind::Modify(ModifyKind::Any)).add_path(database));
2281
2282 admission.verify()?;
2283
2284 require(
2285 entry
2286 .acceptance_event
2287 .lock()
2288 .map_err(|_poisoned| std::io::Error::other("acceptance event lock poisoned"))?
2289 .is_none(),
2290 "delayed irrelevant event was not consumed",
2291 )?;
2292 *entry
2293 .acceptance_event
2294 .lock()
2295 .map_err(|_poisoned| std::io::Error::other("acceptance event lock poisoned"))? =
2296 Some(Event::new(EventKind::Modify(ModifyKind::Any)).add_path(source));
2297 require(
2298 matches!(admission.verify(), Err(CliError::RefreshRequired(_))),
2299 "delayed relevant event was accepted",
2300 )?;
2301 Ok(())
2302 }
2303
2304 #[test]
2305 fn mutation_verification_rechecks_continuity_under_drain_lock() -> Result<(), Box<dyn Error>> {
2306 let temp = tempfile::tempdir()?;
2307 let (database, _source) = indexed_project(temp.path())?;
2308 let registry = SourceObservationRegistry::default();
2309 let admission = registry.admit_mutation(&database, temp.path(), None, &test_control())?;
2310 let MutationSourceWitness::Observed { entry, .. } = &admission.witness else {
2311 return Err(
2312 std::io::Error::other("mutation did not retain an observed witness").into(),
2313 );
2314 };
2315 entry
2316 .drain_continuity_invalidations
2317 .store(1, Ordering::Release);
2318
2319 require(
2320 matches!(admission.verify(), Err(CliError::RefreshRequired(_))),
2321 "continuity loss during drain admission was accepted",
2322 )?;
2323 require(
2324 entry.drain_continuity_invalidations.load(Ordering::Acquire) == 0,
2325 "drain continuity invalidation was not consumed",
2326 )?;
2327 Ok(())
2328 }
2329
2330 #[test]
2331 fn mutation_admission_uses_exact_fallback_when_observer_capacity_is_full()
2332 -> Result<(), Box<dyn Error>> {
2333 let temp = tempfile::tempdir()?;
2334 let (database, source) = indexed_project(temp.path())?;
2335 let git = temp.path().join(".git");
2336 let git_info = git.join("info");
2337 let git_exclude = git_info.join("exclude");
2338 fs::create_dir_all(&git_info)?;
2339 fs::create_dir_all(git.join("objects"))?;
2340 fs::create_dir_all(git.join("refs"))?;
2341 fs::write(git.join("HEAD"), "ref: refs/heads/main\n")?;
2342 fs::write(git.join("config"), "[core]\n")?;
2343 fs::write(&git_exclude, "")?;
2344 let registry = SourceObservationRegistry::default();
2345 let binding = SourceBinding::new(&database, temp.path(), None)?;
2346 let filler = registry
2347 .entry(binding.clone())?
2348 .ok_or_else(|| std::io::Error::other("source observer was unavailable"))?;
2349 {
2350 let mut entries = registry
2351 .entries
2352 .lock()
2353 .map_err(|_poisoned| std::io::Error::other("observer registry was poisoned"))?;
2354 entries.clear();
2355 for index in 0..SOURCE_OBSERVATION_CAPACITY {
2356 let mut filler_binding = binding.clone();
2357 filler_binding.config = Some(temp.path().join(format!("observer-{index}.toml")));
2358 entries.insert(filler_binding, Arc::clone(&filler));
2359 }
2360 }
2361
2362 let control = test_control();
2363 let admission = registry.admit_mutation(&database, temp.path(), None, &control)?;
2364 require(
2365 matches!(&admission.witness, MutationSourceWitness::Exact { .. }),
2366 "full observer registry did not use exact mutation admission",
2367 )?;
2368 let entries = registry
2369 .entries
2370 .lock()
2371 .map_err(|_poisoned| std::io::Error::other("observer registry was poisoned"))?;
2372 require(
2373 entries.len() == SOURCE_OBSERVATION_CAPACITY && !entries.contains_key(&binding),
2374 "exact mutation admission changed the bounded observer registry",
2375 )?;
2376 drop(entries);
2377
2378 let store = super::super::open_atlas_store_for_project(&database, temp.path())?;
2379 let transaction = store.begin_purpose_mutation()?;
2380 store.set_purpose("source.rs", "Exact fallback purpose", PurposeSource::Agent)?;
2381 admission.verify()?;
2382 transaction.commit()?;
2383 require(
2384 store.load_node_by_path("source.rs")?.is_some_and(|node| {
2385 node.purpose.purpose.as_deref() == Some("Exact fallback purpose")
2386 }),
2387 "exact fallback did not commit an unchanged-source mutation",
2388 )?;
2389
2390 let policy_admission = registry.admit_mutation(&database, temp.path(), None, &control)?;
2391 let before_revision = store.authored_purpose_revision()?;
2392 let before_purpose = store
2393 .load_node_by_path("source.rs")?
2394 .ok_or_else(|| std::io::Error::other("indexed source missing"))?
2395 .purpose;
2396 let transaction = store.begin_purpose_mutation()?;
2397 store.set_purpose("source.rs", "Rejected policy purpose", PurposeSource::Agent)?;
2398 fs::write(&git_exclude, "never-present-policy-only.tmp\n")?;
2399 let verification = policy_admission.verify();
2400 transaction.rollback()?;
2401 require(
2402 matches!(verification, Err(CliError::RefreshRequired(_))),
2403 "exact fallback accepted changed source-selection policy",
2404 )?;
2405 require(
2406 store.authored_purpose_revision()? == before_revision
2407 && store
2408 .load_node_by_path("source.rs")?
2409 .is_some_and(|node| node.purpose == before_purpose),
2410 "policy-drift rejection changed authored purpose",
2411 )?;
2412
2413 let replaced = registry.admit_mutation(&database, temp.path(), None, &control)?;
2414 drop(store);
2415 fs::write(&source, "fn concurrently_published() {}\n")?;
2416 let mut publisher = super::super::open_atlas_store_for_project(&database, temp.path())?;
2417 let plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
2418 super::super::run_scan_pipeline(
2419 &mut publisher,
2420 &plan,
2421 &super::super::SymbolBuildOptions::new(
2422 super::super::MAX_SYMBOL_FILE_BYTES,
2423 Some(1),
2424 None,
2425 ),
2426 )?;
2427 drop(publisher);
2428 verify_saved_source_matches_index_controlled(&database, temp.path(), None, &control)?;
2429 let store = super::super::open_atlas_store_for_project(&database, temp.path())?;
2430 let before_revision = store.authored_purpose_revision()?;
2431 let before_purpose = store
2432 .load_node_by_path("source.rs")?
2433 .ok_or_else(|| std::io::Error::other("indexed source missing"))?
2434 .purpose;
2435 let transaction = store.begin_purpose_mutation()?;
2436 store.set_purpose(
2437 "source.rs",
2438 "Replaced generation purpose",
2439 PurposeSource::Agent,
2440 )?;
2441 let verification = replaced.verify();
2442 transaction.rollback()?;
2443 require(
2444 matches!(verification, Err(CliError::RefreshRequired(_))),
2445 "exact fallback accepted a replacement publication generation",
2446 )?;
2447 require(
2448 store.authored_purpose_revision()? == before_revision
2449 && store
2450 .load_node_by_path("source.rs")?
2451 .is_some_and(|node| node.purpose == before_purpose),
2452 "replacement-generation rejection changed authored purpose",
2453 )?;
2454
2455 let cancellation = IndexCancellation::new();
2456 let canceled_control =
2457 IndexWorkControl::new(cancellation.clone(), Some(Duration::from_secs(30)));
2458 let canceled = registry.admit_mutation(&database, temp.path(), None, &canceled_control)?;
2459 cancellation.cancel();
2460 require(
2461 matches!(canceled.verify(), Err(CliError::IndexWork(_))),
2462 "exact fallback did not retain cancellation through verification",
2463 )?;
2464
2465 let before_revision = store.authored_purpose_revision()?;
2466 let before_purpose = store
2467 .load_node_by_path("source.rs")?
2468 .ok_or_else(|| std::io::Error::other("indexed source missing"))?
2469 .purpose;
2470 let stale = registry.admit_mutation(&database, temp.path(), None, &control)?;
2471 let transaction = store.begin_purpose_mutation()?;
2472 store.set_purpose(
2473 "source.rs",
2474 "Rejected fallback purpose",
2475 PurposeSource::Agent,
2476 )?;
2477 fs::write(&source, "fn changed_after_exact_admission() {}\n")?;
2478 let verification = stale.verify();
2479 transaction.rollback()?;
2480 require(
2481 matches!(verification, Err(CliError::RefreshRequired(_))),
2482 "exact fallback admitted a mutation after saved source changed",
2483 )?;
2484 require(
2485 store.authored_purpose_revision()? == before_revision,
2486 "rejected exact fallback advanced authored-purpose revision",
2487 )?;
2488 require(
2489 store
2490 .load_node_by_path("source.rs")?
2491 .is_some_and(|node| node.purpose == before_purpose),
2492 "rejected exact fallback changed the purpose row",
2493 )?;
2494 Ok(())
2495 }
2496
2497 #[test]
2498 fn post_admission_edit_rolls_back_without_waiting_for_observer_delivery()
2499 -> Result<(), Box<dyn Error>> {
2500 let temp = tempfile::tempdir()?;
2501 let (database, source) = indexed_project(temp.path())?;
2502 let registry = SourceObservationRegistry::default();
2503 let control = test_control();
2504 let admission = registry.admit_mutation(&database, temp.path(), None, &control)?;
2505 let store = super::super::open_atlas_store_for_project(&database, temp.path())?;
2506 let before = store
2507 .load_node_by_path("source.rs")?
2508 .ok_or_else(|| std::io::Error::other("indexed source missing"))?
2509 .purpose;
2510 let before_revision = store.authored_purpose_revision()?;
2511 let transaction = store.begin_purpose_mutation()?;
2512 store.set_purpose("source.rs", "Stale purpose", PurposeSource::Agent)?;
2513
2514 fs::write(&source, "fn changed_after_admission() {}\n")?;
2515 let verification = admission.verify();
2516 drop(transaction);
2517 require(
2518 matches!(verification, Err(CliError::RefreshRequired(_))),
2519 "post-admission edit did not invalidate the purpose commit",
2520 )?;
2521 require(
2522 store.authored_purpose_revision()? == before_revision,
2523 "rolled-back mutation advanced authored-purpose revision",
2524 )?;
2525 require(
2526 store
2527 .load_node_by_path("source.rs")?
2528 .is_some_and(|node| node.purpose == before),
2529 "rolled-back mutation changed the purpose row",
2530 )?;
2531 Ok(())
2532 }
2533
2534 #[test]
2535 fn post_admission_cancellation_rolls_back_purpose_mutation() -> Result<(), Box<dyn Error>> {
2536 let temp = tempfile::tempdir()?;
2537 let (database, _source) = indexed_project(temp.path())?;
2538 let registry = SourceObservationRegistry::default();
2539 let cancellation = IndexCancellation::new();
2540 let control = IndexWorkControl::new(cancellation.clone(), Some(Duration::from_secs(30)));
2541 let admission = registry.admit_mutation(&database, temp.path(), None, &control)?;
2542 let store = super::super::open_atlas_store_for_project(&database, temp.path())?;
2543 let before_revision = store.authored_purpose_revision()?;
2544 let transaction = store.begin_purpose_mutation()?;
2545 store.set_purpose("source.rs", "Canceled purpose", PurposeSource::Agent)?;
2546
2547 cancellation.cancel();
2548 let verification = admission.verify();
2549 drop(transaction);
2550 require(
2551 matches!(verification, Err(CliError::IndexWork(_))),
2552 "post-admission cancellation did not invalidate the purpose commit",
2553 )?;
2554 require(
2555 store.authored_purpose_revision()? == before_revision,
2556 "canceled mutation advanced authored-purpose revision",
2557 )?;
2558 require(
2559 store
2560 .load_node_by_path("source.rs")?
2561 .is_none_or(|node| node.purpose.purpose.as_deref() != Some("Canceled purpose")),
2562 "canceled mutation changed the purpose row",
2563 )?;
2564 Ok(())
2565 }
2566
2567 #[test]
2568 fn same_native_path_rejects_distinct_unresolved_paths() -> Result<(), Box<dyn Error>> {
2569 let temp = tempfile::tempdir()?;
2570 let left = temp.path().join("missing-left").join("leaf");
2571 let right = temp.path().join("missing-right").join("leaf");
2572
2573 require(
2574 !super::same_native_path(&left, &right),
2575 "unresolved distinct paths were treated as equal",
2576 )?;
2577 Ok(())
2578 }
2579
2580 #[test]
2581 fn same_native_path_matches_paths_below_a_canonicalizable_parent() -> Result<(), Box<dyn Error>>
2582 {
2583 let temp = tempfile::tempdir()?;
2584 let parent = temp.path().join("canonical-parent");
2585 let nested = parent.join("nested");
2586 fs::create_dir(&parent)?;
2587 fs::create_dir(&nested)?;
2588 let left = parent.join("missing");
2589 let right = nested.join("..").join("missing");
2590
2591 require(
2592 super::same_native_path(&left, &right),
2593 "equivalent paths below a canonicalizable parent diverged",
2594 )?;
2595 Ok(())
2596 }
2597
2598 #[cfg(windows)]
2599 #[test]
2600 fn observer_filters_case_variant_database_sidecars_without_rescan() -> Result<(), Box<dyn Error>>
2601 {
2602 let temp = tempfile::tempdir()?;
2603 let root = temp.path().join("MiXeDAtlasRoot");
2604 let metadata = root.join(".projectatlas");
2605 fs::create_dir_all(&metadata)?;
2606 let database = metadata.join("ProjectAtlas.db");
2607 fs::write(&database, b"SQLite format 3\0")?;
2608 let sidecars = ["-wal", "-shm", "-journal"].map(|suffix| {
2609 let mut sidecar = database.as_os_str().to_os_string();
2610 sidecar.push(suffix);
2611 PathBuf::from(sidecar)
2612 });
2613 for sidecar in &sidecars {
2614 fs::write(sidecar, b"SQLite sidecar")?;
2615 }
2616 let source = root.join("source.rs");
2617 fs::write(&source, "fn source() {}\n")?;
2618 let binding = SourceBinding::new(&database, &root, None)?;
2619 let scan_options = projectatlas_fs::ScanOptions::default();
2620 let differently_cased = |path: &Path| -> Result<PathBuf, Box<dyn Error>> {
2621 let path = path
2622 .to_str()
2623 .ok_or_else(|| std::io::Error::other("test path was not UTF-8"))?;
2624 Ok(PathBuf::from(path.to_ascii_uppercase()))
2625 };
2626
2627 for path in std::iter::once(&database).chain(sidecars.iter()) {
2628 let event =
2629 Event::new(EventKind::Modify(ModifyKind::Any)).add_path(differently_cased(path)?);
2630 let changes = super::observer_event_changes(&binding, &scan_options, &event);
2631 require(
2632 !changes.has_changes(),
2633 "case-variant SQLite runtime event triggered a rescan",
2634 )?;
2635 }
2636
2637 for sidecar in &sidecars {
2638 fs::remove_file(sidecar)?;
2639 let event = Event::new(EventKind::Modify(ModifyKind::Any))
2640 .add_path(differently_cased(sidecar)?);
2641 let changes = super::observer_event_changes(&binding, &scan_options, &event);
2642 require(
2643 !changes.has_changes(),
2644 "case-variant removed SQLite sidecar event triggered a rescan",
2645 )?;
2646 }
2647
2648 let event = Event::new(EventKind::Modify(ModifyKind::Any)).add_path(source);
2649 let changes = super::observer_event_changes(&binding, &scan_options, &event);
2650 require(
2651 changes.has_changes(),
2652 "non-runtime source event was incorrectly filtered",
2653 )?;
2654 Ok(())
2655 }
2656
2657 #[cfg(windows)]
2658 #[test]
2659 fn observer_keeps_case_variant_sidecar_event_in_case_sensitive_directory()
2660 -> Result<(), Box<dyn Error>> {
2661 let temp = tempfile::tempdir()?;
2662 let parent = temp.path().join("case-sensitive-observer");
2663 fs::create_dir(&parent)?;
2664 let enabled = Command::new("fsutil")
2665 .args(["file", "SetCaseSensitiveInfo"])
2666 .arg(&parent)
2667 .arg("enable")
2668 .status()
2669 .is_ok_and(|status| status.success());
2670 if !enabled {
2671 return Ok(());
2672 }
2673 let root = parent.join("Root");
2674 let metadata = root.join(".projectatlas");
2675 fs::create_dir_all(&metadata)?;
2676 let database = metadata.join("ProjectAtlas.db");
2677 fs::write(&database, b"SQLite format 3\0")?;
2678 let binding = SourceBinding::new(&database, &root, None)?;
2679 let event_path = metadata.join("PROJECTATLAS.DB-WAL");
2680 let event = Event::new(EventKind::Modify(ModifyKind::Any)).add_path(event_path);
2681 let changes = super::observer_event_changes(
2682 &binding,
2683 &projectatlas_fs::ScanOptions::default(),
2684 &event,
2685 );
2686 require(
2687 changes.has_changes(),
2688 "case-variant sidecar event was suppressed in a case-sensitive directory",
2689 )
2690 }
2691
2692 #[test]
2693 fn cancellation_and_continuity_loss_never_certify_partial_truth() -> Result<(), Box<dyn Error>>
2694 {
2695 let temp = tempfile::tempdir()?;
2696 let (database, _source) = indexed_project(temp.path())?;
2697 let registry = SourceObservationRegistry::default();
2698 let initial = registry.with_verified_read(
2699 &database,
2700 temp.path(),
2701 None,
2702 &test_control(),
2703 |store, _stamp| Ok(store.overview()?),
2704 )?;
2705 require(
2706 initial.work.exact_verifications >= 1,
2707 "initial read did not establish exact truth",
2708 )?;
2709 let binding = SourceBinding::new(&database, temp.path(), None)?;
2710 let entry = registry
2711 .entries
2712 .lock()
2713 .map_err(|_poisoned| std::io::Error::other("registry lock poisoned"))?
2714 .get(&binding)
2715 .cloned()
2716 .ok_or_else(|| std::io::Error::other("observer entry missing"))?;
2717
2718 let cancellation = IndexCancellation::new();
2719 cancellation.cancel();
2720 let canceled_control = IndexWorkControl::new(cancellation, None);
2721 let canceled = registry.with_verified_read(
2722 &database,
2723 temp.path(),
2724 None,
2725 &canceled_control,
2726 |store, _stamp| Ok(store.overview()?),
2727 );
2728 require(
2729 matches!(canceled, Err(CliError::IndexWork(_))),
2730 "cancelled observer read did not return index-work cancellation",
2731 )?;
2732 require(
2733 entry.current_epoch()?.is_none(),
2734 "cancellation left the previous verified epoch installed",
2735 )?;
2736
2737 let recovered = registry.with_verified_read(
2738 &database,
2739 temp.path(),
2740 None,
2741 &test_control(),
2742 |store, _stamp| Ok(store.overview()?),
2743 )?;
2744 require(
2745 recovered.work.exact_verifications >= 1,
2746 "read after cancellation did not establish exact truth",
2747 )?;
2748 entry.continuity_lost.store(true, Ordering::Release);
2749 let reverified = registry.with_verified_read(
2750 &database,
2751 temp.path(),
2752 None,
2753 &test_control(),
2754 |store, _stamp| Ok(store.overview()?),
2755 )?;
2756 require(
2757 reverified.work.exact_verifications >= 1,
2758 "continuity loss did not trigger exact verification",
2759 )?;
2760 Ok(())
2761 }
2762}