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