ADR-0025: Scope-mode excludes prune at walk time (not query time)
Date: 2026-06-17 / Status: Superseded by ADR-0024 on 2026-07-26, which removed scope mode itself; the exclude mechanism described here went with it.
Kept rather than deleted because a decision record is only useful if the
rejected and retired options stay readable next to the surviving one — the
same reason ADR-0028 stays as Rejected.
It was removed from the tree outright in 0f3d873, which also left a hole
in the numbering that reads as an accident rather than a decision.
Decision
Let scope mode (ADR-0024) take a set of exclude paths alongside its roots, and honour them by pruning at folder-walk time: when the walk reaches a directory whose folded absolute path matches an exclude, it skips that entry and does not descend, so the excluded subtree is never indexed. Excludes flow AppSettings.ScopeExcludes → fmf_index_start_scope(roots, n, excludes, m) → Engine::index_start_scope(roots, excludes) → WorkerKind::Walk { roots, excludes } → walk_scan(roots, excludes).
The UI restricts an exclude to a subfolder under one of the selected roots (ScopePaths.IsUnderAnyRoot), and collapses nested roots before persisting (ScopePaths.Normalize); the engine just prunes whatever folded paths it is handed.
Rationale
- Scope mode exists to keep the index small (ADR-0024: “only the roots a knowledge worker actually touches”). The common excludes —
node_modules, build output,.git, archive trees — are exactly the high-file-count directories a developer does not want searched. Pruning them at walk time keeps them out of RAM and shortens the cold walk; that is the first-principles fit for scope mode, not a bolt-on filter. - The alternative, query-time exclusion (reuse the ADR-0019
!path:rewrite, appending the excludes to every query) was rejected: it still indexes every excluded entry (RAM and walk-time wasted, contradicting the lean-index premise) and adds a per-query path-evaluation cost on the hot search path against the single-digit-ms bar. Index-time pruning costs one hash lookup per directory and zero per query. - Matching is allocation-free. The walk already folds each entry’s path (ADR-0003, per-char length-preserving) to build its synthetic-FRN record key. Excludes are folded the same way once, up front, into a
HashSet<Vec<u8>>; the prune check is a single case-insensitive lookup of the path already in hand. Exact-match (not prefix) on the directory’s full folded path, soarchivenever prunes a siblingarchive2, and pruning at the subtree root means descendants are unreachable without per-descendant checks. - No index-format or contract-POD change. Excludes ride the existing FFI as a second string array;
fmf_index_start_scope’s signature is extended in place (scope mode is FFI-only and co-shipped with the DLL — no external ABI consumer, no opcode, noFmfVolumeStatus/POD change), soABI_VERSIONis unchanged andcontract/goldenis untouched. The export-signature pin infmf-ffi/src/lib.rsis updated to the new arity.
Consequences
- Changing excludes (like changing roots) is not a live operation: the engine no-ops
index_start_scopeon an existing scope slot, so the UI persists and relaunches into a freshWalkInProcthat re-walks with the new set — the same save+relaunch model the roots re-selection already uses (MainViewModel.ApplyScopeChange). - The prune count is normal behaviour, not a degradation: it is returned in
ScanStats::walk_excluded_prunedand surfaced in thefull scan completelog, deliberately not as a degrade counter (unlikewalk_read_errors/walk_depth_truncated, which signal incompleteness). If exclude effectiveness later needs a metric, promote it to a counter via the 3-point set (metrics +fmf-contract::counters::COUNTER_NAMES+just contract-gen). - Folded-path canonicalization. A root that folds to a trailing separator (drive roots:
C:\→c:\) previously seeded its children’s keys asc:\\users(doubled). The root’sPending.foldednow strips one trailing separator, so children readc:\users— matchingfold(full_path), the exclude set, and a future watcher’s stateless recompute. This corrects a latent inconsistency for drive-root scopes; ordinary roots (no trailing separator) are unaffected. - The Phase 2 watcher must also honour excludes.
WatcherJournalSourceis still a no-op stub; when it lands (ReadDirectoryChangesW), changes under an excluded subtree must be filtered the same way, or excluded files would re-enter the index on edit. Tracked as a Phase 2 obligation.
Relationship to ADR-0024
This refines scope mode; it does not touch the privileged ($MFT/USN) path. The “filename-only indexing” core and the two-seam cap (ADR-0018) are unchanged — pruning happens inside the scan (outside the seam), exactly where walk_scan already lives.
Re-examination triggers
- If users want excludes that are name/glob patterns (
node_modules,*.tmp) applied across all roots rather than specific subfolder paths → extend the match from exact folded-path to a name/glob test in the walk (the prune point is the same). - If an exclude needs to apply at query time (e.g. a saved search that hides a subtree without re-walking) → revisit the ADR-0019
!path:rewrite as a complementary, not replacement, mechanism.