Skip to main content

fdu_core/
engine_contract.rs

1//! The observation and commit contract shared by every producer and consumer.
2//!
3//! The walker, revalidator, and watch layer produce [`Observation`] batches. The index
4//! arbitrates their preconditions, removes no-ops, and creates a [`Commit`] only after
5//! every effective fact, reducer, and state change is known. The journal retains those
6//! commits as the only source of change truth.
7//!
8//! Three properties are load-bearing, and the rest of the crate depends on them:
9//!
10//! - **Observations carry truth, not hints.** A producer stats before it emits. Filesystem
11//!   events on most platforms carry no metadata, so a raw event is never a delta.
12//! - **Conditional observations cannot overwrite newer state.** Revalidation attaches
13//!   state plus generation and revision guards from the start of its check. If another
14//!   producer commits a conflicting change first, arbitration rejects the delayed
15//!   observation.
16//! - **Commits contain changes, not attempts.** No-ops and stale observations do not
17//!   advance the public clock or consume journal space.
18
19use std::path::{Path, PathBuf};
20
21use crate::stored_state::EntryScope;
22
23/// A monotonic logical clock, in the spirit of Watchman's clockspec but process-local.
24///
25/// Every [`Commit`] is stamped, so a consumer can ask "what changed since C?"
26/// rather than having to hold a live subscription.
27#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Default, Hash)]
28pub struct Clock(pub u64);
29
30impl Clock {
31    /// The clock before anything has been applied.
32    pub const ZERO: Clock = Clock(0);
33
34    /// The next clock value, or `None` when the process-local clock is exhausted.
35    #[inline]
36    #[must_use]
37    pub const fn checked_next(self) -> Option<Clock> {
38        match self.0.checked_add(1) {
39            Some(value) => Some(Clock(value)),
40            None => None,
41        }
42    }
43}
44
45/// What kind of filesystem entry a record describes.
46///
47/// The numeric values reach the snapshot format, so they are pinned: never renumber a
48/// variant, only append.
49#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
50#[repr(u8)]
51pub enum EntryKind {
52    /// Regular file.
53    File = 0,
54    /// Directory.
55    Dir = 1,
56    /// Symbolic link retained without following it.
57    Symlink = 2,
58    /// Other filesystem object such as a socket or device.
59    Other = 3,
60}
61
62impl EntryKind {
63    /// Recover a kind from its pinned on-disk value.
64    pub const fn from_u8(raw: u8) -> Option<Self> {
65        match raw {
66            0 => Some(Self::File),
67            1 => Some(Self::Dir),
68            2 => Some(Self::Symlink),
69            3 => Some(Self::Other),
70            _ => None,
71        }
72    }
73
74    #[inline]
75    /// Whether this kind is a directory.
76    pub const fn is_dir(self) -> bool {
77        matches!(self, Self::Dir)
78    }
79}
80
81/// The stat fields an entry contributes to roll-ups, plus the ones that identify it.
82#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Hash)]
83pub struct Attrs {
84    /// Apparent size in bytes.
85    pub size: u64,
86    /// Allocated size in bytes (block count x 512 on Unix). Falls back to `size` on
87    /// platforms that do not report block counts.
88    pub allocated: u64,
89    /// Modification time, nanoseconds since the Unix epoch.
90    pub mtime_ns: i64,
91    /// Inode change time, nanoseconds since the Unix epoch. Zero where unavailable.
92    pub ctime_ns: i64,
93    /// Inode number. Zero where unavailable.
94    pub inode: u64,
95    /// Device number. Zero where unavailable.
96    pub dev: u64,
97}
98
99impl Attrs {
100    /// The change-detection fingerprint for these attributes.
101    #[inline]
102    pub const fn fingerprint(&self) -> Fingerprint {
103        Fingerprint {
104            size: self.size,
105            mtime_ns: self.mtime_ns,
106            ctime_ns: self.ctime_ns,
107            inode: self.inode,
108            dev: self.dev,
109        }
110    }
111}
112
113/// The fingerprint used to decide whether an entry really changed.
114///
115/// Size and mtime alone are not enough. mtime is user-settable, and some applications
116/// roll it back after modifying a file; ctime is kernel-controlled and cannot be set
117/// directly. Borg keys on ctime/size/inode and restic requires both mtime and ctime to
118/// match, and this engine follows them: an index that keys purely on mtime is trusting a
119/// value userspace can forge.
120#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Hash)]
121pub struct Fingerprint {
122    /// Apparent file size.
123    pub size: u64,
124    /// Modification time in nanoseconds since the Unix epoch.
125    pub mtime_ns: i64,
126    /// Inode-change time in nanoseconds since the Unix epoch.
127    pub ctime_ns: i64,
128    /// Platform inode or file identity component.
129    pub inode: u64,
130    /// Platform device or volume identity component.
131    pub dev: u64,
132}
133
134/// Semantic inputs that decide which entries and derived values belong in an index.
135///
136/// Operational settings such as producer batch size are intentionally absent. A
137/// snapshot may be reused only when this value matches exactly.
138#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
139pub struct ScanScope {
140    /// Maximum retained relative depth, or unlimited when absent.
141    pub max_depth: Option<usize>,
142    /// Whether directory symlinks are followed.
143    pub follow_symlinks: bool,
144    /// Whether traversal stays on the root filesystem.
145    pub one_filesystem: bool,
146    /// Identity of leading-dot component admission and its exact-name allowlist.
147    pub hidden_fingerprint: u64,
148    /// Whether filesystem objects outside files, directories, and symlinks are excluded.
149    pub exclude_special: bool,
150    /// Ignored population retained by this scan.
151    pub population: crate::query::IgnoredEntries,
152    /// Identity of the compiled ignore policy.
153    pub ignore_rules_fingerprint: u64,
154    /// Identity of the compiled type-classification policy.
155    pub type_rules_fingerprint: u64,
156    /// Identity of the enabled reducer set.
157    pub reducers_fingerprint: u64,
158}
159
160/// Answer-semantics identity derived from validated classification and reducer rules.
161#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
162pub struct SemanticIdentity {
163    /// Identity of the compiled ignore policy.
164    pub ignore_rules_fingerprint: u64,
165    /// Identity of the compiled type-classification policy.
166    pub type_rules_fingerprint: u64,
167    /// Identity of the enabled reducer set.
168    pub reducers_fingerprint: u64,
169}
170
171/// Opaque identity of one opened-root lifetime.
172///
173/// This process-local value prevents a cursor or expected version from one open from
174/// being accepted by another whose sequence happens to match. It is not a credential
175/// and is never persisted.
176#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
177pub struct SessionId(pub(crate) u64);
178
179impl SessionId {
180    /// Return the process-local opaque value for a language or wire adapter.
181    ///
182    /// This value is an identity, not a credential. Consumers should preserve it exactly
183    /// and must not infer ordering or lifetime from it.
184    pub const fn opaque(self) -> u64 {
185        self.0
186    }
187
188    /// Recover an opaque session identity previously returned by [`Self::opaque`].
189    ///
190    /// Zero is reserved and never identifies a live opened root.
191    pub const fn from_opaque(value: u64) -> Option<Self> {
192        if value == 0 { None } else { Some(Self(value)) }
193    }
194}
195
196/// Identity and exact sequence of one committed opened-root state.
197#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
198pub struct EngineVersion {
199    /// Live owner that minted this version.
200    pub session: SessionId,
201    /// Exact committed index sequence observed by the read.
202    pub sequence: Clock,
203    /// Filesystem-fact identity bound when the root was opened.
204    pub scope: EntryScope,
205    /// Classification and reducer identity bound when the root was opened.
206    pub semantics: SemanticIdentity,
207}
208
209impl ScanScope {
210    /// Whether an index of this scope observed `.gitignore` control state.
211    ///
212    /// False when the scan ran with [`ScanConfig::read_controls`](crate::ScanConfig) off:
213    /// no control file was read and no entry was classified. Such an index cannot say
214    /// whether an entry is ignored, so
215    /// [`Index::is_ignored`](crate::Index::is_ignored),
216    /// [`Index::controls`](crate::Index::controls), and the partition accessors
217    /// ([`Index::partition_total`](crate::Index::partition_total) and its per-directory
218    /// forms) refuse with [`Error::ControlStateNotObserved`] rather than answer "not
219    /// ignored" for everything, and a shared
220    /// [`ChildSnapshot`](crate::ChildSnapshot) carries no ignore bit or partitions.
221    ///
222    /// The ignore-rules fingerprint is derived from the scope's
223    /// [`ControlTierIdentity`](crate::ControlTierIdentity), which reserves zero for a tier
224    /// that observed nothing; a caller holding the identity asks it directly.
225    pub const fn observes_controls(self) -> bool {
226        self.ignore_rules_fingerprint != 0
227    }
228
229    /// The part of this validated scope that determines retained filesystem facts.
230    pub const fn entry_scope(self) -> EntryScope {
231        EntryScope {
232            max_depth: self.max_depth,
233            follow_symlinks: self.follow_symlinks,
234            one_filesystem: self.one_filesystem,
235            hidden_fingerprint: self.hidden_fingerprint,
236            exclude_special: self.exclude_special,
237            population: self.population,
238            control_fingerprint: if matches!(self.population, crate::query::IgnoredEntries::Include)
239            {
240                0
241            } else {
242                self.ignore_rules_fingerprint
243            },
244        }
245    }
246
247    /// The part of this validated scope that determines classifications and roll-ups.
248    pub const fn semantic_identity(self) -> SemanticIdentity {
249        SemanticIdentity {
250            ignore_rules_fingerprint: self.ignore_rules_fingerprint,
251            type_rules_fingerprint: self.type_rules_fingerprint,
252            reducers_fingerprint: self.reducers_fingerprint,
253        }
254    }
255}
256
257/// Where a value came from, so a consumer can trade speed for certainty knowingly.
258///
259/// Ordered weakest-last: comparing two sources yields the one to trust less, which is
260/// what a roll-up needs when combining a subtree.
261#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash, Default)]
262pub enum Source {
263    /// Observed from the filesystem by this process.
264    #[default]
265    Scanned,
266    /// Loaded from a snapshot and re-verified by a fresh stat this session.
267    Revalidated,
268    /// Loaded from a snapshot; a change journal reported nothing touching this subtree
269    /// since the cursor, and nothing has re-checked it.
270    ///
271    /// Named for what actually happened — a journal *scoped* the work — rather than for
272    /// what a reader might wish it meant. Nothing here was confirmed against the
273    /// filesystem: a scoped revalidation stats the paths the journal names and does not
274    /// stat the rest, so this value rests on the journal having been complete.
275    ///
276    /// It is deliberately weaker than [`Self::Revalidated`] because that assumption is
277    /// known to fail. macOS `FSEvents` will report `HistoryDone` after silently dropping
278    /// history, with no degradation flag, which means a journal answer can be wrong
279    /// without announcing it. Journal-assisted revalidation therefore bounds exposure
280    /// with a maximum age and a periodic full sweep; those are risk controls, not
281    /// proofs, and they do not make any individual answer here verified.
282    JournalScoped,
283    /// Loaded from a snapshot and not re-checked since.
284    Cached,
285}
286
287/// Whether a value covers everything beneath its path.
288///
289/// This is the **structural coverage** axis, and only that. How far to *trust* what is
290/// covered is [`Source`], and the two are deliberately independent: a cached value
291/// covers the whole subtree but may be out of date, while a half-built one covers less
292/// than the subtree but every byte in it was just observed.
293///
294/// An enum rather than a boolean because coverage will gain more ways to be incomplete
295/// — truncated by a cap, cancelled, failed — and ordered worst-last so roll-ups combine
296/// by taking the maximum. Those variants are not here yet; see the progressive-results
297/// plan for the lifecycle they belong to.
298#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash, Default)]
299#[non_exhaustive]
300pub enum Status {
301    /// The value accounts for everything beneath this path that is in scope.
302    #[default]
303    Complete,
304    /// The value does not account for everything beneath this path.
305    ///
306    /// **Not a promise of monotonicity.** A value being built by an additive walk only
307    /// grows, but one left incomplete by reconciliation errors can move either way once
308    /// the missing part is read. Monotonicity is a property of the *producer* that is
309    /// running, not of this status, and a consumer that needs it must know a walk is in
310    /// progress rather than infer it from here.
311    Partial,
312}
313
314/// Everything a consumer needs to decide how far to trust one value.
315///
316/// A *view* type, built on demand rather than stored: the index keeps one [`Source`]
317/// byte per entry and its observation timestamps once, because on a tree of millions
318/// of entries the timestamps are shared by nearly all of them and a per-entry struct
319/// would cost more memory than the information is worth.
320///
321/// The three facts are independent on purpose, because they answer different
322/// questions. [`Status`] asks how much of the subtree the number covers;
323/// [`Source`] asks how far to trust what it covers; `observed_at_ns` asks when.
324/// A [`Status::Complete`] but [`Source::Cached`] value is a point estimate that may
325/// move either way and reads as "about 3.2 GB, as of two minutes ago", while a
326/// [`Status::Partial`] value is missing part of its subtree and reads as "3.2 GB so
327/// far". Collapsing them would make a shrinking number look like a defect.
328///
329/// Note that "3.2 GB so far" is only a *lower bound that grows* while an additive walk
330/// is running. See [`Status::Partial`]: the status records coverage, not direction.
331#[derive(Clone, Copy, PartialEq, Eq, Debug)]
332pub struct Provenance {
333    /// Where the value came from.
334    pub source: Source,
335    /// When the underlying filesystem observation was made, in nanoseconds since the
336    /// Unix epoch. For [`Source::Cached`] this is when the snapshot captured it — the
337    /// "as of" a consumer displays. Zero when unknown.
338    pub observed_at_ns: i64,
339    /// How settled the value is.
340    pub status: Status,
341}
342
343impl Source {
344    /// Whether a value from this source was checked against the filesystem during
345    /// this session.
346    pub const fn is_verified(self) -> bool {
347        matches!(self, Self::Scanned | Self::Revalidated)
348    }
349}
350
351impl Provenance {
352    /// Freshly observed by this process, complete.
353    pub const fn scanned(observed_at_ns: i64) -> Self {
354        Self { source: Source::Scanned, observed_at_ns, status: Status::Complete }
355    }
356
357    /// Combine with another value's provenance, taking the less trustworthy of each
358    /// fact.
359    ///
360    /// This is what makes a directory only as trustworthy as its least trustworthy
361    /// descendant: the weakest source, the oldest observation, and the worst status.
362    ///
363    /// Every fact fails closed, including time: an unknown `observed_at_ns` is
364    /// absorbing rather than skipped, so a subtree with one contributor of unknown age
365    /// reports an unknown age instead of a precise time it cannot prove.
366    ///
367    /// There is deliberately **no identity element**. Because unknown is absorbing, it
368    /// cannot double as the seed of a fold, and a caller aggregating a possibly-empty
369    /// set must represent emptiness separately (`Option<Provenance>`) rather than
370    /// seeding with a zero timestamp — otherwise every roll-up would come out unknown.
371    #[must_use]
372    pub fn combine(self, other: Self) -> Self {
373        Self {
374            source: self.source.max(other.source),
375            observed_at_ns: match (self.observed_at_ns, other.observed_at_ns) {
376                // Unknown is contagious, not skipped. Zero means "we cannot say when",
377                // and the honest combination of a known time with an unknown one is
378                // still unknown: a parent that drops the unknown contributor would
379                // advertise a precise "as of" it cannot prove for the whole subtree.
380                // Unknown is not the identity for "oldest observation" — it is the
381                // absorbing element.
382                (0, _) | (_, 0) => 0,
383                (mine, other) => mine.min(other),
384            },
385            status: self.status.max(other.status),
386        }
387    }
388
389    /// Whether this value was checked against the filesystem during this session.
390    pub const fn is_verified(self) -> bool {
391        self.source.is_verified()
392    }
393}
394
395/// Trust state for an index or queried subtree.
396#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
397pub enum Freshness {
398    /// Every path in scope has been reconciled successfully.
399    Fresh,
400    /// A reconciliation pass is currently checking this scope.
401    Reconciling,
402    /// A producer reported lost precision and reconciliation has not completed.
403    Stale,
404    /// Reconciliation encountered errors, so some state is unknown.
405    Partial,
406}
407
408/// Current activity of one opened root.
409///
410/// Phase is deliberately independent of coverage and freshness. A stopped root may
411/// still serve a useful partial image, and a watching root may temporarily be stale.
412#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
413pub enum LifecyclePhase {
414    /// A cold walk is adding verified entries.
415    Discovering,
416    /// Explicit or gap-closing verification is in progress.
417    Reconciling,
418    /// The current retained image is available without a live observer.
419    Ready,
420    /// Native or polling observation is active.
421    Watching,
422    /// The owner will perform no more expanding work.
423    Stopped,
424    /// A terminal provider failure ended useful work.
425    Failed,
426}
427
428/// Why an opened root cannot claim complete structural coverage.
429#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
430pub enum CoverageReason {
431    /// Initial discovery has not yet finished.
432    Building,
433    /// A configured resource budget refused additional admissible work.
434    Budget,
435    /// The owner was cancelled before the operation completed.
436    Cancelled,
437    /// Part of the configured scope could not be read.
438    Inaccessible,
439    /// A terminal provider failure prevented completion.
440    Failed,
441}
442
443/// Structural coverage of one opened root.
444#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
445pub enum Coverage {
446    /// Every directory in scope has a complete child listing.
447    Complete,
448    /// Some in-scope absence remains unknowable for the stated reason.
449    Partial(CoverageReason),
450}
451
452/// Maximum issue details retained by one index image.
453pub const MAX_RETAINED_ISSUES: usize = 64;
454/// Maximum UTF-8 bytes retained in one rendered issue message.
455pub const MAX_ISSUE_MESSAGE_BYTES: usize = 512;
456/// Maximum native encoded bytes retained for one issue path.
457pub const MAX_ISSUE_PATH_BYTES: usize = 4_096;
458
459/// Stable category for one non-fatal condition or terminal provider failure.
460#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
461pub enum IssueKind {
462    /// The operating system refused access to a path.
463    Permission,
464    /// A path disappeared during verification.
465    Disappeared,
466    /// Filesystem metadata could not be interpreted.
467    InvalidMetadata,
468    /// A configured discovery resource bound refused work.
469    ResourceBudget,
470    /// The filesystem observer lost precision and required verified recovery.
471    ObservationGap,
472    /// The provider failed for another reason.
473    ProviderFailure,
474}
475
476/// Bounded diagnostic evidence retained with an index state.
477#[derive(Clone, PartialEq, Eq, Debug, Hash)]
478pub struct Issue {
479    /// Machine-readable category.
480    pub kind: IssueKind,
481    /// Affected path when it fits the detail bound. Issues an opened root retains or
482    /// returns name it relative to the root, the form its reads use; a message may still
483    /// name the absolute path the operating system refused.
484    pub path: Option<PathBuf>,
485    /// Human-readable detail, truncated at a UTF-8 boundary when necessary.
486    pub message: String,
487    /// Operating-system error number when one was supplied.
488    pub os_error: Option<i32>,
489}
490
491impl Issue {
492    /// Convert one engine error without retaining unbounded rendered detail.
493    pub fn from_error(error: &Error) -> Self {
494        match error {
495            Error::Io { path, source } => Self::from_io(path, source),
496            other => Self {
497                kind: IssueKind::ProviderFailure,
498                path: None,
499                message: bounded_issue_message(other.to_string()),
500                os_error: None,
501            },
502        }
503    }
504
505    pub(crate) fn from_io(path: &Path, source: &std::io::Error) -> Self {
506        Self {
507            kind: match source.kind() {
508                std::io::ErrorKind::PermissionDenied => IssueKind::Permission,
509                std::io::ErrorKind::NotFound => IssueKind::Disappeared,
510                std::io::ErrorKind::InvalidData | std::io::ErrorKind::InvalidInput => {
511                    IssueKind::InvalidMetadata
512                }
513                _ => IssueKind::ProviderFailure,
514            },
515            path: bounded_issue_path(path),
516            message: bounded_issue_message(format!("I/O error at {}: {source}", path.display())),
517            os_error: source.raw_os_error(),
518        }
519    }
520
521    /// Convert one error met while reading an opened root, naming its path relative to it.
522    ///
523    /// Scan errors carry the absolute path the operating system refused, and the message
524    /// keeps it for whoever has to fix the permission. The `path` field is the root-relative
525    /// form every other opened-root issue uses, so two issues about one directory agree and
526    /// a consumer can match an issue to the path it reads.
527    pub(crate) fn from_error_under(root: &Path, error: &Error) -> Self {
528        let mut issue = Self::from_error(error);
529        if let Error::Io { path, .. } = error {
530            issue.relativize(root, path);
531        }
532        issue
533    }
534
535    /// [`Self::from_io`] for a path under an opened root, naming it relative to the root.
536    pub(crate) fn from_io_under(root: &Path, path: &Path, source: &std::io::Error) -> Self {
537        let mut issue = Self::from_io(path, source);
538        issue.relativize(root, path);
539        issue
540    }
541
542    fn relativize(&mut self, root: &Path, path: &Path) {
543        if let Ok(relative) = path.strip_prefix(root) {
544            self.path = bounded_issue_path(relative);
545        }
546    }
547
548    /// Describe the first file refused by an opened-root resource budget.
549    pub(crate) fn resource_budget(max_files: u64) -> Self {
550        Self {
551            kind: IssueKind::ResourceBudget,
552            path: None,
553            message: format!(
554                "verified work refused an admissible file after retaining {max_files}"
555            ),
556            os_error: None,
557        }
558    }
559
560    /// Describe observer loss without confusing it with consumer journal loss.
561    pub(crate) fn observation_gap(path: &Path, reason: InvalidateReason) -> Self {
562        Self {
563            kind: IssueKind::ObservationGap,
564            path: bounded_issue_path(path),
565            message: bounded_issue_message(format!(
566                "filesystem observation lost precision at {}: {reason:?}",
567                path.display()
568            )),
569            os_error: None,
570        }
571    }
572
573    /// Describe an operational provider failure when no structured OS error is available.
574    pub(crate) fn provider_failure(path: Option<&Path>, message: String) -> Self {
575        Self {
576            kind: IssueKind::ProviderFailure,
577            path: path.and_then(bounded_issue_path),
578            message: bounded_issue_message(message),
579            os_error: None,
580        }
581    }
582}
583
584fn bounded_issue_path(path: &Path) -> Option<PathBuf> {
585    (path.as_os_str().as_encoded_bytes().len() <= MAX_ISSUE_PATH_BYTES).then(|| path.to_path_buf())
586}
587
588fn bounded_issue_message(mut message: String) -> String {
589    if message.len() <= MAX_ISSUE_MESSAGE_BYTES {
590        return message;
591    }
592    let mut end = MAX_ISSUE_MESSAGE_BYTES;
593    while !message.is_char_boundary(end) {
594        end -= 1;
595    }
596    message.truncate(end);
597    message
598}
599
600/// Counts for the bounded issue details captured with a state.
601#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Hash)]
602pub struct IssueSummary {
603    /// Details retained and available to a coherent read.
604    pub retained: u64,
605    /// Additional details omitted after the bound was reached.
606    pub omitted: u64,
607}
608
609/// Committed, bounded discovery counters.
610#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Hash)]
611pub struct DiscoveryProgress {
612    /// Regular files retained by cold discovery.
613    pub files_retained: u64,
614    /// Directories whose complete in-scope child listing was committed: by discovery, or
615    /// by a later complete reconciliation that listed one the index did not yet hold as
616    /// complete, such as a directory created afterwards or one discovery could not read.
617    /// Each is one [`StateTransition::DirectoryComplete`].
618    pub directories_complete: u64,
619}
620
621/// Coherent public state captured at an index commit boundary.
622#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
623pub struct IndexState {
624    /// Current activity of the opened root.
625    pub phase: LifecyclePhase,
626    /// Whether the retained tree covers all configured scope.
627    pub coverage: Coverage,
628    /// How current the retained facts are believed to be.
629    pub freshness: Freshness,
630    /// Weakest source represented by this first implementation.
631    pub source: Source,
632    /// Stable counters advanced only by committed discovery and reconciliation work.
633    pub progress: DiscoveryProgress,
634    /// Bounded diagnostic evidence counts at this version.
635    pub issues: IssueSummary,
636}
637
638/// Maximum native projections accepted by one coherent read.
639pub const MAX_READ_PROJECTIONS: usize = 16;
640/// Maximum rows returned by one page projection.
641pub const MAX_PAGE_ROWS: usize = 4_096;
642/// Maximum deterministic work allowance accepted by one page projection.
643pub const MAX_PAGE_WORK: u64 = 1_000_000;
644/// Maximum report sections and reported omissions accepted in one opened read.
645pub const MAX_REPORT_VIEWS: usize = 16;
646/// Default cap for a selection count not backed by an exact maintained aggregate.
647pub const DEFAULT_COUNT_CAP: u64 = 10_000;
648/// Maximum caller-selected cap for an on-demand aggregate.
649pub const MAX_COUNT_CAP: u64 = 1_000_000;
650/// Maximum retained payload for one handle-local continuation record.
651///
652/// Together with the 128-record table bound, this caps retained continuation payload at
653/// eight MiB per opened root, excluding fixed map nodes and allocator bookkeeping.
654pub const MAX_CONTINUATION_RECORD_BYTES: usize = 64 * 1_024;
655
656/// Opaque identifier for resumable work retained by one opened root.
657#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
658pub struct ContinuationId {
659    pub(crate) session: SessionId,
660    pub(crate) ordinal: u64,
661}
662
663impl ContinuationId {
664    /// Return the opaque parts a language or wire adapter must round-trip.
665    pub const fn opaque_parts(self) -> (u64, u64) {
666        (self.session.opaque(), self.ordinal)
667    }
668
669    /// Recover an opaque continuation previously returned by [`Self::opaque_parts`].
670    ///
671    /// Both zero values are reserved and never identify live continuation state.
672    pub const fn from_opaque_parts(session: u64, ordinal: u64) -> Option<Self> {
673        match SessionId::from_opaque(session) {
674            Some(session) if ordinal != 0 => Some(Self { session, ordinal }),
675            _ => None,
676        }
677    }
678}
679
680/// Output and work bounds for one resumable page.
681#[derive(Clone, Copy, PartialEq, Eq, Debug)]
682pub struct PageRequest {
683    /// Maximum variable rows to return, excluding the projection's fixed envelope.
684    pub limit: usize,
685    /// Maximum retained-index rows to inspect.
686    pub max_work: u64,
687}
688
689/// A retained entry's canonical POSIX-relative name, in the form ordered pages use.
690///
691/// **This is not the filename.** It is a derived name, and it differs from the native one
692/// whenever a component holds bytes that are not valid UTF-8, or holds a literal `%`.
693/// Never open, stat, or compare a filesystem path against one of these:
694/// [`EntryValue::path`] is the identity, and this is the wire form the ordered
695/// projections are keyed and ordered by. The newtype exists so that mistake is a compile
696/// error rather than a convention, because a bare `String` here reads exactly like a path
697/// and every test tree an author thinks to write is pure UTF-8, so the substitution passes
698/// locally and fails on a real disk.
699///
700/// Every entry has one. A native path is not obliged to be UTF-8 — Unix filenames are
701/// arbitrary non-NUL bytes, Windows filenames may hold unpaired surrogates — so the two
702/// kinds of byte that cannot be carried are percent-escaped: those that do not decode,
703/// and `%` itself. Escaping `%` is what makes the mapping injective. A file named
704/// `caf%FF.txt` is valid UTF-8 and a file named `caf<0xFF>.txt` is not; escaping only the
705/// undecodable byte would give both the same wire name, which is the aliasing bug of
706/// lossy conversion in better clothes.
707///
708/// Nothing else is touched. This produces a JSON string, not a URL, so spaces, `#`, `?`
709/// and every non-ASCII scalar pass through: `café/naïve.txt` is unchanged.
710///
711/// Totality is why ordered pages and native roll-ups answer over one population, why a
712/// directory whose name has a stray byte still lists its children, and why a complete
713/// directory that does not hold a name can answer `absent` rather than `unknown`. The
714/// partial version that preceded it needed an omission count, bounded escaped examples,
715/// and a second completeness flag on [`TreePage`] to describe what it could not name; all
716/// three are gone.
717///
718/// Distinct from the unrelated structural question of whether a path is relative and
719/// never ascends. A path can satisfy either condition and fail the other.
720#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
721pub struct PortablePath(String);
722
723impl PortablePath {
724    /// Wrap an already-canonical portable string.
725    pub(crate) fn new(path: String) -> Self {
726        Self(path)
727    }
728
729    /// The canonical POSIX-relative form, for transport and ordering only.
730    pub fn as_str(&self) -> &str {
731        &self.0
732    }
733
734    /// Consume the wrapper, yielding the canonical POSIX-relative form.
735    pub fn into_string(self) -> String {
736        self.0
737    }
738
739    /// Heap payload retained when a continuation record owns this path.
740    ///
741    /// Named to match `Selection::retained_heap_bytes` and
742    /// `EntrySelection::retained_heap_bytes`, because all three feed one cap and a
743    /// component that accounts for itself differently is how a bound silently stops
744    /// holding.
745    pub(crate) fn retained_heap_bytes(&self) -> usize {
746        self.0.capacity()
747    }
748}
749
750impl std::borrow::Borrow<str> for PortablePath {
751    fn borrow(&self) -> &str {
752        &self.0
753    }
754}
755
756impl std::fmt::Display for PortablePath {
757    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
758        formatter.write_str(&self.0)
759    }
760}
761
762// Transparent on purpose. These values appear in session goldens, where the reader is
763// checking which path a page returned; a wrapper name repeated on every row would be
764// noise in the one place the value has to stay legible.
765impl std::fmt::Debug for PortablePath {
766    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
767        std::fmt::Debug::fmt(&self.0, formatter)
768    }
769}
770
771/// One immutable retained entry returned by an opened-root read.
772#[derive(Clone, PartialEq, Eq, Debug)]
773pub struct EntryValue {
774    /// Native path relative to the opened root.
775    pub path: PathBuf,
776    /// Canonical POSIX-relative form. Every entry has one. See [`PortablePath`].
777    pub portable_path: PortablePath,
778    /// Retained filesystem kind.
779    pub kind: EntryKind,
780    /// Retained filesystem attributes.
781    pub attrs: Attrs,
782    /// Effective fixed-control classification.
783    pub ignored: bool,
784    /// Name- and registry-derived identity for a regular file.
785    ///
786    /// Projected at read time so detached indexes and the standalone CLI retain no
787    /// duplicate strings or interactive-only classification payload.
788    pub classification: Option<crate::classify::NameClassification>,
789    /// Both constant-size maintained aggregate partitions for a directory.
790    pub rollup: Option<crate::index::PartitionRollUpSummary>,
791    /// Whether a directory's complete in-scope child set is known.
792    pub children_complete: Option<bool>,
793}
794
795/// Three-valued knowledge for a path lookup.
796#[derive(Clone, PartialEq, Eq, Debug)]
797pub enum Knowledge<T> {
798    /// The requested value is retained.
799    Present(T),
800    /// Complete relevant coverage proves the path is absent.
801    Absent,
802    /// Current coverage cannot prove presence or absence.
803    Unknown {
804        /// Why the relevant scope is incomplete.
805        reason: CoverageReason,
806    },
807}
808
809/// One fdu-native projection requested under a coherent read boundary.
810#[derive(Clone, Debug)]
811pub enum ReadProjection {
812    /// Look up one relative path with three-valued, portable-safe absence.
813    Lookup {
814        /// Native path relative to the opened root.
815        path: PathBuf,
816    },
817    /// Return both maintained aggregate partitions for one directory.
818    RollUp {
819        /// Directory relative to the opened root.
820        path: PathBuf,
821    },
822    /// Return one page of portable descendants, level by level to `depth`.
823    Tree {
824        /// Directory relative to the opened root.
825        path: PathBuf,
826        /// How many levels below `path` the page descends.
827        ///
828        /// `Limit(1)` is this directory's own children. Rows arrive in breadth-first
829        /// level order, so a page cut short by `page` withholds depth rather than
830        /// breadth: the caller can always tell how wide each level it received was.
831        depth: crate::query::Bound,
832        /// Whether entries in the fixed ignored partition are traversed.
833        ///
834        /// `false` prunes the excluded directory's whole subtree, contributing neither
835        /// it nor any descendant, rather than filtering its row and descending anyway.
836        include_ignored: bool,
837        /// Page output and work bounds.
838        page: PageRequest,
839    },
840    /// Return one portable-path-ordered page under an fdu-native selection.
841    Flat {
842        /// Additive portable-entry selection composing the existing query predicates.
843        selection: crate::query::EntrySelection,
844        /// Compact or full retained row shape.
845        shape: RowShape,
846        /// Page output and work bounds.
847        page: PageRequest,
848    },
849    /// Count portable entries under one selection, exactly or to an explicit cap.
850    Aggregate {
851        /// Additive portable-entry selection composing the existing query predicates.
852        selection: crate::query::EntrySelection,
853        /// Maximum matches counted before returning a lower bound.
854        count_cap: u64,
855        /// Maximum portable index rows inspected.
856        max_work: u64,
857    },
858    /// Evaluate the existing pure fdu query/report machinery at this read boundary.
859    Report(ReportRequest),
860    /// Resume a tree or flat page from handle-local retained traversal state.
861    Continue {
862        /// Opaque continuation returned by a prior page.
863        continuation: ContinuationId,
864        /// Bounds for this page; query identity remains in the handle.
865        page: PageRequest,
866    },
867    /// Return fixed-size owner, scope, and retained-issue diagnostics.
868    Diagnostics,
869}
870
871/// Fixed-size diagnostics captured with a coherent read.
872#[derive(Clone, PartialEq, Eq, Debug)]
873pub struct ReadDiagnostics {
874    /// Absolute filesystem root owned by this handle.
875    pub root: PathBuf,
876    /// Validated filesystem and semantic scope.
877    pub scope: ScanScope,
878    /// Live retained entries, including the root.
879    pub entries: u64,
880    /// Bounded typed issue details at this version.
881    pub issues: Vec<Issue>,
882    /// Which `.gitignore` files apply and which were refused, at this version. An opened
883    /// root always observes control state.
884    pub controls: crate::control::ControlObservation,
885}
886
887/// One structural page of a directory's descendants, to the requested depth.
888#[derive(Clone, PartialEq, Eq, Debug)]
889pub struct TreePage {
890    /// Directory whose descendants are listed.
891    pub directory: EntryValue,
892    /// Portable rows in breadth-first level order; each parent's children are
893    /// directories-first in canonical byte order.
894    pub rows: Vec<EntryValue>,
895    /// Opaque continuation when another page exists at this version.
896    pub next: Option<ContinuationId>,
897    /// Whether this directory's complete in-scope child set is known.
898    pub complete: bool,
899}
900
901/// Retained fields copied into portable page rows.
902#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Hash)]
903pub enum RowShape {
904    /// Path, kind, attributes, and classification without directory roll-ups.
905    #[default]
906    Compact,
907    /// Compact fields plus maintained directory roll-ups and completeness.
908    Full,
909}
910
911/// One portable flat-entry page.
912#[derive(Clone, PartialEq, Eq, Debug)]
913pub struct FlatPage {
914    /// Rows in complete canonical POSIX-relative UTF-8 byte order.
915    pub rows: Vec<EntryValue>,
916    /// Opaque continuation when another matching row exists at this version.
917    pub next: Option<ContinuationId>,
918}
919
920/// Product count whose exactness is explicit.
921#[derive(Clone, Copy, PartialEq, Eq, Debug)]
922pub enum CountResult {
923    /// Every matching row was counted.
924    Exact(u64),
925    /// Additional matches exist beyond this proven lower bound.
926    AtLeast(u64),
927}
928
929/// The read half of a request at an opened root, plus one read's work bound.
930///
931/// The basis is the opened root's own and is never supplied here: a caller names what each
932/// read asks -- the query and the instant it is asked at -- and [`crate::query::Request`]
933/// composes the two for validation.
934#[derive(Clone, Debug)]
935pub struct ReportRequest {
936    /// Existing selection and view vocabulary shared by one-shot surfaces.
937    pub query: crate::query::Query,
938    /// The instant this read resolves against, which is also the report's `generated_at`.
939    ///
940    /// Caller-supplied, keeping the projection deterministic.
941    pub now: std::time::SystemTime,
942    /// Maximum retained-index and maintained-index rows read by the report.
943    pub max_work: u64,
944}
945
946/// Projection whose deterministic work allowance was exhausted.
947#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
948pub enum LimitedProjection {
949    /// Structural tree page.
950    Tree,
951    /// Portable flat page.
952    Flat,
953    /// Existing query/report projection.
954    Report,
955    /// Selection count outside the maintained aggregate set.
956    Aggregate,
957}
958
959/// Typed bounded-query result; no partial calculation is presented as exact.
960#[derive(Clone, Copy, PartialEq, Eq, Debug)]
961pub struct QueryLimit {
962    /// Projection that exhausted its allowance.
963    pub projection: LimitedProjection,
964    /// Work allowance supplied by the caller.
965    pub max_work: u64,
966    /// Rows examined before stopping.
967    pub rows_visited: u64,
968}
969
970/// Input to one coherent opened-root read.
971#[derive(Clone, Debug, Default)]
972pub struct ReadRequest {
973    /// Projections to return, in this exact order.
974    pub projections: Vec<ReadProjection>,
975    /// Exact version required by a caller assembling a multi-read result.
976    pub expected: Option<EngineVersion>,
977}
978
979/// Why one projection of a read refused, while every other projection still answered.
980///
981/// A read fails as a whole only when no projection in it can be trusted: the request's
982/// shape is invalid, the root is closed, or the version it pinned is not the one the index
983/// holds. A refusal is narrower. It depends on what one projection found at that version,
984/// so the lookup of a path beside a tree page that refused the same path still answers,
985/// and a caller never has to prove a path is a directory before it may batch the question.
986#[derive(Clone, PartialEq, Eq, Debug)]
987pub enum ProjectionRefusal {
988    /// A tree page or roll-up named a retained path that is not a directory.
989    ///
990    /// A lookup of the same path answers `Present`, so neither three-valued answer fits:
991    /// `Absent` claims coverage proves the path missing, and `Unknown` claims coverage
992    /// cannot tell, which for a retained path never resolves. It depends on index state:
993    /// the same projection answers while the path is a directory and refuses once it has
994    /// become a file, so a path taken from an earlier page can start refusing between
995    /// reads.
996    NotADirectory {
997        /// The normalized path the projection named.
998        path: PathBuf,
999    },
1000    /// A page stopped with rows left, and the record that would resume it exceeds
1001    /// [`MAX_CONTINUATION_RECORD_BYTES`].
1002    ///
1003    /// The page is refused rather than returned without a way to continue, which would
1004    /// present a truncated page as a finished one. Only very long paths or a very large
1005    /// selection reach it. A refused continued page keeps its continuation, so the caller
1006    /// may retry it with a different page bound.
1007    ContinuationRecordLimit {
1008        /// Structural payload bytes the record would retain.
1009        attempted: usize,
1010        /// Maximum structural payload retained by one record.
1011        limit: usize,
1012    },
1013    /// A `Continue` named a continuation this root issued but no longer retains.
1014    ///
1015    /// An earlier page consumed it, or the root's bound on retained continuations evicted
1016    /// it to make room for newer pages. Both depend on what other pages did, not on the
1017    /// request, so a token that worked a moment ago costs only its own projection. Start
1018    /// the page again from its first request. A token from another opened root, or one
1019    /// this root never issued, still fails the whole read with
1020    /// [`Error::ContinuationUnavailable`].
1021    ContinuationUnavailable,
1022}
1023
1024/// One projection result, in the same position as its request.
1025#[derive(Clone, Debug)]
1026pub enum ProjectionResult {
1027    /// Three-valued retained-entry lookup.
1028    Lookup(Knowledge<EntryValue>),
1029    /// Three-valued directory roll-up lookup.
1030    RollUp(Knowledge<crate::index::PartitionRollUpSummary>),
1031    /// Three-valued structural tree page.
1032    Tree(Knowledge<TreePage>),
1033    /// Portable flat entry page.
1034    Flat(FlatPage),
1035    /// Exact maintained or explicitly capped portable selection count.
1036    Aggregate(CountResult),
1037    /// Existing fdu query report.
1038    Report(crate::query::Report),
1039    /// Fixed-size provider and lifecycle diagnostics.
1040    Diagnostics(ReadDiagnostics),
1041    /// A bounded projection stopped without returning a misleading partial answer.
1042    Limit(QueryLimit),
1043    /// This projection refused; the rest of the read answered.
1044    Refused(ProjectionRefusal),
1045}
1046
1047/// One coherent opened-root response.
1048#[derive(Clone, Debug)]
1049pub struct ReadResponse {
1050    /// Exact live-session version observed by every returned field.
1051    pub version: EngineVersion,
1052    /// State captured at the same commit boundary as every projection.
1053    pub state: IndexState,
1054    /// Projection results in request order.
1055    pub results: Vec<ProjectionResult>,
1056    /// Deterministic work charged while assembling the response.
1057    pub work: Work,
1058    /// Cursor from which a consumer can later resume exact changes.
1059    pub change_cursor: EngineVersion,
1060}
1061
1062/// Input to one blocking opened-root journal poll.
1063#[derive(Clone, Copy, PartialEq, Eq, Debug)]
1064pub struct ChangeRequest {
1065    /// Exact live version after which commits are requested.
1066    pub after: EngineVersion,
1067    /// Maximum time to wait when the journal has not advanced.
1068    pub timeout: std::time::Duration,
1069}
1070
1071/// Journal outcome at one coherent terminal version and state.
1072#[derive(Clone, PartialEq, Eq, Debug)]
1073pub enum ChangeOutcome {
1074    /// Every retained exact commit after the requested version, oldest first.
1075    Changes {
1076        /// Exact engine commits; consumers invalidate and coherently reread projections.
1077        commits: Vec<Commit>,
1078        /// Bounded union of the commits' answer invalidations.
1079        impact: Impact,
1080    },
1081    /// No newer commit arrived before the timeout.
1082    Idle,
1083    /// The requested version predates retained history and the consumer must reread.
1084    Reset {
1085        /// Complete invalidation guidance; lost paths are never presented as enumerable.
1086        impact: Impact,
1087    },
1088}
1089
1090/// Result of one opened-root journal poll.
1091#[derive(Clone, PartialEq, Eq, Debug)]
1092pub struct ChangePoll {
1093    /// Cursor to use for the next poll; unchanged for [`ChangeOutcome::Idle`].
1094    pub cursor: EngineVersion,
1095    /// Exact terminal version captured with the outcome and state.
1096    pub version: EngineVersion,
1097    /// Complete public state at `version`.
1098    pub state: IndexState,
1099    /// Changes, timeout, or consumer-history recovery.
1100    pub outcome: ChangeOutcome,
1101    /// Deterministic journal work performed while assembling the result.
1102    pub work: Work,
1103}
1104
1105/// Why one requested refresh path was not verified.
1106///
1107/// Rejections are values rather than dropped inputs so a hint producer can distinguish
1108/// "verified and unchanged" from "not examined" without parsing an error message.
1109#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
1110#[non_exhaustive]
1111pub enum RefreshRejection {
1112    /// The path was absolute or traversed above the opened root.
1113    OutsideRoot,
1114    /// The path is deeper than the opened root's semantic depth boundary.
1115    BeyondDepth,
1116    /// A fixed admission rule excludes the path from retained truth.
1117    NotAdmitted,
1118    /// A symlink or filesystem boundary makes the requested ancestry unsafe to walk.
1119    UnsafeAncestry,
1120    /// Verifying the path could expand a resource-stopped retained set.
1121    ResourceBudget,
1122}
1123
1124impl RefreshRejection {
1125    /// Stable label for wire adapters.
1126    pub const fn as_str(self) -> &'static str {
1127        match self {
1128            Self::OutsideRoot => "outside_root",
1129            Self::BeyondDepth => "beyond_depth",
1130            Self::NotAdmitted => "not_admitted",
1131            Self::UnsafeAncestry => "unsafe_ancestry",
1132            Self::ResourceBudget => "resource_budget",
1133        }
1134    }
1135}
1136
1137/// One refresh path that the engine declined, with its typed reason.
1138#[derive(Clone, PartialEq, Eq, Debug, Hash)]
1139pub struct RejectedRefreshPath {
1140    /// Path exactly as supplied by the caller.
1141    pub path: PathBuf,
1142    /// Rule that prevented verification.
1143    pub reason: RefreshRejection,
1144}
1145
1146/// Result of one bounded, multi-path refresh.
1147///
1148/// Every commit created by this operation lies in the half-open journal interval
1149/// `(after, version]`. Concurrent producers may also commit within that interval, so
1150/// `impact` covers the complete interval rather than trying to identify a producer.
1151#[derive(Clone, PartialEq, Eq, Debug)]
1152pub struct RefreshResult {
1153    /// Coherent engine version immediately before refresh state was announced.
1154    pub after: EngineVersion,
1155    /// Coherent terminal engine version after every accepted scope was closed.
1156    pub version: EngineVersion,
1157    /// Complete public state at `version`.
1158    pub state: IndexState,
1159    /// Unique canonical paths accepted for verification, in stable path order.
1160    pub accepted: Vec<PathBuf>,
1161    /// Rejected request entries in caller order.
1162    pub rejected: Vec<RejectedRefreshPath>,
1163    /// Bounded union of every invalidation in `(after, version]`, or `all_dirty` when
1164    /// the journal floor advanced before the receipt was assembled.
1165    pub impact: Impact,
1166    /// Deterministic filesystem and commit work performed by this refresh.
1167    pub work: Work,
1168    /// Bounded operational issues encountered while verification continued.
1169    pub issues: Vec<Issue>,
1170    /// Additional issues omitted after the public detail bound.
1171    pub omitted_issues: u64,
1172}
1173
1174impl Default for IndexState {
1175    fn default() -> Self {
1176        Self {
1177            phase: LifecyclePhase::Ready,
1178            coverage: Coverage::Complete,
1179            freshness: Freshness::Fresh,
1180            source: Source::Scanned,
1181            progress: DiscoveryProgress::default(),
1182            issues: IssueSummary::default(),
1183        }
1184    }
1185}
1186
1187impl Freshness {
1188    pub(crate) const fn rank(self) -> u8 {
1189        match self {
1190            Self::Fresh => 0,
1191            Self::Reconciling => 1,
1192            Self::Stale => 2,
1193            Self::Partial => 3,
1194        }
1195    }
1196}
1197
1198/// Why a producer had to escalate to [`Op::InvalidateSubtree`] instead of describing a
1199/// change precisely.
1200///
1201/// Each variant corresponds to a real, documented platform limitation rather than a
1202/// defensive catch-all, and the scan layer resolves every one of them the same way.
1203#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
1204pub enum InvalidateReason {
1205    /// The kernel dropped events: inotify `Q_OVERFLOW`, `FSEvents` `MustScanSubDirs`, or a
1206    /// Windows `ReadDirectoryChangesW` buffer overrun. All three surface through notify
1207    /// as `Flag::Rescan`, and swallowing that flag silently corrupts any index built on
1208    /// events.
1209    WatchOverflow,
1210    /// A rename left entries no event describes. Renames arrive one side at a time
1211    /// (`FSEvents` reports each path with no mechanism to associate old and new), and
1212    /// each side is verified as its own path, so this is raised only where a side's own
1213    /// path does not settle it: a directory that arrived by rename is relisted, because
1214    /// no backend reports a moved tree's contents; a renamed name its parent does not
1215    /// list exactly (a case-only rename on an insensitive filesystem) reconciles the
1216    /// parent; and a rename of the root itself, or from a backend that may never name
1217    /// the new side (kqueue), reconciles the whole root.
1218    UnpairedRename,
1219    /// A directory was created and its watch registered a moment later; anything created
1220    /// inside that window produced no event at all.
1221    WatchSetupRace,
1222    /// A periodic reconciliation sweep, for backends that cannot signal drops at all
1223    /// (kqueue).
1224    PeriodicSweep,
1225    /// Stat verification failed without proving the path is gone. The known entry must
1226    /// remain until reconciliation can retry and report the underlying I/O error.
1227    VerificationFailed,
1228    /// A verified child arrived below ancestry the index has not verified. The child is
1229    /// withheld while reconciliation starts from the nearest known directory.
1230    UnknownAncestry,
1231    /// Repeated concurrent commits prevented a watch sample from reaching a stable
1232    /// arbitration boundary. The root is reconciled instead of doing filesystem I/O
1233    /// under the index lock or allowing an old sample to win.
1234    WatchContention,
1235    /// A control edit may admit or remove entries under a narrowed ignored population,
1236    /// including entries absent from the current index. Revisit its parent directory.
1237    ControlPopulationChanged,
1238    /// Requested by the caller.
1239    Requested,
1240}
1241
1242/// A single change to one path.
1243#[derive(Clone, PartialEq, Eq, Debug)]
1244pub enum Op {
1245    /// The entry appeared or changed. Always carries a fresh stat, never a bare event.
1246    Upsert {
1247        /// Path relative to the index root.
1248        path: PathBuf,
1249        /// Newly observed entry kind.
1250        kind: EntryKind,
1251        /// Newly observed metadata.
1252        attrs: Attrs,
1253    },
1254    /// The entry is gone. Implies removal of every descendant.
1255    Remove {
1256        /// Path relative to the index root.
1257        path: PathBuf,
1258    },
1259    /// Exact bytes read from a verified `.gitignore` control file.
1260    ///
1261    /// This is separate from the file entry upsert so admission may retain the signal
1262    /// without creating a visible row. Producers normally emit both while control files
1263    /// are visible and only this operation when later admission excludes the row.
1264    ControlUpsert {
1265        /// Control-file path relative to the index root.
1266        path: PathBuf,
1267        /// Complete source bytes, bounded by the receiving control table.
1268        source: Vec<u8>,
1269    },
1270    /// A previously observed `.gitignore` control file is absent.
1271    ControlRemove {
1272        /// Control-file path relative to the index root.
1273        path: PathBuf,
1274    },
1275    /// The producer could not describe the change precisely; the consumer must re-scan
1276    /// this subtree. The scan layer turns this back into precise ops, so escalation is
1277    /// closed-loop rather than a dead end.
1278    InvalidateSubtree {
1279        /// Relative root of the scope that must be reconciled.
1280        path: PathBuf,
1281        /// Why the producer could not report precise changes.
1282        reason: InvalidateReason,
1283    },
1284}
1285
1286impl Op {
1287    /// The path this op applies to.
1288    pub fn path(&self) -> &Path {
1289        match self {
1290            Self::Upsert { path, .. }
1291            | Self::Remove { path }
1292            | Self::ControlUpsert { path, .. }
1293            | Self::ControlRemove { path }
1294            | Self::InvalidateSubtree { path, .. } => path,
1295        }
1296    }
1297}
1298
1299/// The complete indexed state of one path at an observation boundary.
1300#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
1301pub enum PathState {
1302    /// The path was not indexed.
1303    Absent,
1304    /// The path was indexed with these observed fields.
1305    Present {
1306        /// Indexed entry kind.
1307        kind: EntryKind,
1308        /// Indexed metadata.
1309        attrs: Attrs,
1310    },
1311}
1312
1313/// Generation- and revision-safe identity for one indexed entry.
1314///
1315/// The fields are intentionally opaque. Producers obtain identities through
1316/// [`crate::Index::expectation`] rather than manufacturing handles that could
1317/// accidentally alias a recycled arena slot or bypass ABA detection.
1318#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
1319pub(crate) struct EntryIdentity {
1320    slot: u32,
1321    generation: u64,
1322    revision: u64,
1323    children_revision: u64,
1324    directory: bool,
1325}
1326
1327impl EntryIdentity {
1328    pub(crate) const fn new(
1329        slot: u32,
1330        generation: u64,
1331        revision: u64,
1332        children_revision: u64,
1333        directory: bool,
1334    ) -> Self {
1335        Self { slot, generation, revision, children_revision, directory }
1336    }
1337
1338    pub(crate) const fn same_target(self, other: Self, require_structure: bool) -> bool {
1339        self.slot == other.slot
1340            && self.generation == other.generation
1341            && self.revision == other.revision
1342            && (!require_structure || self.children_revision == other.children_revision)
1343    }
1344
1345    pub(crate) const fn same_absence_guard(self, other: Self) -> bool {
1346        self.slot == other.slot
1347            && self.generation == other.generation
1348            && self.children_revision == other.children_revision
1349            && (other.directory || self.revision == other.revision)
1350    }
1351}
1352
1353/// State and entry revisions captured at one observation boundary.
1354///
1355/// Present paths carry a generation-safe target identity and direct revision so a
1356/// change-away-and-back cannot masquerade as the original state. Absent paths carry the
1357/// nearest existing ancestor's structural revision, closing create/remove and parent
1358/// replacement races without making unrelated subtrees conflict.
1359#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
1360pub struct PathExpectation {
1361    /// Visible state captured at the observation boundary.
1362    pub state: PathState,
1363    entry: Option<EntryIdentity>,
1364    absence_guard: Option<EntryIdentity>,
1365}
1366
1367impl PathExpectation {
1368    pub(crate) const fn new(
1369        state: PathState,
1370        entry: Option<EntryIdentity>,
1371        absence_guard: Option<EntryIdentity>,
1372    ) -> Self {
1373        Self { state, entry, absence_guard }
1374    }
1375
1376    pub(crate) const fn entry(self) -> Option<EntryIdentity> {
1377        self.entry
1378    }
1379
1380    pub(crate) const fn absence_guard(self) -> Option<EntryIdentity> {
1381        self.absence_guard
1382    }
1383}
1384
1385/// The condition under which an observation may be committed.
1386#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
1387pub enum Expectation {
1388    /// Commit according to arrival order. Used by freshly verified watch observations
1389    /// and cold-scan bootstrap data.
1390    Any,
1391    /// Commit only if the target state and relevant structural revisions still match the
1392    /// producer baseline.
1393    State(PathExpectation),
1394}
1395
1396/// One observed operation together with its arbitration precondition.
1397#[derive(Clone, PartialEq, Eq, Debug)]
1398pub struct ObservationOp {
1399    /// Proposed path mutation.
1400    pub op: Op,
1401    /// Arbitration precondition for that mutation.
1402    pub expectation: Expectation,
1403}
1404
1405impl ObservationOp {
1406    /// An operation whose fresh verification makes arrival order authoritative.
1407    pub const fn unconditional(op: Op) -> Self {
1408        Self { op, expectation: Expectation::Any }
1409    }
1410
1411    /// An operation valid only while `expected` still matches the index.
1412    pub const fn if_state(op: Op, expected: PathExpectation) -> Self {
1413        Self { op, expectation: Expectation::State(expected) }
1414    }
1415}
1416
1417/// A producer batch awaiting arbitration by the index.
1418///
1419/// Batching is not just an efficiency detail: producers coalesce per path within a batch
1420/// and stat once per batch rather than once per event.
1421#[derive(Clone, PartialEq, Eq, Debug, Default)]
1422pub struct Observation {
1423    /// Ordered operations in this producer batch.
1424    pub ops: Vec<ObservationOp>,
1425}
1426
1427impl Observation {
1428    /// Build an unconditional batch from freshly verified operations.
1429    pub fn new(ops: Vec<Op>) -> Self {
1430        Self { ops: ops.into_iter().map(ObservationOp::unconditional).collect() }
1431    }
1432
1433    /// Build a batch whose operations already carry explicit expectations.
1434    pub const fn from_ops(ops: Vec<ObservationOp>) -> Self {
1435        Self { ops }
1436    }
1437
1438    #[inline]
1439    /// Whether the batch contains no operations.
1440    pub fn is_empty(&self) -> bool {
1441        self.ops.is_empty()
1442    }
1443
1444    #[inline]
1445    /// Number of operations in the batch.
1446    pub fn len(&self) -> usize {
1447        self.ops.len()
1448    }
1449}
1450
1451/// One exact fact mutation performed by the index.
1452///
1453/// These are deliberately more specific than [`Op`]. An observation says what a
1454/// producer requested; an effective change says what the index actually did. One
1455/// upsert may, for example, replace a subtree and insert a differently typed entry.
1456#[derive(Clone, PartialEq, Eq, Debug)]
1457pub enum EffectiveChange {
1458    /// A previously absent entry was inserted.
1459    Inserted {
1460        /// Relative path of the inserted entry.
1461        path: PathBuf,
1462        /// Filesystem kind retained for the entry.
1463        kind: EntryKind,
1464        /// Metadata retained for the entry.
1465        attrs: Attrs,
1466    },
1467    /// An existing entry retained its kind and changed metadata.
1468    Updated {
1469        /// Relative path of the updated entry.
1470        path: PathBuf,
1471        /// Filesystem kind retained for the entry.
1472        kind: EntryKind,
1473        /// Metadata before the commit.
1474        previous: Attrs,
1475        /// Metadata after the commit.
1476        current: Attrs,
1477    },
1478    /// One entry was removed. Removing a subtree records one change per entry.
1479    Removed {
1480        /// Relative path of the removed entry.
1481        path: PathBuf,
1482        /// Filesystem kind the entry had before removal.
1483        kind: EntryKind,
1484        /// Metadata the entry had before removal.
1485        attrs: Attrs,
1486    },
1487    /// Exact control source identity changed.
1488    ControlUpdated {
1489        /// Relative `.gitignore` path.
1490        path: PathBuf,
1491        /// Previous source identity, or absence.
1492        previous: Option<crate::control::ControlIdentity>,
1493        /// Current source identity, or absence.
1494        current: Option<crate::control::ControlIdentity>,
1495    },
1496    /// A control file was refused, or its refusal lifted, so ignore classification's
1497    /// coverage changed.
1498    ///
1499    /// A refusal replacing a retained source arrives with the [`Self::ControlUpdated`]
1500    /// that drops the source; a refusal of a file no rule came from arrives alone.
1501    ControlRefusalUpdated {
1502        /// Relative `.gitignore` path.
1503        path: PathBuf,
1504        /// Why the file was refused before the commit, or absence.
1505        previous: Option<crate::control::ControlRefusalReason>,
1506        /// Why the file is refused after the commit, or absence.
1507        current: Option<crate::control::ControlRefusalReason>,
1508    },
1509    /// One retained entry moved between the fixed ignored and unignored partitions.
1510    Reclassified {
1511        /// Relative retained-entry path.
1512        path: PathBuf,
1513        /// Effective ignore classification before the commit.
1514        previous_ignored: bool,
1515        /// Effective ignore classification after the commit.
1516        current_ignored: bool,
1517    },
1518    /// A producer reported uncertainty that requires verified reconciliation.
1519    Invalidated {
1520        /// Relative root of the invalidated subtree.
1521        path: PathBuf,
1522        /// Why precise facts were unavailable.
1523        reason: InvalidateReason,
1524    },
1525}
1526
1527impl EffectiveChange {
1528    /// Relative path affected by this change.
1529    pub fn path(&self) -> &Path {
1530        match self {
1531            Self::Inserted { path, .. }
1532            | Self::Updated { path, .. }
1533            | Self::Removed { path, .. }
1534            | Self::ControlUpdated { path, .. }
1535            | Self::ControlRefusalUpdated { path, .. }
1536            | Self::Reclassified { path, .. }
1537            | Self::Invalidated { path, .. } => path,
1538        }
1539    }
1540}
1541
1542/// A stable fdu-native answer domain that one commit may have made stale.
1543#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)]
1544pub enum ImpactDomain {
1545    /// Entry presence, kind, parentage, or child membership.
1546    Topology,
1547    /// Filesystem metadata on retained entries.
1548    Metadata,
1549    /// Type or ignore classification.
1550    Classification,
1551    /// Maintained directory and whole-tree aggregates.
1552    Aggregates,
1553    /// Derived content records or aggregates.
1554    Content,
1555    /// Trust, coverage, or lifecycle state.
1556    State,
1557}
1558
1559/// Maximum number of individual dirty paths retained in one commit.
1560///
1561/// When a commit touches more, [`Impact::all_dirty`] is set and the partial list is
1562/// discarded. A truncated list would look complete and allow a stale answer to survive.
1563pub const MAX_DIRTY_PATHS: usize = 256;
1564
1565/// Bounded invalidation guidance derived from exact effective changes.
1566#[derive(Clone, PartialEq, Eq, Debug, Default)]
1567pub struct Impact {
1568    /// Answer domains that may have changed, in stable enum order without duplicates.
1569    pub domains: Vec<ImpactDomain>,
1570    /// Exact affected paths and ancestors, unless [`Self::all_dirty`] is set.
1571    pub dirty_paths: Vec<PathBuf>,
1572    /// Whether the affected path set exceeded the engine's retained-path limit.
1573    pub all_dirty: bool,
1574}
1575
1576/// One observable transition that did not change a retained filesystem entry.
1577#[derive(Clone, PartialEq, Eq, Debug)]
1578pub enum StateTransition {
1579    /// The trust state visible for a subtree changed.
1580    Freshness {
1581        /// Relative root of the affected subtree.
1582        path: PathBuf,
1583        /// State before the commit.
1584        previous: Freshness,
1585        /// State after the commit.
1586        current: Freshness,
1587    },
1588    /// A completed reconciliation verified every retained path beneath this root.
1589    Verified {
1590        /// Relative root covered by the reconciliation.
1591        path: PathBuf,
1592    },
1593    /// One directory's complete in-scope child listing became known.
1594    DirectoryComplete {
1595        /// Relative directory whose child set is now authoritative.
1596        path: PathBuf,
1597    },
1598    /// Previously known child-listing completeness was withdrawn after failed verification.
1599    DirectoryIncomplete {
1600        /// Relative directory whose child set is no longer authoritative.
1601        path: PathBuf,
1602    },
1603    /// The coherent opened-root state changed.
1604    IndexState {
1605        /// State before this commit.
1606        previous: IndexState,
1607        /// State after this commit.
1608        current: IndexState,
1609    },
1610}
1611
1612impl StateTransition {
1613    /// Relative path affected by this transition.
1614    pub fn path(&self) -> &Path {
1615        match self {
1616            Self::Freshness { path, .. }
1617            | Self::Verified { path }
1618            | Self::DirectoryComplete { path }
1619            | Self::DirectoryIncomplete { path } => path,
1620            Self::IndexState { .. } => Path::new(""),
1621        }
1622    }
1623}
1624
1625/// Bounded work performed while committing producer input or serving engine reads.
1626#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
1627pub struct Work {
1628    /// Producer operations considered.
1629    pub observations: u64,
1630    /// Accepted operations whose complete observed state already matched.
1631    pub unchanged: u64,
1632    /// Conditional observations rejected at the commit boundary.
1633    pub stale: u64,
1634    /// File observations refused at the commit boundary by a resource budget.
1635    pub resource_refused: u64,
1636    /// Retained index rows examined by a read projection.
1637    pub rows_visited: u64,
1638    /// Rows copied into read projection results.
1639    pub rows_returned: u64,
1640    /// Lookups against commit-maintained projection indexes.
1641    pub maintained_index_work: u64,
1642    /// Retained journal commits examined by a change poll.
1643    pub commits_visited: u64,
1644    /// Exact journal commits copied into a change result.
1645    pub commits_returned: u64,
1646    /// Filesystem directories successfully enumerated by verified work.
1647    pub directories_read: u64,
1648    /// Filesystem entries whose metadata was examined by verified work.
1649    pub entries_visited: u64,
1650    /// Regular files whose metadata was examined by verified work.
1651    pub files_visited: u64,
1652    /// Apparent bytes represented by regular files examined by verified work.
1653    pub bytes_visited: u64,
1654}
1655
1656/// Bytes [`Commit::retained_cost`] charges for a commit's own frame in the journal.
1657const RETAINED_COMMIT_BYTES: usize = 256;
1658/// Bytes [`Commit::retained_cost`] charges for each retained change, transition, or dirty
1659/// path, before the bytes of the path it names.
1660const RETAINED_ITEM_BYTES: usize = 128;
1661/// Smallest journal budget, in bytes, an opened root accepts.
1662///
1663/// The floor refuses a count passed where bytes are expected. Until the budget was stated
1664/// in bytes it counted retained items, and its default was this same number, so a caller
1665/// still thinking in counts passes at most that: every smaller count is refused, and the
1666/// old default itself, read as bytes, is a working budget.
1667///
1668/// It also guarantees history worth polling, as [`Commit::retained_cost`] charges it. The
1669/// cheapest commit a tree produces, one change to a one-byte name at the root, costs the
1670/// frame, three items (the change, the root it dirties, and its own path), and two path
1671/// bytes: 642 bytes. The floor holds about a hundred of those, or one commit of some two
1672/// hundred changes to short names in one directory. A budget that held only a few would
1673/// answer [`ChangeOutcome::Reset`] to a consumer barely behind, for a cause it cannot see.
1674pub const MIN_JOURNAL_CAPACITY_BYTES: usize = 64 * 1024;
1675
1676/// One atomic, exact index transition.
1677///
1678/// Detached indexes use the process-local [`Clock`] as their version sequence. The
1679/// opened-root layer later binds that sequence to its own lifetime identity without
1680/// putting live-session identity into clonable detached state.
1681#[derive(Clone, PartialEq, Eq, Debug)]
1682pub struct Commit {
1683    /// Logical commit clock minted for the complete transition.
1684    pub clock: Clock,
1685    /// Exact retained fact mutations in application order.
1686    pub changes: Vec<EffectiveChange>,
1687    /// Bounded answer invalidation derived from `changes` and `state`.
1688    pub impact: Impact,
1689    /// Observable non-entry transitions committed at the same boundary.
1690    pub state: Vec<StateTransition>,
1691    /// Work performed to reach this commit.
1692    pub work: Work,
1693}
1694
1695impl Commit {
1696    /// Whether this value carries no effective fact or state transition.
1697    pub fn is_empty(&self) -> bool {
1698        self.changes.is_empty() && self.state.is_empty()
1699    }
1700
1701    /// Approximate bytes this commit retains in the bounded journal.
1702    ///
1703    /// The estimate is a fixed allowance for the commit's own frame plus, for every
1704    /// change, transition, and dirty path, a fixed allowance for the item and the bytes of
1705    /// the path it names. Paths are the part that varies, so charging their bytes makes
1706    /// [`crate::DEFAULT_JOURNAL_CAPACITY_BYTES`] mean what it says whatever the tree's paths
1707    /// look like; a charge per item would let long paths hold many times the budget, and
1708    /// every change poll clones what the journal holds. The allowances are fixed rather
1709    /// than measured with `size_of` so the budget means the same on every target: the
1710    /// retained types differ in size by platform, and a recorded journal work count would
1711    /// otherwise differ with them.
1712    pub fn retained_cost(&self) -> usize {
1713        let paths = self
1714            .changes
1715            .iter()
1716            .map(|change| change.path().as_os_str().len())
1717            .chain(self.state.iter().map(|transition| transition.path().as_os_str().len()))
1718            .chain(self.impact.dirty_paths.iter().map(|path| path.as_os_str().len()))
1719            .sum::<usize>();
1720        let items = self.changes.len() + self.state.len() + self.impact.dirty_paths.len();
1721        RETAINED_COMMIT_BYTES + items * RETAINED_ITEM_BYTES + paths
1722    }
1723}
1724
1725/// Errors the engine can report.
1726#[derive(Debug, thiserror::Error)]
1727pub enum Error {
1728    /// A filesystem operation failed at a specific path.
1729    #[error("I/O error at {path}: {source}")]
1730    Io {
1731        /// Path whose operation failed.
1732        path: PathBuf,
1733        #[source]
1734        /// Underlying operating-system error.
1735        source: std::io::Error,
1736    },
1737
1738    /// An observation or subtree path was not relative to the index root.
1739    #[error("path escapes the index root: {0}")]
1740    PathEscapesRoot(PathBuf),
1741
1742    /// A live upsert named a child below ancestry the index has not verified.
1743    #[error("upsert {path:?} has unknown ancestry; reconcile from {reconcile_from:?}")]
1744    UnknownAncestry {
1745        /// Child whose parent chain is not known.
1746        path: PathBuf,
1747        /// Nearest known directory from which a producer can reconcile safely.
1748        reconcile_from: PathBuf,
1749    },
1750
1751    /// A control observation did not name the fixed control filename.
1752    #[error("invalid control-file path: {0:?}")]
1753    InvalidControlPath(PathBuf),
1754
1755    /// Snapshot persistence failed after a usable snapshot had been selected.
1756    #[error("snapshot is not usable: {0}")]
1757    Snapshot(String),
1758
1759    /// A scan or watch setting has no supported safe semantics.
1760    ///
1761    /// Raised only where no request was built: a bound root, a raw scan, a watch
1762    /// configuration, a scanner batch. A scope axis a request names is refused by
1763    /// [`Request::validate`](crate::query::Request::validate) as
1764    /// [`RequestError::ScopeUnsupported`](crate::query::RequestError::ScopeUnsupported)
1765    /// before any stored state is read, so a surface renders it in its own words and
1766    /// classifies it as the refused request it is. Both print the same sentence, because
1767    /// both ask [`ScopeAxis::reason`](crate::query::ScopeAxis::reason) for it.
1768    #[error("unsupported scan configuration: {0}")]
1769    UnsupportedScanConfig(&'static str),
1770
1771    /// An index or report built without observing `.gitignore` control state was asked
1772    /// about it, was asked to select entries by it, or was handed control input.
1773    ///
1774    /// Such a scan read no control file and classified no entry, so answering "not
1775    /// ignored" for every entry, handing back an empty control table, or selecting every
1776    /// entry or none by ignored state would state a fact nobody observed. Nor does an index
1777    /// accept a `ControlUpsert` or `ControlRemove` ([`Op`]): its scope says no rule was
1778    /// read, and a table installed anyway would contradict it, in the index and in every
1779    /// snapshot saved from it. Scanning with [`ScanConfig::read_controls`](crate::ScanConfig)
1780    /// on, as it is by default, makes the answers exact.
1781    #[error(
1782        "this scan did not observe .gitignore control state, so it neither says nor selects \
1783         what is ignored and accepts no control input; scan with read_controls to observe it"
1784    )]
1785    ControlStateNotObserved,
1786
1787    /// An index's control table enforces other limits than the ones its scan scope was
1788    /// taken under.
1789    ///
1790    /// The scope's ignore-rules identity names the limits that decide which `.gitignore`
1791    /// rules apply, so a table refusing under other limits would contradict it, in the index
1792    /// and in every snapshot saved from it. [`Index::new_with_scope`](crate::Index) applies
1793    /// the default limits whatever its scope claims;
1794    /// [`Index::new_with_config`](crate::Index::new_with_config) builds a table and a scope
1795    /// from one configuration, so they agree.
1796    #[error(
1797        "this index's .gitignore limits ({limits}) are not the ones its scan scope was taken \
1798         under; build it with Index::new_with_config from the ScanConfig that made its scope"
1799    )]
1800    ControlLimitsOutsideScope {
1801        /// The limits the index's control table enforces.
1802        limits: crate::control::ControlLimits,
1803    },
1804
1805    /// An opened root's journal budget is below [`MIN_JOURNAL_CAPACITY_BYTES`].
1806    #[error(
1807        "journal_capacity_bytes is {requested} bytes, below the {minimum}-byte minimum; it is \
1808         a size in bytes, not a count, so set it to at least {minimum} bytes, or leave it \
1809         unset for the default"
1810    )]
1811    JournalCapacityTooSmall {
1812        /// The budget requested, in bytes.
1813        requested: usize,
1814        /// [`MIN_JOURNAL_CAPACITY_BYTES`].
1815        minimum: usize,
1816    },
1817
1818    /// Requested scan semantics differ from the index's immutable scope.
1819    #[error("scan scope mismatch: index has {indexed:?}, requested {requested:?}")]
1820    ScanScopeMismatch {
1821        /// Scope represented by the index.
1822        indexed: ScanScope,
1823        /// Scope requested by the operation.
1824        requested: ScanScope,
1825    },
1826
1827    /// A requested relative subtree lies beyond the configured scan boundary.
1828    #[error("subtree {path:?} lies outside scan scope {scope:?}")]
1829    SubtreeOutsideScanScope {
1830        /// Rejected relative path.
1831        path: PathBuf,
1832        /// Scope that excludes the path.
1833        scope: ScanScope,
1834    },
1835
1836    /// A writer panicked while owning the shared index lock.
1837    #[error("index lock was poisoned by a panicking writer")]
1838    IndexLockPoisoned,
1839
1840    /// No further logical commit clock can be represented.
1841    #[error("the process-local index clock is exhausted")]
1842    ClockExhausted,
1843
1844    /// A tree, or a batch, would leave a whole-tree total no `u64` can hold.
1845    ///
1846    /// Roll-ups are `u64` counts and byte totals, and every route that counts a tree
1847    /// refuses one whose total leaves that range, with this error, rather than wrapping
1848    /// or panicking. A filesystem can produce one: tmpfs, XFS, and btrfs let a sparse file
1849    /// claim up to 8 EiB apparent without allocating anything, so three such files sum
1850    /// past `u64::MAX`.
1851    ///
1852    /// - A one-shot report, whether it folds a summary or builds a full or folded index,
1853    ///   fails at the first file the root's total cannot hold, and reports nothing.
1854    /// - A batch committed to an index ([`Index::apply`](crate::Index::apply), and every
1855    ///   scan, refresh, reconciliation, and watch commit) is applied in order, so the index
1856    ///   must be representable after each of its operations: a batch that removes before
1857    ///   it adds is accepted where the reverse order is not. The batch is refused before
1858    ///   any of it is applied, and the index, its clock, and its journal are as they were.
1859    #[error(
1860        "counting {path:?} would carry the tree's {counter} total past what a u64 can hold, \
1861         so nothing that includes it was applied or reported"
1862    )]
1863    UnrepresentableTotal {
1864        /// The entry, or the batch operation, at which the total would leave the
1865        /// representable range.
1866        path: PathBuf,
1867        /// Which total: `files`, `directories`, `bytes`, or `allocated bytes`.
1868        counter: &'static str,
1869    },
1870
1871    /// No further live-session identity can be represented in this process.
1872    #[error("the process-local opened-index identity space is exhausted")]
1873    OpenedIdentityExhausted,
1874
1875    /// An operation was attempted after shared shutdown began.
1876    #[error("the opened index is closed")]
1877    OpenedIndexClosed,
1878
1879    /// An expanding operation was attempted after a resource stop.
1880    #[error("the opened index is stopped and cannot expand its retained set")]
1881    OpenedIndexStopped,
1882
1883    /// A priority request exceeded the public per-call bound.
1884    #[error("priority request contains {attempted} paths; limit is {limit}")]
1885    PriorityPathLimit {
1886        /// Paths supplied by the caller.
1887        attempted: usize,
1888        /// Maximum paths accepted by one request.
1889        limit: usize,
1890    },
1891
1892    /// A refresh request exceeded the public per-call input bound.
1893    #[error("refresh request contains {attempted} paths; limit is {limit}")]
1894    RefreshPathLimit {
1895        /// Paths supplied by the caller.
1896        attempted: usize,
1897        /// Maximum paths accepted by one request.
1898        limit: usize,
1899    },
1900
1901    /// A coherent read requested more projections than one bounded call accepts.
1902    #[error("read request contains {attempted} projections; limit is {limit}")]
1903    ReadProjectionLimit {
1904        /// Projections supplied by the caller.
1905        attempted: usize,
1906        /// Maximum projections accepted by one request.
1907        limit: usize,
1908    },
1909
1910    /// A pinned coherent read named a foreign or no-longer-current version.
1911    #[error("requested opened-index version {requested:?} is unavailable; current is {current:?}")]
1912    VersionUnavailable {
1913        /// Version required by the caller.
1914        requested: Box<EngineVersion>,
1915        /// Only version the live image can currently answer.
1916        current: Box<EngineVersion>,
1917    },
1918
1919    /// A page output bound was zero or exceeded the public maximum.
1920    #[error("page row limit {attempted} is outside 1..={limit}")]
1921    PageRowLimit {
1922        /// Rejected row limit.
1923        attempted: usize,
1924        /// Maximum accepted row limit.
1925        limit: usize,
1926    },
1927
1928    /// A page work bound was zero or exceeded the public maximum.
1929    #[error("page work limit {attempted} is outside 1..={limit}")]
1930    PageWorkLimit {
1931        /// Rejected work limit.
1932        attempted: u64,
1933        /// Maximum accepted work limit.
1934        limit: u64,
1935    },
1936
1937    /// A tree page asked for zero levels.
1938    ///
1939    /// Distinct from the page bounds above, which are about how much of a level fits.
1940    /// Zero levels is not a small page, it is a request with no rows to return, and
1941    /// reporting it as a row-limit failure sends a caller to inspect the wrong argument.
1942    #[error("tree page depth must be at least one level")]
1943    TreeDepthZero,
1944
1945    /// A flat page attempted to use presentation axes whose ordering is not resumable.
1946    #[error(
1947        "flat opened-index pages use fixed portable path order; selection cannot set depth, limit, sort, or reverse"
1948    )]
1949    UnsupportedFlatSelection,
1950
1951    /// An aggregate cap was zero or exceeded the public maximum.
1952    #[error("aggregate count cap {attempted} is outside 1..={limit}")]
1953    CountCapLimit {
1954        /// Rejected count cap.
1955        attempted: u64,
1956        /// Maximum accepted count cap.
1957        limit: u64,
1958    },
1959
1960    /// A request no holder of its own basis could answer, or that this holder cannot.
1961    ///
1962    /// The refusal is a value, rendered here in the library's field names; a surface that
1963    /// built the request renders the same value in its own words through
1964    /// [`RequestError::message`](crate::query::RequestError::message), which is why every
1965    /// door refuses one request with one rule.
1966    #[error(transparent)]
1967    InvalidRequest(crate::query::RequestError),
1968
1969    /// A report request exceeded the bounded section vocabulary for one read.
1970    #[error("report request contains {attempted} views or omissions; limit is {limit}")]
1971    ReportViewLimit {
1972        /// Views and omitted-view records supplied by the caller.
1973        attempted: usize,
1974        /// Maximum accepted combined records.
1975        limit: usize,
1976    },
1977
1978    /// A continuation belongs to another opened root, or names an ordinal this root never
1979    /// issued.
1980    ///
1981    /// Either is a malformed request, so the whole read fails. A token this root issued and
1982    /// no longer retains -- consumed or evicted -- refuses only its own projection with
1983    /// [`ProjectionRefusal::ContinuationUnavailable`].
1984    #[error(
1985        "the page continuation was not issued by this opened index; continue from a token a \
1986         page of this root returned"
1987    )]
1988    ContinuationUnavailable,
1989
1990    /// No further handle-local continuation identifier can be represented.
1991    #[error("the opened index continuation identity space is exhausted")]
1992    ContinuationIdentityExhausted,
1993
1994    /// A continuation was pinned to an older committed image.
1995    #[error("the page continuation version {requested:?} is stale; current is {current:?}")]
1996    ContinuationStale {
1997        /// Version captured by the continuation.
1998        requested: Box<EngineVersion>,
1999        /// Current live version.
2000        current: Box<EngineVersion>,
2001    },
2002
2003    /// A change cursor belongs to another handle, has incompatible identities, or is in
2004    /// the future.
2005    #[error("change cursor {requested:?} is unavailable; current is {current:?}")]
2006    ChangeCursorUnavailable {
2007        /// Cursor supplied by the consumer.
2008        requested: Box<EngineVersion>,
2009        /// Current live version against which it was validated.
2010        current: Box<EngineVersion>,
2011    },
2012
2013    /// A panic poisoned opened-root journal wait coordination.
2014    #[error("opened-index journal wait state was poisoned by a panic")]
2015    OpenedJournalPoisoned,
2016
2017    /// A producer tried to complete a path that was not a retained directory.
2018    #[error("directory completion named an unknown or non-directory path: {0:?}")]
2019    InvalidDirectoryCompletion(PathBuf),
2020
2021    /// A panic poisoned the opened index's lifecycle coordination state.
2022    #[error("opened-index lifecycle state was poisoned by a panic")]
2023    OpenedLifecyclePoisoned,
2024
2025    /// An owned opened-index worker panicked.
2026    ///
2027    /// Joined shutdown reports it, and so does a change poll that would otherwise wait for
2028    /// commits the root can no longer make.
2029    #[error("opened-index worker {worker} panicked")]
2030    OpenedWorkerPanicked {
2031        /// Stable role of the failed worker.
2032        worker: &'static str,
2033    },
2034
2035    /// An owned opened-index worker returned a terminal error during joined shutdown.
2036    #[error("opened-index worker {worker} failed: {source}")]
2037    OpenedWorkerFailed {
2038        /// Stable role of the failed worker.
2039        worker: &'static str,
2040        /// Original typed engine error, shared so repeated close returns the same cause.
2041        #[source]
2042        source: std::sync::Arc<Error>,
2043    },
2044
2045    /// An operating-system thread could not be created for an opened-index worker.
2046    #[error("could not start opened-index worker {worker}: {source}")]
2047    OpenedWorkerSpawn {
2048        /// Stable role of the worker that could not start.
2049        worker: &'static str,
2050        #[source]
2051        /// Underlying operating-system error.
2052        source: std::io::Error,
2053    },
2054
2055    /// The watch worker ended permanently without panicking.
2056    #[error("watch worker stopped before another observation was available")]
2057    WatchStopped,
2058
2059    /// The watch worker panicked; its bounded channel is no longer live.
2060    #[error("watch worker panicked and stopped")]
2061    WatchWorkerPanicked,
2062
2063    /// A scripted watch backend's event file could not be used.
2064    #[cfg(all(feature = "watch", test))]
2065    #[error("invalid watch script: {0}")]
2066    WatchScript(String),
2067
2068    /// Capture began, but verification could not establish a complete live baseline.
2069    #[error("filesystem observation handoff could not establish a complete verified baseline")]
2070    ObservationHandoffIncomplete,
2071
2072    #[cfg(test)]
2073    /// A test-only reducer preflight rejected a prepared transition.
2074    #[error("prepared commit rejected by {0}")]
2075    CommitRejected(&'static str),
2076
2077    /// An argument value did not match its documented grammar.
2078    ///
2079    /// Carries a suggestion rather than only a rejection, because these values are typed
2080    /// by humans and agents at a prompt: the whole point of a closed grammar is that a
2081    /// near miss can say what the near-hit spelling would be.
2082    #[error("invalid {kind} {value:?}: {hint}")]
2083    InvalidValue {
2084        /// Which grammar was expected, for the message: `time` or `size`.
2085        kind: &'static str,
2086        /// The rejected input, as written.
2087        value: String,
2088        /// What to write instead.
2089        hint: String,
2090    },
2091
2092    /// A watcher was paired with an index rooted at a different directory.
2093    #[error("watch root {watched:?} does not match index root {indexed:?}")]
2094    WatchRootMismatch {
2095        /// Canonical root owned by the watcher.
2096        watched: PathBuf,
2097        /// Canonical root owned by the index.
2098        indexed: PathBuf,
2099    },
2100}
2101
2102impl Error {
2103    pub(crate) fn io(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
2104        Self::Io { path: path.into(), source }
2105    }
2106}
2107
2108/// Result alias for engine operations.
2109pub type Result<T> = std::result::Result<T, Error>;
2110
2111#[cfg(test)]
2112mod tests {
2113    use super::*;
2114
2115    #[test]
2116    fn clock_advances_monotonically() {
2117        let c = Clock::ZERO;
2118        assert_eq!(c.checked_next(), Some(Clock(1)));
2119        assert!(c.checked_next().is_some_and(|next| next > c));
2120        assert_eq!(Clock(u64::MAX).checked_next(), None);
2121    }
2122
2123    #[test]
2124    fn entry_kind_roundtrips_through_its_pinned_value() {
2125        for kind in [EntryKind::File, EntryKind::Dir, EntryKind::Symlink, EntryKind::Other] {
2126            assert_eq!(EntryKind::from_u8(kind as u8), Some(kind));
2127        }
2128        assert_eq!(EntryKind::from_u8(99), None);
2129    }
2130
2131    #[test]
2132    fn fingerprint_ignores_allocated_size_but_tracks_ctime() {
2133        let base = Attrs { size: 10, allocated: 4096, mtime_ns: 5, ctime_ns: 7, inode: 42, dev: 1 };
2134        // Allocation changes alone are not a content change.
2135        let repacked = Attrs { allocated: 8192, ..base };
2136        assert_eq!(base.fingerprint(), repacked.fingerprint());
2137
2138        // A ctime bump is, even when mtime was rolled back to look unchanged.
2139        let touched = Attrs { ctime_ns: 9, ..base };
2140        assert_ne!(base.fingerprint(), touched.fingerprint());
2141    }
2142
2143    #[test]
2144    fn scan_scope_separates_admission_from_answer_semantics() {
2145        let base = ScanScope::default();
2146        let changed_admission = ScanScope { exclude_special: !base.exclude_special, ..base };
2147        let changed_semantics =
2148            ScanScope { reducers_fingerprint: base.reducers_fingerprint.wrapping_add(1), ..base };
2149
2150        assert_ne!(base.entry_scope(), changed_admission.entry_scope());
2151        assert_eq!(base.semantic_identity(), changed_admission.semantic_identity());
2152        assert_eq!(base.entry_scope(), changed_semantics.entry_scope());
2153        assert_ne!(base.semantic_identity(), changed_semantics.semantic_identity());
2154    }
2155}
2156
2157#[cfg(test)]
2158mod provenance_tests {
2159    use super::*;
2160
2161    #[test]
2162    fn sources_order_from_most_to_least_trustworthy() {
2163        assert!(Source::Scanned < Source::Revalidated);
2164        assert!(Source::Revalidated < Source::JournalScoped);
2165        assert!(Source::JournalScoped < Source::Cached);
2166    }
2167
2168    #[test]
2169    fn combining_takes_the_least_trustworthy_of_each_fact() {
2170        let verified =
2171            Provenance { source: Source::Scanned, observed_at_ns: 900, status: Status::Complete };
2172        let stale =
2173            Provenance { source: Source::Cached, observed_at_ns: 100, status: Status::Partial };
2174        let combined = verified.combine(stale);
2175        assert_eq!(combined.source, Source::Cached, "weakest source wins");
2176        assert_eq!(combined.observed_at_ns, 100, "oldest observation wins");
2177        assert_eq!(combined.status, Status::Partial, "worst status wins");
2178        assert_eq!(combined, stale.combine(verified), "combination is commutative");
2179    }
2180
2181    #[test]
2182    fn an_unknown_timestamp_makes_the_combination_unknown() {
2183        // Fail closed. Zero means "we cannot say when", so a subtree containing one
2184        // contributor of unknown age has an unknown age too. Returning the known
2185        // timestamp instead would let a directory advertise a precise "as of" that is
2186        // wrong for part of what it summarises — the silent lie the provenance model
2187        // exists to prevent.
2188        let known = Provenance::scanned(500);
2189        let unknown = Provenance { observed_at_ns: 0, ..Provenance::scanned(0) };
2190        assert_eq!(known.combine(unknown).observed_at_ns, 0);
2191        assert_eq!(unknown.combine(known).observed_at_ns, 0);
2192        assert_eq!(known.combine(unknown), unknown.combine(known), "combination stays commutative");
2193    }
2194
2195    #[test]
2196    fn only_this_session_counts_as_verified() {
2197        assert!(Provenance::scanned(1).is_verified());
2198        assert!(Provenance { source: Source::Revalidated, ..Provenance::scanned(1) }.is_verified());
2199        // The journal can omit history without saying so, so its word is not a check.
2200        assert!(
2201            !Provenance { source: Source::JournalScoped, ..Provenance::scanned(1) }.is_verified()
2202        );
2203        assert!(!Provenance { source: Source::Cached, ..Provenance::scanned(1) }.is_verified());
2204    }
2205}