projectatlas/runtime/
source_observation.rs

1//! Process-local source observation for long-lived verified index reads.
2
3use super::{
4    ScanRuntimePlan, SourceVerificationWork, WatchChangeSet,
5    open_atlas_store_read_only_for_project, open_exact_fresh_atlas_store_for_project_controlled,
6    publication_input_error, source_changed_during_derivation, source_inspection_error,
7};
8use crate::CliError;
9use blake3::Hasher;
10use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
11use projectatlas_core::graph::ProjectInstanceId;
12use projectatlas_core::{IndexGeneration, IndexWorkControl, IndexWorkStage};
13use projectatlas_db::{AtlasStore, CapturedProjectBinding, IndexPublicationState};
14use std::collections::HashMap;
15use std::fmt;
16#[cfg(test)]
17use std::fs;
18use std::fs::File;
19use std::hash::Hash;
20use std::io::{Read, Take};
21use std::path::{Path, PathBuf};
22use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
23use std::sync::mpsc::{Receiver, SyncSender, TryRecvError, TrySendError, sync_channel};
24use std::sync::{Arc, Mutex};
25use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
26
27/// Maximum independent project bindings observed by one MCP process.
28const SOURCE_OBSERVATION_CAPACITY: usize = 16;
29/// Maximum unprocessed native watcher events retained per project.
30const SOURCE_OBSERVATION_QUEUE_CAPACITY: usize = 1_024;
31/// Fixed attempts used to reconcile an edit that overlaps a query or verification.
32const VERIFIED_READ_ATTEMPTS: usize = 3;
33/// Maximum bytes read from any one external source-selection policy input.
34const MAX_POLICY_INPUT_BYTES: u64 = 16 * 1_024 * 1_024;
35
36/// Exact process-local evidence attached to one accepted `SQLite` read snapshot.
37#[derive(Clone, Debug, Eq, PartialEq)]
38pub(crate) struct VerifiedReadStamp {
39    /// Nonce distinguishing this server process from every restart.
40    pub(crate) process_nonce: [u8; 16],
41    /// Monotonic verified epoch inside this process and source binding.
42    pub(crate) epoch: u64,
43    /// Durable complete publication generation read by the `SQLite` snapshot.
44    pub(crate) generation: IndexGeneration,
45    /// Durable project identity captured by the same root-bound snapshot.
46    pub(crate) project_instance_id: ProjectInstanceId,
47}
48
49/// Measured freshness work consumed before one result was accepted.
50#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
51pub(crate) struct VerifiedReadWork {
52    /// Exact full-source verifications performed for this accepted call.
53    pub(crate) exact_verifications: u64,
54    /// Repository entries inspected by exact verification.
55    pub(crate) filesystem_entries: u64,
56    /// Repository source bytes hashed by exact verification.
57    pub(crate) filesystem_bytes: u64,
58    /// `SQLite` read statements owned directly by freshness verification.
59    pub(crate) sqlite_read_statements: u64,
60    /// Full indexed-node rows decoded by freshness verification.
61    pub(crate) decoded_nodes: u64,
62    /// Provisional query results discarded after an overlapping invalidation.
63    pub(crate) retries: u64,
64    /// Wall time for verification, bounded query construction, and acceptance.
65    pub(crate) elapsed: Duration,
66    /// Rendered result bytes supplied by the adapter after acceptance.
67    pub(crate) output_bytes: u64,
68}
69
70impl VerifiedReadWork {
71    /// Accumulate one exact source verification into this accepted call's work.
72    fn add_exact(&mut self, work: SourceVerificationWork) {
73        self.exact_verifications = self.exact_verifications.saturating_add(1);
74        self.filesystem_entries = self
75            .filesystem_entries
76            .saturating_add(work.filesystem_entries);
77        self.filesystem_bytes = self.filesystem_bytes.saturating_add(work.filesystem_bytes);
78        self.sqlite_read_statements = self
79            .sqlite_read_statements
80            .saturating_add(work.sqlite_read_statements);
81        self.decoded_nodes = self.decoded_nodes.saturating_add(work.decoded_nodes);
82    }
83}
84
85/// Accepted owned result plus its epoch stamp and measured freshness work.
86#[derive(Clone, Debug, Eq, PartialEq)]
87pub(crate) struct VerifiedReadOutcome<T> {
88    /// Result built entirely inside the accepted `SQLite` snapshot.
89    pub(crate) value: T,
90    /// Process epoch and durable generation that accepted the result.
91    pub(crate) stamp: VerifiedReadStamp,
92    /// Work consumed before the result was accepted.
93    pub(crate) work: VerifiedReadWork,
94}
95
96impl<T> VerifiedReadOutcome<T> {
97    /// Record the already-rendered output size without rescanning or reopening `SQLite`.
98    pub(crate) fn with_output_bytes(mut self, output_bytes: usize) -> Self {
99        self.work.output_bytes = u64::try_from(output_bytes).unwrap_or(u64::MAX);
100        self
101    }
102}
103
104/// Exact root/database/config identity used to isolate one observer.
105#[derive(Clone, Debug, Eq)]
106struct SourceBinding {
107    /// Canonical source root watched by this observer.
108    root: PathBuf,
109    /// Database excluded from source-change invalidation.
110    database: PathBuf,
111    /// Optional scanner configuration watched with the source root.
112    config: Option<PathBuf>,
113}
114
115impl PartialEq for SourceBinding {
116    fn eq(&self, other: &Self) -> bool {
117        self.root == other.root && self.database == other.database && self.config == other.config
118    }
119}
120
121impl Hash for SourceBinding {
122    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
123        self.root.hash(state);
124        self.database.hash(state);
125        self.config.hash(state);
126    }
127}
128
129impl SourceBinding {
130    /// Resolve one root, database, and optional configuration into a stable binding.
131    fn new(database: &Path, root: &Path, config: Option<&Path>) -> Result<Self, CliError> {
132        let root = root.canonicalize().map_err(|source| CliError::Io {
133            path: root.to_path_buf(),
134            source,
135        })?;
136        let database = absolute_path_from(&root, database);
137        let config = config.map(|path| absolute_path_from(&root, path));
138        Ok(Self {
139            root,
140            database,
141            config,
142        })
143    }
144}
145
146/// One successfully verified source epoch retained only by this process.
147#[derive(Clone, Debug, Eq, PartialEq)]
148struct VerifiedSourceEpoch {
149    /// Accepted process and durable publication identity.
150    stamp: VerifiedReadStamp,
151    /// Latest watcher ingress sequence covered by this epoch.
152    ingress_sequence: u64,
153    /// Source publication contract covered by this epoch.
154    contract_fingerprint: String,
155    /// Exact external source-selection policy covered by this epoch.
156    policy_witness: String,
157}
158
159/// Mutable verified-epoch state serialized for one source binding.
160#[derive(Debug, Default)]
161struct ObservationState {
162    /// Monotonic epoch sequence within the owning observer.
163    next_epoch: u64,
164    /// Last source epoch proven safe for warm reads.
165    verified: Option<VerifiedSourceEpoch>,
166}
167
168/// Concrete watcher and state for one exact source binding.
169struct SourceObservationEntry {
170    /// Exact source, database, and configuration identity.
171    binding: SourceBinding,
172    /// Native watcher kept alive for the entry lifetime.
173    _watcher: RecommendedWatcher,
174    /// Bounded watcher event receiver drained during reconciliation.
175    receiver: Mutex<Receiver<Event>>,
176    /// Monotonic count of relevant events accepted by the callback.
177    ingress_sequence: Arc<AtomicU64>,
178    /// Whether overflow, disconnection, or rescan invalidated event continuity.
179    continuity_lost: Arc<AtomicBool>,
180    /// Serializes exact verification and epoch installation for this binding.
181    reconcile: Mutex<()>,
182    /// Current process-local verified epoch.
183    state: Mutex<ObservationState>,
184    #[cfg(test)]
185    /// Deterministic event ingress used only by owning tests.
186    test_sender: SyncSender<Event>,
187}
188
189impl fmt::Debug for SourceObservationEntry {
190    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
191        formatter
192            .debug_struct("SourceObservationEntry")
193            .field("binding", &self.binding)
194            .field(
195                "ingress_sequence",
196                &self.ingress_sequence.load(Ordering::Acquire),
197            )
198            .field(
199                "continuity_lost",
200                &self.continuity_lost.load(Ordering::Acquire),
201            )
202            .finish_non_exhaustive()
203    }
204}
205
206impl SourceObservationEntry {
207    /// Start a bounded native watcher for one source binding.
208    fn start(binding: SourceBinding) -> Result<Self, CliError> {
209        let (sender, receiver) = sync_channel(SOURCE_OBSERVATION_QUEUE_CAPACITY);
210        #[cfg(test)]
211        let test_sender = sender.clone();
212        let ingress_sequence = Arc::new(AtomicU64::new(0));
213        let continuity_lost = Arc::new(AtomicBool::new(false));
214        let mut watcher = notify::recommended_watcher(watcher_callback(
215            sender,
216            Arc::clone(&ingress_sequence),
217            Arc::clone(&continuity_lost),
218        ))
219        .map_err(|source| observer_error(&binding.root, &source))?;
220        watcher
221            .watch(&binding.root, RecursiveMode::Recursive)
222            .map_err(|source| observer_error(&binding.root, &source))?;
223        if let Some(config) = binding.config.as_deref()
224            && !config.starts_with(&binding.root)
225            && let Some(parent) = config.parent()
226        {
227            watcher
228                .watch(parent, RecursiveMode::NonRecursive)
229                .map_err(|source| observer_error(&binding.root, &source))?;
230        }
231        Ok(Self {
232            binding,
233            _watcher: watcher,
234            receiver: Mutex::new(receiver),
235            ingress_sequence,
236            continuity_lost,
237            reconcile: Mutex::new(()),
238            state: Mutex::new(ObservationState::default()),
239            #[cfg(test)]
240            test_sender,
241        })
242    }
243
244    /// Discard the current verified epoch after any uncertainty.
245    fn invalidate(&self) {
246        if let Ok(mut state) = self.state.lock() {
247            state.verified = None;
248        }
249    }
250
251    /// Reset continuity and drain events before sampling exact source truth.
252    fn clear_before_exact_verification(&self) -> Result<(), CliError> {
253        self.invalidate();
254        self.continuity_lost.store(false, Ordering::Release);
255        let receiver = self
256            .receiver
257            .lock()
258            .map_err(|_poisoned| lock_error(&self.binding.root, "source observation receiver"))?;
259        loop {
260            match receiver.try_recv() {
261                Ok(_event) => {}
262                Err(TryRecvError::Empty) => return Ok(()),
263                Err(TryRecvError::Disconnected) => {
264                    self.continuity_lost.store(true, Ordering::Release);
265                    return Ok(());
266                }
267            }
268        }
269    }
270
271    /// Return whether any potentially relevant event arrived since exact truth was sampled.
272    fn changed_since_exact_verification(
273        &self,
274        scan_options: &projectatlas_fs::ScanOptions,
275    ) -> Result<bool, CliError> {
276        if self.continuity_lost.load(Ordering::Acquire) {
277            return Ok(true);
278        }
279        let receiver = self
280            .receiver
281            .lock()
282            .map_err(|_poisoned| lock_error(&self.binding.root, "source observation receiver"))?;
283        let mut changes = WatchChangeSet::default();
284        loop {
285            match receiver.try_recv() {
286                Ok(event) => {
287                    let event_changes = observer_event_changes(&self.binding, scan_options, &event);
288                    changes.requires_full_scan |= event_changes.requires_full_scan;
289                    changes.paths.extend(event_changes.paths);
290                    if self
291                        .binding
292                        .config
293                        .as_ref()
294                        .is_some_and(|config| event.paths.iter().any(|path| path == config))
295                    {
296                        changes.requires_full_scan = true;
297                    }
298                }
299                Err(TryRecvError::Empty) => break,
300                Err(TryRecvError::Disconnected) => {
301                    self.continuity_lost.store(true, Ordering::Release);
302                    return Ok(true);
303                }
304            }
305        }
306        let changed = changes.requires_full_scan || !changes.paths.is_empty();
307        if !changed {
308            let acknowledged = self.ingress_sequence.load(Ordering::Acquire);
309            let mut state = self
310                .state
311                .lock()
312                .map_err(|_poisoned| lock_error(&self.binding.root, "source observation state"))?;
313            if let Some(epoch) = state.verified.as_mut() {
314                epoch.ingress_sequence = acknowledged;
315            }
316        }
317        Ok(changed)
318    }
319
320    /// Install a new verified epoch after exact source and policy reconciliation.
321    fn install_epoch(
322        &self,
323        process_nonce: [u8; 16],
324        binding: &CapturedProjectBinding,
325        generation: IndexGeneration,
326        ingress_sequence: u64,
327        contract_fingerprint: String,
328        policy_witness: String,
329    ) -> Result<VerifiedSourceEpoch, CliError> {
330        let mut state = self
331            .state
332            .lock()
333            .map_err(|_poisoned| lock_error(&self.binding.root, "source observation state"))?;
334        state.next_epoch = state.next_epoch.saturating_add(1);
335        let epoch = VerifiedSourceEpoch {
336            stamp: VerifiedReadStamp {
337                process_nonce,
338                epoch: state.next_epoch,
339                generation,
340                project_instance_id: binding.project_instance_id,
341            },
342            ingress_sequence,
343            contract_fingerprint,
344            policy_witness,
345        };
346        state.verified = Some(epoch.clone());
347        Ok(epoch)
348    }
349
350    /// Clone the current verified epoch without holding the state lock.
351    fn current_epoch(&self) -> Result<Option<VerifiedSourceEpoch>, CliError> {
352        self.state
353            .lock()
354            .map(|state| state.verified.clone())
355            .map_err(|_poisoned| lock_error(&self.binding.root, "source observation state"))
356    }
357}
358
359/// Classify source events without letting this binding's own `SQLite` files self-invalidate it.
360fn observer_event_changes(
361    binding: &SourceBinding,
362    scan_options: &projectatlas_fs::ScanOptions,
363    event: &Event,
364) -> WatchChangeSet {
365    let mut filtered = event.clone();
366    let metadata_directory = binding.root.join(".projectatlas");
367    filtered.paths.retain(|path| {
368        let candidate = super::absolute_watch_path(&binding.root, path);
369        !same_native_path(&candidate, &metadata_directory)
370            && !is_database_runtime_path(&candidate, &binding.database)
371    });
372    if filtered.paths.is_empty() && !event.need_rescan() {
373        return WatchChangeSet::default();
374    }
375    super::notify_event_changes(&binding.root, scan_options, &filtered)
376}
377
378/// Return whether a path is the database or one of its runtime sidecar files.
379fn is_database_runtime_path(candidate: &Path, database: &Path) -> bool {
380    if same_native_path(candidate, database) {
381        return true;
382    }
383    let Some(database_name) = database.file_name().and_then(|name| name.to_str()) else {
384        return false;
385    };
386    candidate.parent().is_some_and(|parent| {
387        database
388            .parent()
389            .is_some_and(|database_parent| same_native_path(parent, database_parent))
390    }) && candidate
391        .file_name()
392        .and_then(|name| name.to_str())
393        .is_some_and(|name| {
394            name.strip_prefix(database_name)
395                .is_some_and(|suffix| matches!(suffix, "-wal" | "-shm" | "-journal"))
396        })
397}
398
399/// Compare normalized native paths using platform case semantics.
400fn same_native_path(left: &Path, right: &Path) -> bool {
401    let left = projectatlas_core::normalize_native_path_display(left);
402    let right = projectatlas_core::normalize_native_path_display(right);
403    if cfg!(windows) {
404        left.eq_ignore_ascii_case(&right)
405    } else {
406        left == right
407    }
408}
409
410/// Bounded process-local registry shared by every clone of one MCP server.
411pub(crate) struct SourceObservationRegistry {
412    /// Random identity distinguishing epochs created by this process.
413    process_nonce: [u8; 16],
414    /// Bounded observers keyed by exact source binding.
415    entries: Mutex<HashMap<SourceBinding, Arc<SourceObservationEntry>>>,
416}
417
418impl fmt::Debug for SourceObservationRegistry {
419    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
420        let entries = self.entries.lock().map_or(0, |entries| entries.len());
421        formatter
422            .debug_struct("SourceObservationRegistry")
423            .field("entries", &entries)
424            .field("capacity", &SOURCE_OBSERVATION_CAPACITY)
425            .finish_non_exhaustive()
426    }
427}
428
429impl Default for SourceObservationRegistry {
430    fn default() -> Self {
431        Self {
432            process_nonce: process_nonce(),
433            entries: Mutex::new(HashMap::new()),
434        }
435    }
436}
437
438impl SourceObservationRegistry {
439    /// Run one complete query inside a source-epoch and `SQLite`-generation boundary.
440    pub(crate) fn with_verified_read<T, F>(
441        &self,
442        database: &Path,
443        root: &Path,
444        config: Option<&Path>,
445        control: &IndexWorkControl,
446        query: F,
447    ) -> Result<VerifiedReadOutcome<T>, CliError>
448    where
449        F: FnMut(&AtlasStore, VerifiedReadStamp) -> Result<T, CliError>,
450    {
451        let binding = SourceBinding::new(database, root, config)?;
452        let Some(entry) = self.entry(binding.clone())? else {
453            return self.with_exact_fallback(&binding, control, query);
454        };
455        self.with_observed_read(&entry, control, query)
456    }
457
458    /// Inject one deterministic observer event for owning integration tests.
459    #[cfg(test)]
460    pub(crate) fn inject_test_event(
461        &self,
462        database: &Path,
463        root: &Path,
464        config: Option<&Path>,
465        event: Event,
466    ) -> Result<(), CliError> {
467        let binding = SourceBinding::new(database, root, config)?;
468        let entry = self
469            .entries
470            .lock()
471            .map_err(|_poisoned| lock_error(&binding.root, "source observation registry"))?
472            .get(&binding)
473            .cloned()
474            .ok_or_else(|| {
475                CliError::InvalidInput(format!(
476                    "source observer test entry is missing for '{}'",
477                    binding.root.display()
478                ))
479            })?;
480        entry.ingress_sequence.fetch_add(1, Ordering::AcqRel);
481        entry.test_sender.try_send(event).map_err(|source| {
482            CliError::InvalidInput(format!(
483                "deterministic source observer event injection failed: {source}"
484            ))
485        })
486    }
487
488    /// Reuse or admit the bounded observer for an exact source binding.
489    fn entry(
490        &self,
491        binding: SourceBinding,
492    ) -> Result<Option<Arc<SourceObservationEntry>>, CliError> {
493        let mut entries = self
494            .entries
495            .lock()
496            .map_err(|_poisoned| lock_error(&binding.root, "source observation registry"))?;
497        if let Some(entry) = entries.get(&binding) {
498            return Ok(Some(Arc::clone(entry)));
499        }
500        if entries.len() >= SOURCE_OBSERVATION_CAPACITY {
501            return Ok(None);
502        }
503        let entry = match SourceObservationEntry::start(binding.clone()) {
504            Ok(entry) => Arc::new(entry),
505            Err(_observer_unavailable) => return Ok(None),
506        };
507        entries.insert(binding, Arc::clone(&entry));
508        Ok(Some(entry))
509    }
510
511    /// Retry a query until one observed epoch remains valid through acceptance.
512    fn with_observed_read<T, F>(
513        &self,
514        entry: &SourceObservationEntry,
515        control: &IndexWorkControl,
516        mut query: F,
517    ) -> Result<VerifiedReadOutcome<T>, CliError>
518    where
519        F: FnMut(&AtlasStore, VerifiedReadStamp) -> Result<T, CliError>,
520    {
521        let started = Instant::now();
522        let mut work = VerifiedReadWork::default();
523        for attempt in 0..VERIFIED_READ_ATTEMPTS {
524            if let Err(error) = control.check(IndexWorkStage::Publication) {
525                entry.invalidate();
526                return Err(error.into());
527            }
528            let (store, epoch) = match self.prepare_observed_store(entry, control, &mut work) {
529                Ok(prepared) => prepared,
530                Err(error) => {
531                    entry.invalidate();
532                    return Err(error);
533                }
534            };
535            let value = match query(&store, epoch.stamp.clone()) {
536                Ok(value) => value,
537                Err(error) => {
538                    if matches!(error, CliError::IndexWork(_)) {
539                        entry.invalidate();
540                    }
541                    drop(store.finish_index_read_snapshot());
542                    return Err(error);
543                }
544            };
545            match Self::accepts_observed_result(entry, &epoch, control) {
546                Ok(true) => {
547                    store.finish_index_read_snapshot()?;
548                    work.elapsed = started.elapsed();
549                    return Ok(VerifiedReadOutcome {
550                        value,
551                        stamp: epoch.stamp,
552                        work,
553                    });
554                }
555                Ok(false) => {}
556                Err(error) => {
557                    entry.invalidate();
558                    drop(store.finish_index_read_snapshot());
559                    return Err(error);
560                }
561            }
562            entry.invalidate();
563            drop(store.finish_index_read_snapshot());
564            if attempt + 1 < VERIFIED_READ_ATTEMPTS {
565                work.retries = work.retries.saturating_add(1);
566            }
567        }
568        Err(source_changed_during_derivation(&entry.binding.root, "."))
569    }
570
571    /// Open a snapshot backed by a current epoch or establish a new exact epoch.
572    fn prepare_observed_store(
573        &self,
574        entry: &SourceObservationEntry,
575        control: &IndexWorkControl,
576        work: &mut VerifiedReadWork,
577    ) -> Result<(AtlasStore, VerifiedSourceEpoch), CliError> {
578        let _reconcile = entry
579            .reconcile
580            .lock()
581            .map_err(|_poisoned| lock_error(&entry.binding.root, "source reconciliation"))?;
582        let plan = ScanRuntimePlan::for_path_controlled(
583            entry.binding.config.as_deref(),
584            &entry.binding.root,
585            None,
586            control,
587        )
588        .map_err(|source| publication_input_error(&entry.binding.root, source))?;
589        if entry.changed_since_exact_verification(&plan.scan_options)? {
590            entry.invalidate();
591        }
592        let contract_fingerprint = plan.publication_contract_fingerprint();
593        let policy_witness = source_policy_witness(&plan, control)?;
594        if let Some(epoch) = entry.current_epoch()?
595            && epoch.contract_fingerprint == contract_fingerprint
596            && epoch.policy_witness == policy_witness
597            && !entry.continuity_lost.load(Ordering::Acquire)
598        {
599            let store = open_atlas_store_read_only_for_project(
600                &entry.binding.database,
601                &entry.binding.root,
602            )?;
603            let publication = store.index_publication()?.filter(|publication| {
604                publication.state == IndexPublicationState::Complete
605                    && publication.contract_fingerprint.as_deref()
606                        == Some(contract_fingerprint.as_str())
607            });
608            let captured = store.captured_project_binding()?;
609            work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
610            if publication
611                .is_some_and(|publication| publication.generation == epoch.stamp.generation)
612                && captured.project_instance_id == epoch.stamp.project_instance_id
613            {
614                return Ok((store, epoch));
615            }
616            entry.invalidate();
617            drop(store.finish_index_read_snapshot());
618        }
619
620        for _attempt in 0..VERIFIED_READ_ATTEMPTS {
621            entry.clear_before_exact_verification()?;
622            let before_plan = ScanRuntimePlan::for_path_controlled(
623                entry.binding.config.as_deref(),
624                &entry.binding.root,
625                None,
626                control,
627            )
628            .map_err(|source| publication_input_error(&entry.binding.root, source))?;
629            let before_contract = before_plan.publication_contract_fingerprint();
630            let before_policy = source_policy_witness(&before_plan, control)?;
631            let exact = open_exact_fresh_atlas_store_for_project_controlled(
632                &entry.binding.database,
633                &entry.binding.root,
634                entry.binding.config.as_deref(),
635                control,
636            )?;
637            work.add_exact(exact.work);
638            let after_plan = ScanRuntimePlan::for_path_controlled(
639                entry.binding.config.as_deref(),
640                &entry.binding.root,
641                None,
642                control,
643            )
644            .map_err(|source| publication_input_error(&entry.binding.root, source))?;
645            let after_contract = after_plan.publication_contract_fingerprint();
646            let after_policy = source_policy_witness(&after_plan, control)?;
647            let changed = entry.changed_since_exact_verification(&after_plan.scan_options)?;
648            if changed || before_contract != after_contract || before_policy != after_policy {
649                drop(exact.store.finish_index_read_snapshot());
650                continue;
651            }
652            let publication = exact
653                .store
654                .index_publication()?
655                .ok_or_else(|| source_changed_during_derivation(&entry.binding.root, "."))?;
656            let captured = exact.store.captured_project_binding()?;
657            work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
658            let ingress_sequence = entry.ingress_sequence.load(Ordering::Acquire);
659            let epoch = entry.install_epoch(
660                self.process_nonce,
661                &captured,
662                publication.generation,
663                ingress_sequence,
664                after_contract,
665                after_policy,
666            )?;
667            return Ok((exact.store, epoch));
668        }
669        Err(source_changed_during_derivation(&entry.binding.root, "."))
670    }
671
672    /// Confirm that no source, policy, or observer event invalidated a query result.
673    fn accepts_observed_result(
674        entry: &SourceObservationEntry,
675        epoch: &VerifiedSourceEpoch,
676        control: &IndexWorkControl,
677    ) -> Result<bool, CliError> {
678        control.check(IndexWorkStage::Publication)?;
679        if entry.continuity_lost.load(Ordering::Acquire) {
680            return Ok(false);
681        }
682        let plan = ScanRuntimePlan::for_path_controlled(
683            entry.binding.config.as_deref(),
684            &entry.binding.root,
685            None,
686            control,
687        )
688        .map_err(|source| publication_input_error(&entry.binding.root, source))?;
689        if entry.changed_since_exact_verification(&plan.scan_options)?
690            || plan.publication_contract_fingerprint() != epoch.contract_fingerprint
691            || source_policy_witness(&plan, control)? != epoch.policy_witness
692        {
693            return Ok(false);
694        }
695        Ok(entry.current_epoch()?.is_some_and(|current| {
696            current.stamp == epoch.stamp
697                && current.contract_fingerprint == epoch.contract_fingerprint
698                && current.policy_witness == epoch.policy_witness
699                && current.ingress_sequence == entry.ingress_sequence.load(Ordering::Acquire)
700        }))
701    }
702
703    /// Exact-per-call compatibility path when a native observer cannot be admitted.
704    fn with_exact_fallback<T, F>(
705        &self,
706        binding: &SourceBinding,
707        control: &IndexWorkControl,
708        mut query: F,
709    ) -> Result<VerifiedReadOutcome<T>, CliError>
710    where
711        F: FnMut(&AtlasStore, VerifiedReadStamp) -> Result<T, CliError>,
712    {
713        let started = Instant::now();
714        let mut work = VerifiedReadWork::default();
715        for attempt in 0..VERIFIED_READ_ATTEMPTS {
716            let exact = open_exact_fresh_atlas_store_for_project_controlled(
717                &binding.database,
718                &binding.root,
719                binding.config.as_deref(),
720                control,
721            )?;
722            work.add_exact(exact.work);
723            let publication = exact
724                .store
725                .index_publication()?
726                .ok_or_else(|| source_changed_during_derivation(&binding.root, "."))?;
727            let captured = exact.store.captured_project_binding()?;
728            let stamp = VerifiedReadStamp {
729                process_nonce: self.process_nonce,
730                epoch: 0,
731                generation: publication.generation,
732                project_instance_id: captured.project_instance_id,
733            };
734            let value = query(&exact.store, stamp.clone())?;
735            exact.store.finish_index_read_snapshot()?;
736
737            let post = open_exact_fresh_atlas_store_for_project_controlled(
738                &binding.database,
739                &binding.root,
740                binding.config.as_deref(),
741                control,
742            )?;
743            work.add_exact(post.work);
744            let post_publication = post.store.index_publication()?;
745            let post_binding = post.store.captured_project_binding()?;
746            post.store.finish_index_read_snapshot()?;
747            if post_publication.is_some_and(|candidate| {
748                candidate.generation == stamp.generation
749                    && post_binding.project_instance_id == stamp.project_instance_id
750            }) {
751                work.elapsed = started.elapsed();
752                return Ok(VerifiedReadOutcome { value, stamp, work });
753            }
754            if attempt + 1 < VERIFIED_READ_ATTEMPTS {
755                work.retries = work.retries.saturating_add(1);
756            }
757        }
758        Err(source_changed_during_derivation(&binding.root, "."))
759    }
760}
761
762/// Convert native watcher callbacks into bounded ingress and continuity state.
763fn watcher_callback(
764    sender: SyncSender<Event>,
765    ingress_sequence: Arc<AtomicU64>,
766    continuity_lost: Arc<AtomicBool>,
767) -> impl FnMut(notify::Result<Event>) + Send + 'static {
768    move |result| match result {
769        Ok(event) if matches!(event.kind, EventKind::Access(_)) => {}
770        Ok(event) => {
771            ingress_sequence.fetch_add(1, Ordering::AcqRel);
772            if event.need_rescan() {
773                continuity_lost.store(true, Ordering::Release);
774            }
775            match sender.try_send(event) {
776                Ok(()) => {}
777                Err(TrySendError::Full(_event) | TrySendError::Disconnected(_event)) => {
778                    continuity_lost.store(true, Ordering::Release);
779                }
780            }
781        }
782        Err(_source) => {
783            ingress_sequence.fetch_add(1, Ordering::AcqRel);
784            continuity_lost.store(true, Ordering::Release);
785        }
786    }
787}
788
789/// Hash the bounded external inputs that may change the scanner's source selection.
790fn source_policy_witness(
791    plan: &ScanRuntimePlan,
792    control: &IndexWorkControl,
793) -> Result<String, CliError> {
794    let mut hasher = Hasher::new();
795    hash_field(
796        &mut hasher,
797        "contract",
798        plan.publication_contract_fingerprint().as_bytes(),
799    );
800    hash_field(&mut hasher, "root", plan.root.to_string_lossy().as_bytes());
801    for path in source_policy_paths(plan, control)? {
802        control.check(IndexWorkStage::Publication)?;
803        hash_field(&mut hasher, "path", path.to_string_lossy().as_bytes());
804        match path.symlink_metadata() {
805            Ok(metadata) if metadata.is_file() => {
806                let file = File::open(&path).map_err(|source| CliError::Io {
807                    path: path.clone(),
808                    source,
809                })?;
810                hash_policy_file(&mut hasher, path.as_path(), file, control)?;
811            }
812            Ok(metadata) if metadata.is_dir() => {
813                hash_field(&mut hasher, "state", b"directory");
814            }
815            Ok(metadata) if metadata.file_type().is_symlink() => {
816                hash_field(&mut hasher, "state", b"symlink");
817            }
818            Ok(_metadata) => {
819                hash_field(&mut hasher, "state", b"other");
820            }
821            Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
822                hash_field(&mut hasher, "state", b"absent");
823            }
824            Err(source) => {
825                return Err(CliError::Io { path, source });
826            }
827        }
828    }
829    Ok(hasher.finalize().to_hex().to_string())
830}
831
832/// Collect every scanner policy input whose state contributes to source selection.
833fn source_policy_paths(
834    plan: &ScanRuntimePlan,
835    control: &IndexWorkControl,
836) -> Result<Vec<PathBuf>, CliError> {
837    let mut paths = projectatlas_fs::source_selection_policy_paths_controlled(&plan.root, control)
838        .map_err(|source| source_inspection_error(&plan.root, source))?;
839    if let Some(config) = plan.selected_config_path.as_ref() {
840        paths.push(config.clone());
841    } else {
842        paths.push(plan.root.join(".projectatlas").join("config.toml"));
843        paths.push(plan.root.join("projectatlas.toml"));
844    }
845    paths.sort();
846    paths.dedup();
847    Ok(paths)
848}
849
850/// Hash one bounded policy file with cancellation checks between chunks.
851fn hash_policy_file(
852    hasher: &mut Hasher,
853    path: &Path,
854    file: File,
855    control: &IndexWorkControl,
856) -> Result<(), CliError> {
857    let mut reader = file.take(MAX_POLICY_INPUT_BYTES.saturating_add(1));
858    let mut buffer = [0_u8; 8_192];
859    let mut observed = 0_u64;
860    loop {
861        control.check(IndexWorkStage::Publication)?;
862        let read = read_policy_chunk(&mut reader, &mut buffer, path)?;
863        if read == 0 {
864            break;
865        }
866        observed = observed.saturating_add(u64::try_from(read).unwrap_or(u64::MAX));
867        if observed > MAX_POLICY_INPUT_BYTES {
868            return Err(CliError::InvalidInput(format!(
869                "source-selection policy input '{}' exceeds the {} byte limit",
870                path.display(),
871                MAX_POLICY_INPUT_BYTES
872            )));
873        }
874        hasher.update(&buffer[..read]);
875    }
876    hash_field(hasher, "state", b"present");
877    Ok(())
878}
879
880/// Read one policy chunk while retaining the file path in any I/O error.
881fn read_policy_chunk(
882    reader: &mut Take<File>,
883    buffer: &mut [u8],
884    path: &Path,
885) -> Result<usize, CliError> {
886    reader.read(buffer).map_err(|source| CliError::Io {
887        path: path.to_path_buf(),
888        source,
889    })
890}
891
892/// Append one domain-separated field to a policy witness.
893fn hash_field(hasher: &mut Hasher, name: &str, value: &[u8]) {
894    hasher.update(name.as_bytes());
895    hasher.update(&[0]);
896    hasher.update(value);
897    hasher.update(&[0xff]);
898}
899
900/// Resolve a possibly relative path against the canonical source root.
901fn absolute_path_from(root: &Path, path: &Path) -> PathBuf {
902    if path.is_absolute() {
903        path.to_path_buf()
904    } else {
905        root.join(path)
906    }
907}
908
909/// Generate one process identity, with a deterministic local entropy fallback.
910fn process_nonce() -> [u8; 16] {
911    let mut nonce = [0_u8; 16];
912    if getrandom::fill(&mut nonce).is_ok() {
913        return nonce;
914    }
915    let mut hasher = Hasher::new();
916    hasher.update(&std::process::id().to_le_bytes());
917    let time = SystemTime::now()
918        .duration_since(UNIX_EPOCH)
919        .map_or(0, |duration| duration.as_nanos());
920    hasher.update(&time.to_le_bytes());
921    nonce.copy_from_slice(&hasher.finalize().as_bytes()[..16]);
922    nonce
923}
924
925/// Convert watcher startup failure into a root-scoped observer diagnostic.
926fn observer_error(root: &Path, source: &notify::Error) -> CliError {
927    CliError::InvalidInput(format!(
928        "source observation is unavailable for '{}': {source}",
929        root.display()
930    ))
931}
932
933/// Build a root-scoped diagnostic for poisoned observer state.
934fn lock_error(root: &Path, owner: &str) -> CliError {
935    CliError::InvalidInput(format!(
936        "{owner} lock is unavailable for project root '{}'",
937        root.display()
938    ))
939}
940
941#[cfg(test)]
942mod tests {
943    use super::*;
944    use notify::event::{ModifyKind, RenameMode};
945    use notify::{EventKind, event::AccessKind};
946    use projectatlas_core::IndexCancellation;
947    use std::error::Error;
948
949    /// Create and publish a minimal indexed repository for observer tests.
950    fn indexed_project(root: &Path) -> Result<(PathBuf, PathBuf), Box<dyn Error>> {
951        fs::create_dir_all(root.join(".projectatlas"))?;
952        let source = root.join("source.rs");
953        fs::write(&source, "fn original() {}\n")?;
954        let database = root.join(".projectatlas").join("projectatlas.db");
955        let mut store = super::super::open_atlas_store_for_project(&database, root)?;
956        let plan = ScanRuntimePlan::for_path(None, root, None)?;
957        super::super::run_scan_pipeline(
958            &mut store,
959            &plan,
960            &super::super::SymbolBuildOptions::new(
961                super::super::MAX_SYMBOL_FILE_BYTES,
962                Some(1),
963                None,
964            ),
965        )?;
966        drop(store);
967        Ok((database, source))
968    }
969
970    /// Build a bounded non-cancelled control for observer tests.
971    fn test_control() -> IndexWorkControl {
972        IndexWorkControl::new(IndexCancellation::new(), Some(Duration::from_secs(30)))
973    }
974
975    /// Return a fallible test failure without panicking inside result-returning tests.
976    fn require(condition: bool, message: &str) -> Result<(), Box<dyn Error>> {
977        if condition {
978            Ok(())
979        } else {
980            Err(std::io::Error::other(message).into())
981        }
982    }
983
984    #[test]
985    fn watcher_callback_is_bounded_and_marks_overflow_and_rescan() {
986        let (sender, receiver) = sync_channel(1);
987        let sequence = Arc::new(AtomicU64::new(0));
988        let lost = Arc::new(AtomicBool::new(false));
989        let mut callback = watcher_callback(sender, Arc::clone(&sequence), Arc::clone(&lost));
990        callback(Ok(Event::new(EventKind::Modify(ModifyKind::Name(
991            RenameMode::Any,
992        )))));
993        callback(Ok(Event::new(EventKind::Modify(ModifyKind::Any))));
994
995        assert_eq!(sequence.load(Ordering::Acquire), 2);
996        assert!(lost.load(Ordering::Acquire));
997        assert!(receiver.try_recv().is_ok());
998    }
999
1000    #[test]
1001    fn watcher_callback_ignores_access_events_without_advancing_epoch() {
1002        let (sender, _receiver) = sync_channel(1);
1003        let sequence = Arc::new(AtomicU64::new(0));
1004        let lost = Arc::new(AtomicBool::new(false));
1005        let mut callback = watcher_callback(sender, Arc::clone(&sequence), Arc::clone(&lost));
1006        callback(Ok(Event::new(EventKind::Access(AccessKind::Any))));
1007
1008        assert_eq!(sequence.load(Ordering::Acquire), 0);
1009        assert!(!lost.load(Ordering::Acquire));
1010    }
1011
1012    #[test]
1013    fn verified_epoch_avoids_repeat_tree_and_node_table_work() -> Result<(), Box<dyn Error>> {
1014        let temp = tempfile::tempdir()?;
1015        let (database, _source) = indexed_project(temp.path())?;
1016        let registry = SourceObservationRegistry::default();
1017
1018        let first = registry.with_verified_read(
1019            &database,
1020            temp.path(),
1021            None,
1022            &test_control(),
1023            |store, stamp| Ok((store.overview()?, stamp)),
1024        )?;
1025        require(
1026            (1..=u64::try_from(VERIFIED_READ_ATTEMPTS)?).contains(&first.work.exact_verifications),
1027            "initial read did not perform a bounded exact verification",
1028        )?;
1029        require(
1030            first.work.filesystem_entries > 0,
1031            "initial read did not inspect filesystem entries",
1032        )?;
1033        require(
1034            first.work.decoded_nodes > 0,
1035            "initial read did not decode indexed nodes",
1036        )?;
1037
1038        let second = registry.with_verified_read(
1039            &database,
1040            temp.path(),
1041            None,
1042            &test_control(),
1043            |store, stamp| Ok((store.overview()?, stamp)),
1044        )?;
1045        require(
1046            second.work.exact_verifications == 0,
1047            "warm read unexpectedly repeated exact verification",
1048        )?;
1049        require(
1050            second.work.filesystem_entries == 0,
1051            "warm read unexpectedly inspected filesystem entries",
1052        )?;
1053        require(
1054            second.work.filesystem_bytes == 0,
1055            "warm read unexpectedly hashed source bytes",
1056        )?;
1057        require(
1058            second.work.decoded_nodes == 0,
1059            "warm read unexpectedly decoded the full node table",
1060        )?;
1061        require(
1062            second.work.sqlite_read_statements == 1,
1063            "warm freshness check used an unexpected SQLite statement count",
1064        )?;
1065        require(
1066            first.stamp == second.stamp,
1067            "warm read did not reuse the verified epoch",
1068        )?;
1069        Ok(())
1070    }
1071
1072    #[test]
1073    fn ignore_policy_changes_invalidate_the_epoch_and_refresh_source_truth()
1074    -> Result<(), Box<dyn Error>> {
1075        let temp = tempfile::tempdir()?;
1076        let (database, _source) = indexed_project(temp.path())?;
1077        let registry = SourceObservationRegistry::default();
1078        let first = registry.with_verified_read(
1079            &database,
1080            temp.path(),
1081            None,
1082            &test_control(),
1083            |store, _stamp| Ok(store.load_node_by_path("source.rs")?.is_some()),
1084        )?;
1085        require(first.value, "initial indexed source was missing")?;
1086
1087        fs::write(temp.path().join(".ignore"), "source.rs\n")?;
1088        let ignored = registry.with_verified_read(
1089            &database,
1090            temp.path(),
1091            None,
1092            &test_control(),
1093            |store, _stamp| Ok(store.load_node_by_path("source.rs")?.is_some()),
1094        );
1095
1096        require(
1097            matches!(ignored, Err(CliError::RefreshRequired(_))),
1098            "ignore policy change did not require refresh",
1099        )?;
1100        let binding = SourceBinding::new(&database, temp.path(), None)?;
1101        let entry = registry
1102            .entries
1103            .lock()
1104            .map_err(|_poisoned| std::io::Error::other("registry lock poisoned"))?
1105            .get(&binding)
1106            .cloned()
1107            .ok_or_else(|| std::io::Error::other("observer entry missing"))?;
1108        require(
1109            entry.current_epoch()?.is_none(),
1110            "ignore policy change left a verified epoch installed",
1111        )?;
1112        Ok(())
1113    }
1114
1115    #[test]
1116    fn git_exclude_changes_are_witnessed_with_a_git_directory() -> Result<(), Box<dyn Error>> {
1117        let temp = tempfile::tempdir()?;
1118        let (database, _source) = indexed_project(temp.path())?;
1119        let git_info = temp.path().join(".git").join("info");
1120        fs::create_dir_all(&git_info)?;
1121        fs::write(git_info.join("exclude"), "")?;
1122        let registry = SourceObservationRegistry::default();
1123        let first = registry.with_verified_read(
1124            &database,
1125            temp.path(),
1126            None,
1127            &test_control(),
1128            |store, _stamp| Ok(store.load_node_by_path("source.rs")?.is_some()),
1129        )?;
1130        require(first.value, "initial indexed source was missing")?;
1131
1132        fs::write(git_info.join("exclude"), "source.rs\n")?;
1133        let ignored = registry.with_verified_read(
1134            &database,
1135            temp.path(),
1136            None,
1137            &test_control(),
1138            |store, _stamp| Ok(store.load_node_by_path("source.rs")?.is_some()),
1139        );
1140
1141        require(
1142            matches!(ignored, Err(CliError::RefreshRequired(_))),
1143            "Git exclude change did not require refresh",
1144        )?;
1145        let binding = SourceBinding::new(&database, temp.path(), None)?;
1146        let entry = registry
1147            .entries
1148            .lock()
1149            .map_err(|_poisoned| std::io::Error::other("registry lock poisoned"))?
1150            .get(&binding)
1151            .cloned()
1152            .ok_or_else(|| std::io::Error::other("observer entry missing"))?;
1153        require(
1154            entry.current_epoch()?.is_none(),
1155            "Git exclude change left a verified epoch installed",
1156        )?;
1157        Ok(())
1158    }
1159
1160    #[test]
1161    fn mid_query_edit_discards_provisional_result_and_reconciles() -> Result<(), Box<dyn Error>> {
1162        let temp = tempfile::tempdir()?;
1163        let (database, source) = indexed_project(temp.path())?;
1164        let registry = SourceObservationRegistry::default();
1165        let _initial = registry.with_verified_read(
1166            &database,
1167            temp.path(),
1168            None,
1169            &test_control(),
1170            |store, _stamp| Ok(store.overview()?),
1171        )?;
1172        let binding = SourceBinding::new(&database, temp.path(), None)?;
1173        let entry = registry
1174            .entries
1175            .lock()
1176            .map_err(|_poisoned| std::io::Error::other("registry lock poisoned"))?
1177            .get(&binding)
1178            .cloned()
1179            .ok_or_else(|| std::io::Error::other("observer entry missing"))?;
1180        let mut calls = 0_u64;
1181        let revised = "fn revised() {}\n";
1182
1183        let outcome = registry.with_verified_read(
1184            &database,
1185            temp.path(),
1186            None,
1187            &test_control(),
1188            |store, _stamp| {
1189                calls = calls.saturating_add(1);
1190                let hash = store
1191                    .load_node_by_path("source.rs")?
1192                    .and_then(|node| node.node.content_hash)
1193                    .ok_or_else(|| CliError::InvalidInput("source hash missing".to_string()))?;
1194                if calls == 1 {
1195                    fs::write(&source, revised).map_err(|source_error| CliError::Io {
1196                        path: source.clone(),
1197                        source: source_error,
1198                    })?;
1199                    entry.ingress_sequence.fetch_add(1, Ordering::AcqRel);
1200                    entry
1201                        .test_sender
1202                        .try_send(
1203                            Event::new(EventKind::Modify(ModifyKind::Any)).add_path(source.clone()),
1204                        )
1205                        .map_err(|source_error| {
1206                            CliError::InvalidInput(format!(
1207                                "deterministic observation injection failed: {source_error}"
1208                            ))
1209                        })?;
1210                }
1211                Ok(hash)
1212            },
1213        )?;
1214
1215        require(calls >= 2, "mid-query edit did not retry the query")?;
1216        require(
1217            outcome.work.retries >= 1,
1218            "mid-query edit was not reported as a retry",
1219        )?;
1220        require(
1221            outcome.work.exact_verifications >= 1,
1222            "mid-query edit did not trigger exact verification",
1223        )?;
1224        require(
1225            outcome.value == blake3::hash(revised.as_bytes()).to_hex().to_string(),
1226            "accepted result did not reflect the revised source",
1227        )?;
1228        Ok(())
1229    }
1230
1231    #[test]
1232    fn cancellation_and_continuity_loss_never_certify_partial_truth() -> Result<(), Box<dyn Error>>
1233    {
1234        let temp = tempfile::tempdir()?;
1235        let (database, _source) = indexed_project(temp.path())?;
1236        let registry = SourceObservationRegistry::default();
1237        let initial = registry.with_verified_read(
1238            &database,
1239            temp.path(),
1240            None,
1241            &test_control(),
1242            |store, _stamp| Ok(store.overview()?),
1243        )?;
1244        require(
1245            initial.work.exact_verifications >= 1,
1246            "initial read did not establish exact truth",
1247        )?;
1248        let binding = SourceBinding::new(&database, temp.path(), None)?;
1249        let entry = registry
1250            .entries
1251            .lock()
1252            .map_err(|_poisoned| std::io::Error::other("registry lock poisoned"))?
1253            .get(&binding)
1254            .cloned()
1255            .ok_or_else(|| std::io::Error::other("observer entry missing"))?;
1256
1257        let cancellation = IndexCancellation::new();
1258        cancellation.cancel();
1259        let canceled_control = IndexWorkControl::new(cancellation, None);
1260        let canceled = registry.with_verified_read(
1261            &database,
1262            temp.path(),
1263            None,
1264            &canceled_control,
1265            |store, _stamp| Ok(store.overview()?),
1266        );
1267        require(
1268            matches!(canceled, Err(CliError::IndexWork(_))),
1269            "cancelled observer read did not return index-work cancellation",
1270        )?;
1271        require(
1272            entry.current_epoch()?.is_none(),
1273            "cancellation left the previous verified epoch installed",
1274        )?;
1275
1276        let recovered = registry.with_verified_read(
1277            &database,
1278            temp.path(),
1279            None,
1280            &test_control(),
1281            |store, _stamp| Ok(store.overview()?),
1282        )?;
1283        require(
1284            recovered.work.exact_verifications >= 1,
1285            "read after cancellation did not establish exact truth",
1286        )?;
1287        entry.continuity_lost.store(true, Ordering::Release);
1288        let reverified = registry.with_verified_read(
1289            &database,
1290            temp.path(),
1291            None,
1292            &test_control(),
1293            |store, _stamp| Ok(store.overview()?),
1294        )?;
1295        require(
1296            reverified.work.exact_verifications >= 1,
1297            "continuity loss did not trigger exact verification",
1298        )?;
1299        Ok(())
1300    }
1301}