Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Security — Threat Model and Defenses (v2 service split)

Current architecture: a privileged service fmf-service (LocalSystem, least privilege) reads NTFS $MFT/USN, and the non-privileged UI connects over a named pipe. Decision history and rejected options are in ADR-0016 / ADR-0017; API spec verification is in RESEARCH.md.

Threats and Defenses

#ThreatDefense
1ACL-bypass name leak — the privileged indexer exposes file names invisible under the user’s own ACL to another userRestrict the pipe DACL to SYSTEM + the user SID (SID captured at install time + the everyday-user SID forwarded by the non-elevated UI via --owner-sid. The latter is accepted only if it is a real-user type via validate_user_sid — keeps the everyday user from being locked out even under OTS elevation, while preventing injection of an arbitrary SID). No Authenticated Users / Everyone ACE (deny by default) + token check on connect. SID mismatch disconnects only that client. A token-verification API failure fails closed and stays recoverable: the accept loop drops the listener, serve() observes the lost listener and stops the service gracefully (flush → SCM Stopped), and the next on-demand start rebuilds it. No client is ever admitted unverified, and a Running service with no listener — unrecoverable, since clients reconnect forever and start is a no-op — is the state this explicitly excludes. RevertToSelf failure still aborts the process outright, so no later privileged work can continue under a client token
2Remote connectionPIPE_REJECT_REMOTE_CLIENTS (+ server features are permanently out of scope per the won’t-do list)
3Anonymous connectionNo anonymous ACE in the explicit DACL = deny by default (the NullSessionPipes default is policy-dependent, so do not rely on it)
4Pipe-name squatting / spoofed serverServer: FILE_FLAG_FIRST_PIPE_INSTANCE on the first instance only (no flag on subsequent instances — name preemption is impossible as long as the first instance is held). Client: for the default pipe name, GetNamedPipeServerProcessIdmatch against the SCM-registered fmf-engine service PID (QueryServiceStatusEx; works non-elevated — a SYSTEM process token cannot be opened non-elevated [ACCESS_DENIED], and a session 0 process identity is not obtainable either. A squatter cannot register with the SCM [requires admin] so its PID will not match)
5Malicious client input (malformed frame, huge len, unknown opcode, pathological query)Every allocation-bearing bound is contract, not a tunable: the caps live in fmf-contract::limits and are enforced identically at the C#, FFI, pipe, and core boundaries. The fixed 16-byte header is checked against the opcode-specific cap before any payload is allocated or read, so an attacker-declared length can never reserve memory (the separate global cap exists for response frames, not as a request-allocation allowance). Validation failure drops the connection + pipe_malformed_frames counter. Query text, parsed groups, parsed terms, and regex terms are each separately capped, so neither a long query nor a deeply combinatorial one can force unbounded parse work. IndexStart is transactionally restricted to unique canonical labels in the current fixed-NTFS set, so it cannot launch workers for FAT/exFAT, removable, network, or synthetic paths. The whole dispatcher is a catch_unwind firewall (panic returns FMF_E_PANIC, the service survives). Regex is linear-time matching (no ReDoS) + compile caps size_limit/dfa_size_limit=1 MiB to gracefully reject computational DoS (overflow returns FMF_E_QUERY_SYNTAX. ADR-0023/ADR-0044, RESEARCH.md)
6Local DoS (connection flood, handle exhaustion, flush spamming)Every unbounded-growth path is given a fixed compile-time ceiling rather than a runtime policy: concurrent pipe instances (overflow rejects the connection + pipe_connections_rejected), decoded requests in flight per connection (bounded backpressure; stop/disconnect cancels a blocked producer), result handles per connection (LRU evict → STALE, so exhaustion degrades into the existing re-query path instead of failing), and the per-connection event queue. The contract-visible ceilings are fmf-contract::limits; the accept-loop instance cap is the pipe server’s own constant. Flush is not exposed over the pipe (only the service-internal periodic flush and flush on stop). Events use a bounded queue + drop to protect the USN thread. Note that only the authorized same user can even reach this (#1)
7Leak/tamper of the machine index (.fmfidx contains every file name)Install creates the root protected, publishes it by exact handle, and pins its NTFS identity in protected HKLM; an unproven fixed-name object is quarantined, never repaired in place. Which pre-opened handle fails the install closed and which is rotated out is not a choice — it follows from what Windows share-access can see. Sharing buckets desired access into read (FILE_READ_DATA/FILE_EXECUTE), write (FILE_WRITE_DATA/FILE_APPEND_DATA) and delete (DELETE), so a squatter holding DELETE — or FILE_ADD_FILE, which is FILE_WRITE_DATA — causes a sharing violation and install refuses. WRITE_DAC, WRITE_OWNER and FILE_DELETE_CHILD are in none of those buckets: no share mode can exclude them, and install rotates the object out of the privileged name instead. That is sufficient because a handle is bound to the object, not to the name — measured against a real standard-user token, the squatter keeps its capability over the quarantined directory and is denied even enumeration of the fresh root, whose descriptor admits only SYSTEM and Administrators. Root/descendant mutation is handle-bound and atomic. Administrators own the protected SYSTEM+Administrators tree; only logs add authorized-user read. Fixed paths reject reparse points at install, service start, GC, and purge. The existing tree is hardened bottom-up and write-nothing-on-the-way-down: a directory receives its descriptor only after its whole subtree has been opened FILE_FLAG_OPEN_REPARSE_POINT, type-checked, and given a protected descriptor of its own, so no object’s access depends on what it inherits and an inheritable ACE never reaches a level not yet proven free of reparse points. Both halves are measured rather than assumed: an unelevated test plants a real junction (which, unlike a symlink, any standard user can create) and pins that the target’s descriptor is untouched and that the walk refuses the tree; another builds the production shape — a child directory holding only files — which is the shape no earlier test built, because they all created an empty root and so never executed the walk at all. Both walks are additionally handle-relative: a directory’s contents are enumerated from the handle already held for it (GetFileInformationByHandleEx, never a second open by name), and every child is opened with NtCreateFile against OBJECT_ATTRIBUTES.RootDirectory = that same parent handle under a leaf-only ObjectName (OBJ_CASE_INSENSITIVE + OBJ_DONT_REPARSE, still FILE_OPEN_REPARSE_POINT so the per-handle reparse rejection stays the authority). No step re-resolves a full path, so no ancestor name is consulted twice and there is no TOCTOU window in which one could be repointed — a name repointed between enumeration and open can only present a different object under the already-verified parent, and that object is still judged by the per-handle reparse, type, and link-count checks before anything is mutated. Enumeration therefore also stops depending on the very ACL the hardening walk is rewriting. Pinned by unelevated swap tests that replace a child with a junction (hardening) and with a second link to a file outside the tree (deletion) inside the walk’s own window, and require both to fail closed with the outside object untouched
8Residual risk (accepted)An authorized user can search the “name/path” of files invisible under their own ACL (a structural property of name-only indexing; the contents and the actual ACL cannot be read). Targets single-user machines primarily; multi-user authorization is a re-examination trigger in ADR-0017
9Privilege escalation via the unelevated start/stop right (on-demand lifecycle, ADR-0027)The service-object DACL grants the authorized user SID(s) only SERVICE_START/SERVICE_STOP/SERVICE_QUERY_STATUS+read (built by the unit-pinned security::service_sddl; never hand-rolled). It deliberately withholds SERVICE_CHANGE_CONFIG, DELETE, WRITE_DAC, WRITE_OWNER from a standard user — granting any would let a non-admin repoint this LocalSystem service’s binary and run code as SYSTEM. SYSTEM/Administrators keep full control (SCM management + the SYSTEM-run GC’s DeleteService). The pin asserts start/stop are present and the four escalation rights are absent
10Tampering with the stable SYSTEM binary / elevated helper or adjacent-DLL hijack (ADR-0045)Before the UI passes the bundled helper to runas, it locks the image without write/delete sharing, locks each non-root parent directory the same way wherever this token is granted DELETE on it (a directory whose DACL withholds DELETE from the app’s token — C:\ProgramData does, for a standard user — denies an attacker holding that same token the rename or delete just as strictly, so the open degrades to observation instead of failing closed), rejects file or directory reparse points, compares a constant-time SHA-256 over Windows’ Authenticode PE digest stream with the exact digest embedded by xtask publish, and requires WinVerifyTrust to accept the Authenticode signature. The digest excludes only the mutable certificate table, so it is identical before/after release signing and rejects an older same-signer helper as well as an unsigned replacement. The service statically links the MSVC CRT and embeds /DEPENDENTLOADFLAG:0x800, limiting all remaining static imports to System32; both publish and package parse the PE Load Config and require that exact value. Thus locking the EXE cannot be bypassed with a planted sibling DLL, and no VC Redistributable is a hidden prerequisite. Unpinned or incorrectly linked developer builds cannot cross this elevation boundary. The installed service/GC then run %ProgramData%\find-my-files\fmf-service.exe as SYSTEM; install copies it only after threat-7 DACL hardening and re-hardens it. The GC task runs as S-1-5-18 with HighestAvailable. Install invokes the Known-Folder-resolved absolute System32\schtasks.exe (never PATH search) and XML-escapes its action path

Required machine-security gate

just test-admin pins and runs pipe::admin_security_tests::named_pipe_security_boundaries_are_enforced_on_real_tokens_and_transports. It must prove all three boundaries on real Windows tokens and transports:

  1. a temporary local standard user’s distinct TokenUser SID is denied by the production pipe DACL, while the authorized user connects to the same pending pipe instance;
  2. a deliberately wide test-only DACL admits that other user at the kernel, but server-side verify_client rejects and disconnects it; and
  3. \\COMPUTERNAME\pipe\... succeeds with PIPE_ACCEPT_REMOTE_CLIENTS, then fails with PIPE_REJECT_REMOTE_CLIENTS under the same identity and DACL.

The remote control is fail-closed: inability to prove remote transport fails the test. The test account and password are unique per process, the password is never logged and is cleared after logon, and RAII deletes the account during normal completion or panic unwinding. Release and nightly admin workflows upload this test in build/engine/nextest/admin/admin.xml; removing or renaming it makes the test-admin inventory preflight fail before execution.

Distribution integrity

release.yml is dispatchable only from protected main — the release and release-please environments restrict deployments to that branch, so an off-branch dispatch reaches no signing credential, App token, or publication authority — and its first job binds the dispatch to one exact tag, commit, and draft release ID before any other job starts (ADR-0048). It then isolates build, signing, and publishing into separate jobs. Only the approval-gated signing job receives SSL.com eSigner secrets; every first-party PE listed in the committed signing manifest must pass Authenticode chain, RFC 3161 timestamp, and signer-subject verification. The unsigned service’s Authenticode-stable image digest is embedded into the app before signing; collection proves that signing preserved every first-party PE image, and package independently rechecks the service load policy before sealing the ZIP. Runtime verifies the same service identity immediately before UAC. Actions are commit-SHA pinned and publishing fails closed. Operational steps live in RELEASING.md; rationale is in ADR-0020 and ADR-0029.

Verified technical facts (researched 2026-06-10, primary sources confirmed)

Design decisions assume this file. Sources at the end of each item.

NTFS / MFT / USN journal

  • FSCTL_ENUM_USN_DATA (DeviceIoControl, winioctl.h, documented) is the official API to enumerate MFT records. Call it repeatedly with MFT_ENUM_DATA_V0/V1 as input, starting from StartFileReferenceNumber=0. The returned USN_RECORD_V2 has FRN, parent FRN, file name, and FileAttributes, but no file size or timestamp (TimeStamp is the journal-record time). Indexing with size and date requires reading the raw $MFT ($STANDARD_INFORMATION/$FILE_NAME/$DATA) or an extra per-file query. https://learn.microsoft.com/en-us/windows/win32/api/winioctl/ni-winioctl-fsctl_enum_usn_data https://learn.microsoft.com/en-us/windows/win32/api/winioctl/ns-winioctl-usn_record_v2
  • Incremental monitoring: FSCTL_QUERY_USN_JOURNAL to get UsnJournalID/NextUsn → FSCTL_READ_USN_JOURNAL (READ_USN_JOURNAL_DATA_V0, blocking subscription possible with BytesToWaitFor>0). The state to persist is the UsnJournalID + last-processed USN pair. The journal is maintained by the OS, so changes made while the app is stopped can be caught up. https://learn.microsoft.com/en-us/windows/win32/api/winioctl/ni-winioctl-fsctl_read_usn_journal
  • Error fallback (standard pattern): ERROR_JOURNAL_NOT_ACTIVE → create with FSCTL_CREATE_USN_JOURNAL (admin required). ERROR_JOURNAL_DELETE_IN_PROGRESS (deletion continues across reboots). Saved USN older than FirstUsn → ERROR_JOURNAL_ENTRY_DELETED. These plus a JournalID mismatch fall back to a full rescan. https://learn.microsoft.com/en-us/windows/win32/fileio/creating-modifying-and-deleting-a-change-journal
  • FRN→path: USN records have no path string. Hold an FRN→(name, parent FRN) map for all directories and build paths lazily by walking the parent chain up to the root (fixed at MFT record 5 on NTFS). A folder rename/move updates only that one record; no records are emitted for its children. FRN is 64-bit on NTFS (low 48 bits = record number + high 16 bits = sequence). ReFS is 128-bit (USN_RECORD_V3) — out of scope for MVP but accounted for in the ID type design.
  • Privileges: Opening a volume handle (\\.\C:) requires admin (CreateFile official Remarks: “The caller must have administrative privileges”). The undocumented FSCTL_READ_UNPRIVILEGED_USN_JOURNAL allows non-elevated journal reads, but it is undocumented and has no ENUM equivalent, so the initial scan requires elevation. https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilew
  • Hard links: Multiple directory entries can reference one NTFS file, and USN_REASON_HARD_LINK_CHANGE reports that a link was added or removed. One event name is not a complete link-set snapshot, so initial MFT parsing indexes every non-DOS $FILE_NAME and incremental handling reconciles the current complete set. Each path has its own EntryId while all paths share the object’s full FRN. https://learn.microsoft.com/en-us/windows/win32/fileio/hard-links-and-junctions https://learn.microsoft.com/en-us/windows/win32/api/winioctl/ns-winioctl-usn_record_v3 https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-fscc/46021e52-29b1-475c-b6d3-fe5497d23277
  • Symbolic links / junctions: not followed (cycle-matching cost). Index the reparse point itself as a single entry.

Search syntax (real-usage research)

  • Real-usage research (HN etc.) centers on substring default, space=AND, |=OR, !=NOT, ""=phrase, *? (whole-filename match), ext: path: size: dm: (ranges a..b >x). regex:/content: are niche, and content search is inherently slow. → Supports the syntax scope and the “filename-only indexing” tradeoff (ADR-0001).

Competitors / prior art (as of 2026-06)

  • “Rust engine + native WinUI 3 + truly FOSS” is an empty niche. The strongest competitor, omni-search (Eul45, started 2026-02, 517 stars, MIT), is Tauri v2 + React + C++, requireAdministrator approach.
  • Past FOSS clones are all stalled: Orange (Rust/Tauri/Tantivy, walk-based without MFT, stopped 2023-10), FastFileSearch (2016), Indexer++ (2019), SwiftSearch (actually CC BY-NC = non-FOSS, 2019).

Real C: name/size statistics (2026-06-11, fmf stats C: --name-stats, 1,268,450 entries)

Primary data for layout decisions and synthetic-benchmark calibration (re-measure with this command):

  • fold-identical (lower==orig) = 73.2% / unique names 53.2% / unique after fold 53.0%
  • name length (WTF-8 bytes): mean 29.7 / p50 18 / p90 90 / p99 110 / max 171
  • files over 4GiB = 10 (0.0008%)

See docs/adr/ for design and rejection decisions and their numeric rationale.

Rust crates (existence and maturity confirmed)

  • ntfs-reader 0.4.5 (MIT/Apache-2.0, updated 2026-03): full raw-$MFT record scan (README benchmark: Vec Cache 3.756s / HashMap 4.981s / No Cache 12.3s, environment not stated). Evaluated and no longer a dependency — it is not in engine/Cargo.lock. Its convenience name selector surfaces a single name per record, whereas one searchable row per $FILE_NAME (so hard-linked paths are all retained) is the product’s requirement, and the streaming/deferred-record pipeline it feeds is fmf-specific. Raw record parsing is in-house in fmf-core/src/scan/ntfs.rs; the benchmark above stays here as the external reference point that scan throughput is compared against.
  • usn-journal-rs (wangfu91, MIT, updated 2026-05): MFT enumeration + USN monitoring + FRN path resolution. Read as a reference implementation (policy: do not depend on it).
  • windows-sys 0.61: complete FSCTL constants, MFT_ENUM_DATA, USN_RECORD, etc. The USN wrapper is implemented in-house (~200 lines).
  • memchr (memmem::Finder = SIMD substring), rayon, parking_lot, thiserror, tracing, xxhash-rust.

WinUI 3 (Windows App SDK)

  • Data virtualization: random access with a known count uses non-generic IList + INotifyCollectionChanged + IItemsRangeInfo + placeholders. Explicitly supported in current WASDK (MS Learn updated 2026-03). IList<T> alone does not work (#1809). ISupportIncrementalLoading has crash reports (#6883), avoid it. ItemsView/ItemsRepeater support neither interface. Setting ItemsPanel to anything other than ItemsStackPanel disables virtualization. https://learn.microsoft.com/en-us/windows/apps/develop/performance/listview-and-gridview-data-optimization
  • Tray / hotkey: no native support. H.NotifyIcon.WinUI + in-house RegisterHotKey + an HWND_MESSAGE hidden window (WM_HOTKEY).
  • DPI: the WinUI 3 template defaults to Per-Monitor V2.
  • MSIX × requireAdministrator is a poor fit (allowElevation etc. constraints, almost always rejected in Store review) → unpackaged + self-contained distribution.
  • Known constraints of elevated processes: D&D from Explorer is not possible (UIPI). ShellExecute directly from an elevated process launches the associated app elevated too → de-elevate via explorer.exe "<path>" (standard pattern).
  • WASDK 1.6+ supports Native AOT (official sample cuts startup by about 50%). However, the “instant launch” experience is best ensured by a resident tray + hotkey.

Security — v2 service separation (researched 2026-06-11, primary sources confirmed)

A privileged-indexer → non-privileged-UI design carries an information-disclosure risk: exposing file names and paths that should be invisible per ACL. The v2 threat model and defenses are in docs/SECURITY.md; decision records are ADR-0016/0017. Below is the supporting research:

  • PIPE_REJECT_REMOTE_CLIENTS (CreateNamedPipeW dwPipeMode): officially stated as “Connections from remote clients are automatically rejected”. Direct mechanism for remote rejection. https://learn.microsoft.com/en-us/windows/win32/api/namedpipeapi/nf-namedpipeapi-createnamedpipew
  • FILE_FLAG_FIRST_PIPE_INSTANCE: creating a second instance fails with ERROR_ACCESS_DENIED (officially stated). Defends against pipe-name squatting. Same source as above.
  • GetNamedPipeServerProcessId: a client can get the server process PID. The non-elevated UI compares it with the PID reported by QueryServiceStatusEx for the SCM-registered fmf-engine service. It cannot reliably open a LocalSystem process token or derive its session-0 identity, so token inspection is not the trust decision. https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-getnamedpipeserverprocessid
  • Anonymous access (caution): the default for anonymous restriction via NullSessionPipes is machine-type/policy dependent (enabled on DC/standalone, Not defined on member/client). Make an explicit DACL (no anonymous ACE = default deny) the primary defense for blocking anonymous access. https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-10/security/threat-protection/security-policy-settings/network-access-restrict-anonymous-access-to-named-pipes-and-shares
  • Deny-only Administrators in a UAC-filtered token: in a non-elevated process the BUILTIN\Administrators SID becomes SE_GROUP_USE_FOR_DENY_ONLY and is not used for allow ACEs (only for deny-ACE matching). A pipe DACL that “allows Administrators” cannot be connected to by a non-elevated UI → naming the user’s individual SID is mandatory. https://learn.microsoft.com/en-us/windows/win32/secauthz/sid-attributes-in-an-access-token
  • ImpersonateNamedPipeClient: the server can obtain and inspect the client’s token (SID matching at connect time = defense in depth against a misconfigured DACL). https://learn.microsoft.com/en-us/windows/win32/ipc/impersonating-a-named-pipe-client
  • RevertToSelf failure is process-fatal: Microsoft states that a failed reversion leaves the application running as the client and that the process should shut down. Returning an error and continuing the service thread is not a safe recovery path. https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-reverttoself
  • SERVICE_CONFIG_REQUIRED_PRIVILEGES_INFO (ChangeServiceConfig2): declaring required privileges makes the SCM strip undeclared privileges from the process token at startup (SeChangeNotifyPrivilege always remains; for shared-process services the union applies). Used to disarm LocalSystem. https://learn.microsoft.com/en-us/windows/win32/api/winsvc/ns-winsvc-service_required_privileges_infow
  • SERVICE_CONTROL_PRESHUTDOWN (caution): the default grace period is 10 seconds on Windows 10 1703 and later (3 minutes before that). Saving a large snapshot requires explicitly extending it via SERVICE_PRESHUTDOWN_INFO (dwPreshutdownTimeout). https://learn.microsoft.com/en-us/windows/win32/api/winsvc/ns-winsvc-service_preshutdown_info
  • windows-service crate (Mullvad, v0.8.1 2026-05, MIT/Apache-2.0): provides define_windows_service! and service_control_handler::register. A PRESHUTDOWN handler can be registered. https://github.com/mullvad/windows-service-rs
  • SeBackupPrivilege and raw-volume reads: what is documented goes only as far as “retrieving content of normal files by bypassing the ACL”. There is no documented guarantee that a raw volume handle to \.\C: can be opened with SeBackupPrivilege alone (research scope: Managing Privileges in a File System and others). Volume handles require admin (see “Privileges” item above) → the basis on which ADR-0017 rejected the dedicated low-privilege-account proposal. https://learn.microsoft.com/en-us/windows-hardware/drivers/ifs/privileges

On-demand service lifecycle (researched 2026-06-23, premise = ADR-0027)

The v2 service was registered SERVICE_AUTO_START (boot-resident). ADR-0027 moves it to demand-start + unelevated start/stop + idle stop + a daily GC. Supporting facts:

  • SetServiceObjectSecurity / per-service DACL: a service object carries its own security descriptor, and the SCM grants access per the service DACL. By adding an ACE granting a user SERVICE_START/SERVICE_STOP, that non-admin user can start/stop the service (the standard sc sdset / SetServiceObjectSecurity pattern). The dangerous bits (SERVICE_CHANGE_CONFIG, DELETE, WRITE_DAC, WRITE_OWNER) must stay admin-only — SERVICE_CHANGE_CONFIG lets the holder rewrite lpBinaryPathName, i.e. run arbitrary code as the service account (LocalSystem) = local privilege escalation. https://learn.microsoft.com/en-us/windows/win32/api/winsvc/nf-winsvc-setserviceobjectsecurity / https://learn.microsoft.com/en-us/windows/win32/services/service-security-and-access-rights
  • SERVICE_DEMAND_START / ChangeServiceConfig: CreateService with dwStartType = SERVICE_DEMAND_START registers a manual-start service (no boot launch); ChangeServiceConfigW(SERVICE_NO_CHANGE, SERVICE_DEMAND_START, …) migrates an existing AUTO_START registration. A stopped demand-start service is an inert SCM database row — no process, no RAM. https://learn.microsoft.com/en-us/windows/win32/api/winsvc/nf-winsvc-createservicew
  • MoveFileEx + MOVEFILE_DELAY_UNTIL_REBOOT: schedules a delete (lpNewFileName = NULL) processed at next boot via PendingFileRenameOperations; “can be used only … by a member of the Administrators group or LocalSystem”. The standard idiom for a running image deleting itself (the SYSTEM GC removing its own %ProgramData% binary + dir). https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-movefileexw
  • Task Scheduler as SYSTEM: a task with principal S-1-5-18 (LocalSystem) + RunLevel=HighestAvailable runs unattended with full privilege; StartWhenAvailable=true runs a missed daily trigger when the machine is next on. Registering from XML (schtasks /Create /XML) keeps <Command>/<Arguments> as separate elements, avoiding /TR command-line quoting pitfalls. Registration itself requires elevation (done during the one-time elevated install). https://learn.microsoft.com/en-us/windows/win32/taskschd/daily-trigger-example–xml-

Regex engine (rust regex crate, researched 2026-06-15, premise for first-class status = ADR-0023)

  • Linear-time guarantee, no ReDoS: the regex crate is implemented with finite automata (lazy DFA / Pike VM) and does not backtrack. Matching is linear in “input length × pattern length”, and the catastrophic backtracking that plagues regex services (ReDoS runtime exponential blowup) cannot occur structurally, as officially stated. Even malicious (a+)+$-style patterns run linearly. https://docs.rs/regex/latest/regex/#untrusted-input https://docs.rs/regex/latest/regex/#performance
  • Remaining attack surface = compile time/memory: when accepting untrusted patterns, the only DoS surface is the compile-time program/DFA size demanded by a huge pattern (bounded-repetition expansion like a{1000}{1000}). The crate provides RegexBuilder::size_limit (byte cap on the compiled program, default 10 MiB) and dfa_size_limit (byte cap on the lazy DFA cache, default 2 MiB); on overflow build() returns an Error (CompiledTooBig equivalent). nest_limit (default 250) caps parse-tree depth. The docs recommend tightening both size limits for untrusted patterns. https://docs.rs/regex/latest/regex/struct.RegexBuilder.html#method.size_limit https://docs.rs/regex/latest/regex/struct.RegexBuilder.html#method.dfa_size_limit
  • find-my-files uses 1 MiB each (with name length p99 ≈110B this is excessively generous; legitimate patterns never reach it, and malicious patterns are cleanly rejected with FMF_E_QUERY_SYNTAX). Decision and re-examination triggers are in ADR-0023.

Code signing — CI integration (researched 2026-06-25, premise for ADR-0029)

ADR-0020 fixed the provider/cert (SSL.com eSigner, individual IV). These facts drove the mechanism (official Action) and the buildsignpublish split:

  • Azure Trusted Signing (the 2026 managed standard) is unavailable here. Individual-developer onboarding is paused, and new tenants are limited to US/CA organizations with 3+ years of verifiable history — a Japanese individual cannot enroll. This closes the door ADR-0020 already noted (it was US/CA/EU/UK individual-only before). https://techcommunity.microsoft.com/blog/microsoft-security-blog/trusted-signing-is-now-open-for-individual-developers-to-sign-up-in-public-previ/4273554 / https://learn.microsoft.com/en-us/answers/questions/5810735/cant-create-a-new-trusted-signing-individual-ident
  • dotnet sign only delegates to Azure Key Vault / Trusted Signing. The modern Microsoft CLI computes a digest and submits it to AKV/Trusted Signing for RSA signing; it has no SSL.com eSigner backend. So the “most standard” CLI is not usable with this cert. https://github.com/dotnet/sign
  • The official SSLcom/esigner-codesign Action (batch_sign) is SSL.com’s recommended CI integration, and it works with this account. It downloads CodeSignTool, runs scan_code (pre-signing malware scan, required when the account has the malware blocker on), signs, and timestamps via SSL.com’s TSA. Only file hashes leave the runner. Proven green on this cert in CI (run 28082306344). It is SHA-pinnable (v1.3.2). sign/batch_sign also accept .msix. https://github.com/SSLcom/esigner-codesign
  • eSigner CKA (Cloud Key Adapter) was tried and fails in CI. CKA loads the cloud cert into the Windows store via a CNG KSP so the standard signtool can sign — but the KSP is 32-bit (x64 signtool reports “No certificates were found…”; x86 is required, per SSL.com), and even with x86 the sign call fails at credential retrieval: Signing credentials not configured … SignerSign() failed (0x80090003). This is a CKA-internal CSC credential path, not an account/PIN problem (the Action’s batch_sign signs fine on the same account). Rejected for CI; see ADR-0029. https://github.com/SSLcom/eSignerCKA / https://www.ssl.com/how-to/how-to-integrate-esigner-cka-with-ci-cd-tools-for-automated-code-signing/
  • signtool verify /pa /tw is the authoritative timestamp check. Exit 0 = chain valid + timestamped, 2 = signed but not timestamped, 1 = invalid — so any non-zero fails the build, catching a missing RFC 3161 timestamp (without which a signature dies with the ~460-day cert). https://learn.microsoft.com/en-us/windows-hardware/drivers/devtest/signtool
  • Get-AuthenticodeSignature.TimeStamperCertificate is unreliable for this assertion: it is null under -FilePath on the runner (a long-standing PowerShell bug), so the timestamp guarantee must come from signtool, not this property. The cmdlet is still fine for the signer-subject check. https://github.com/PowerShell/PowerShell/issues/4060

Releasing

The workflows are the executable release specification. This page contains only the human decisions that cannot be encoded safely.

Stable release

  1. Land Conventional Commits on main. Release Please maintains the version, lockfile, changelog, and draft Release PR.
  2. Confirm CI is green and run just ui-test unelevated.
  3. On a clean standard-user account, perform the one secure-desktop check automation cannot drive: accept the real UAC service-install prompt and confirm the same app process/window becomes searchable without relaunching.
  4. Add release: approved to the final Release PR head and merge it (a later head update requires removing and re-adding the label). Never edit the version or create a v* tag manually. Release Please creates the draft/tag, and release-please.yml immediately dispatches release.yml from protected main with that exact tag, commit, and draft Release ID.
  5. Run just perf-gate on the reference machine, cold and idle, before approving anything. CI cannot do this (ADR-0048): the measurement instrument requires an organization runner group that a user-owned repository cannot create, so this is the performance gate. A regression here stops the release.
  6. Approve sign and then the secretless publish-approval job in the protected release environment. Confirm the expected vX.Y.Z and immutable SHA each time. The subsequent API-only publication job obtains App credentials from release-please.

The workflow is the executable specification: preflight binds the approved tag, source SHA, and draft Release ID before anything else runs, every later job revalidates the same identities, and a stray tag cannot publish. Do not bypass or replay individual downstream jobs.

Verify the published artifact

$sum = (Get-Content -LiteralPath SHA256SUMS.txt) -split '\s+', 2
if ((Get-FileHash -Algorithm SHA256 -LiteralPath $sum[1]).Hash -ne $sum[0]) { throw "checksum mismatch" }
$firstParty = @(
  "FindMyFiles.exe", "app\FindMyFiles.exe", "app\FindMyFiles.dll",
  "app\fmf-service.exe", "app\fmf_engine.dll"
)
foreach ($path in $firstParty) { signtool verify /pa /tw /v $path }
gh attestation verify find-my-files-vX.Y.Z-win-x64.zip --repo P4suta/find-my-files

The Release must contain the zip, SHA256SUMS.txt, and both CycloneDX SBOMs. The ZIP must have the exact release identity plus Rust and .NET SBOM attestations.

If automatic dispatch fails after a draft was created, dispatch release-please.yml from main with that existing vX.Y.Z tag. It validates the tag/draft/target, fixes the draft’s numeric ID once, and re-dispatches release.yml for that exact tag commit and Release ID — skipping the dispatch if a run already owns that triple. If release-please itself is the thing that is broken, dispatch the release directly with the same three values:

gh workflow run release.yml --repo P4suta/find-my-files --ref main `
  -f tag_name=vX.Y.Z -f commit_sha=<40-hex tag commit> -f release_id=<numeric draft ID>

--ref main is not optional: workflow_dispatch loads workflow YAML from the selected ref. Any other ref is refused by preflight and, independently, by the protected-main deployment policy on the release and release-please environments.

Nightly

nightly.yml publishes a 14-day unsigned Actions artifact from main, stamped X.Y.Z-nightly.<date>+g<sha>. It receives the same SBOM scan and GitHub attestations as stable, but no Authenticode signature and no GitHub Release.

One-time repository setup still matters: apply .github/rulesets/ and enable immutable Releases. The checked-in default-branch ruleset is solo-maintainer-safe: status checks and conversation resolution remain mandatory, but approving, code-owner, and last-push reviews are disabled. Re-enable all three review gates only after a distinct maintainer has been added to CODEOWNERS and can provide the independent approval.

Install the Release Please App with Administration:read, Contents:write, Issues:write, and Pull requests:write. Store RELEASE_PLEASE_CLIENT_ID and RELEASE_PLEASE_PRIVATE_KEY only in the release-please environment. Restrict it to protected main. Keep the release environment restricted to protected main, with required reviewers, admin bypass disabled, and only the four eSigner secrets.

Performance baselines are recorded by hand on the reference machine, cold and idle: just bench-baseline for the real-volume baseline and just bench-micro-baseline for the Criterion suite. The real-volume result (engine/benches/baseline.json) lands through an ordinary reviewed PR; the Criterion baseline is machine-local. Never hand-edit or fabricate either.

Design rationale lives in ADR-0013, ADR-0020, ADR-0029, ADR-0034, ADR-0035, ADR-0038, ADR-0040, and ADR-0048.

ADR-0001: Index filenames only

Date: 2026-06-11 / Status: Accepted

Decision

The index holds only filename, size, modified time, and attributes. No content index, no property/tag index, no preview.

Rationale

  • Speed and RAM come from the “index filenames only” tradeoff. The RAM gate is engine-only ≤110B/file (M2), which is orders of magnitude incompatible with a content index
  • A content index inflates RAM by orders of magnitude (can reach 8GB-class)
  • Filename-only indexing lands at ≈100B/file (the ≤110B RAM gate is the target derived from this)
  • Real-world search syntax usage centers on substring, ext:, path:, size:, dm:; content:/regex: are niche (docs/RESEARCH.md)

Consequences

  • No search over file contents or meta-properties
  • Under the same scope freeze, FTP/HTTP/ETP servers, FAT/exFAT/network drives (MVP), ReFS (MVP), and cross-platform support are also out of scope

Re-examination triggers

  • The core (filename-only index, content index excluded) is a permanent decision (canonical source: the “do-not list” in AGENTS.md)
  • Volume-level NTFS indexing is the only production ingest path. The former folder-walk exception was removed with ADR-0024 because it duplicated the engine lifecycle while losing whole-volume coverage and USN replay.

ADR-0002: Linear pool sweep + incremental search (no trigram inverted index)

Date: 2026-06-11 / Status: Accepted

Decision

Search is a linear sweep of the folded name pool (SIMD memmem, rayon 64k-chunk parallelism). A re-query that provably narrows the previous query is handled by query::refine, which re-evaluates only the previous hit set (conservative subsumption rules in query/subsume.rs). No trigram inverted index.

Rationale

  • A synthetic 1M-entry cold 3-char query is about 2.9ms (query-cache MISS + derived-cache warm, materialize included). That is an order of magnitude below the criterion “per-volume scan_us p99 > 25ms @1M”
  • Posting maintenance costs +10-15B/file under the RAM ≤110B/file constraint, plus diff maintenance per USN batch. Not worth it
  • Incremental search is O(previous hit count), skipping both the scan and the O(n) materialize

Consequences

  • refine applies only under conservative subsumption rules (same sort, single AND group, needle containment / range shrink / filter addition only). Correctness is held by an oracle property test (refine == fresh search)
  • Kill switch FMF_QUERY_CACHE=0; observability via QueryTrace.cache (miss/refine/partial)

Re-examination triggers (only if all hold)

  1. Cache-MISS cold 3-char scan_us p99 > 25ms @1M
  2. Measured estimate from fmf stats --trigram-estimate ≤15B/file and total ≤110B/file
  3. Posting diff maintenance ≤2ms/batch
  4. Real demand for a single volume exceeding 4M entries

ADR-0003: Lossless WTF-8 storage, length-preserving fold, canonical search

Date: 2026-06-11 (amended 2026-07-26) / Status: Accepted

Decision

Stored names remain byte-exact WTF-8. The folded dictionary still applies only single-code-point lowercase mappings of identical encoded length; this preserves ADR-0004’s shared name-length layout and snapshot format.

Ordinary non-ASCII name/path literals and globs compare NFC query-time views. Valid scalar spans on each side of a lone surrogate normalize independently; the surrogate’s WTF-8 bytes are an opaque barrier. Explicit regex mode continues to evaluate the original spelling, because normalization would change regex offset and syntax semantics.

Candidate generation unions the existing raw folded-pool sweep with a non-ASCII dictionary completion pass. Because NFC and the length-preserving storage fold do not commute for every spelling, fold-nonidentical entries also receive an original-spelling completion pass; the source matcher is then verified as a residual from the original spelling. Normalization-inert ASCII queries take the original sweep and matcher path unchanged. ASCII ;, `, and K (plus folded k) opt into completion because the locked Unicode table has canonical singleton aliases for them. Canonical scratch is per-query/thread and retained only for reuse during that query; no normalized pool or snapshot column is added.

Consequences

  • NFC, NFD, and mixed canonical spellings match for substring, prefix, suffix, extension, path, and whole-name glob searches.
  • Ill-formed UTF-16 still round-trips exactly through stored WTF-8 and FFI.
  • Steady-state index RAM and the normalization-inert ASCII hot path are unchanged. Canonical queries pay a bounded dictionary scan plus an original-spelling pass over fold-nonidentical non-ASCII entries.
  • Multi-character lowercase expansion remains outside the storage fold.

ADR-0004: fold-overflow name layout

Date: 2026-06-11 / Status: Accepted

Decision

The only full-length pool that can be swept contiguously is the single folded lower_pool. The original text is stored in orig_pool only when it differs from the fold, referenced by orig_off (u32, sentinel u32::MAX = identical to fold). name() lends the lower slice directly for fold-identical entries.

Rationale

  • Real C: measurement (1,268,450 entries): fold-identical (lower == orig byte match) = 73.2%. About 3/4 of the double-stored names are duplicate identical bytes
  • Measured −16B/entry. The single largest term toward the M2 RAM gate (≤110B/entry)
  • Three soundness pillars: (1) the fold is length-preserving (ADR-0003) (2) original match ⇒ fold match (a superset sweep is sound; same algebra as bridge_needle in subsume.rs) (3) a fold-unstable needle (a needle differing from its own fold) cannot appear in a fold-identical name → an O(1) rejection via a single orig_off sentinel resolves 73% of candidates
  • Alternative (i) “sorted (entry, orig_off) pairs” is predicted at −19.6B/entry, 1.9B better than the u32-column approach (−17.7B), but adds a binary search per residual verification, so the u32 column was chosen to avoid p99 risk

Consequences

  • Needles containing uppercase (smart case) and Sensitive mode cannot sweep the original directly; they become a superset sweep of the folded needle + original-text residual verification
  • Accepted regression: real C: uppercase needle (“Win”, smart-case) p50 2.5→3.6ms (7% of the p99 budget of 50ms)
  • The snapshot shrinks by the same amount (FMFIDX04)

Re-examination triggers

  • If a name distribution is observed on a real volume where the fold-identical ratio collapses substantially (below 50%-class)

ADR-0005: FRN index is a sorted id permutation

Date: 2026-06-11 / Status: Accepted

Decision

The FRN→EntryId index is held only as a sorted id permutation (ids u32 = 4B/entry, index/frn.rs). The comparison key is the low-48-bit record number read by indirection into the full-FRN column. A key lookup returns the complete equal-key range across the unmerged tail and sorted body, always through the tombstone liveness filter.

EntryId identifies one directory link (one searchable path); the full 64-bit FRN identifies the underlying NTFS object generation. A hard-linked file therefore has multiple live EntryIds sharing one FRN. Link identity is (full FRN, parent directory EntryId, original WTF-8 name). At most one full-FRN generation may be live for a low-48-bit record number, and directories retain exactly one row.

Rationale

  • An FxHashMap implementation is ~25B/entry (16-byte slot + bucket capacity padding + control bytes; real C: frn row 31.2MB), the largest RAM term after the name pool
  • Splitting into two arrays keys u64 + ids u32 gives 12B/entry (frn row 31.2→15.1MB, WS 157→140B/entry; first time under the M0 gate ≤150B)
  • keys is a pure redundant copy of masked(frn[ids[i]]) → removed to reach 4B/entry (−8B/entry, ~10MB on real C:)
  • lookup is on the critical path only for the USN apply path and the builder’s parent resolution; the search hot path does not touch it. The +1 cache miss from indirection is acceptable
  • keeping duplicate FRNs in the existing id permutation adds no new per-row column and needs no snapshot or wire-format change
  • Side benefit: restore goes from a million serial hashmap inserts → one parallel sort, criterion load_1m 89.4→58.9ms (−34%)

Consequences

  • Deletion is tombstone-only with no unmap. A final object delete or record reuse tombstones every live row in the FRN range; removal/rename of one hard link targets its exact link identity. Size/mtime and object attributes update every live row sharing the full FRN.
  • USN_REASON_HARD_LINK_CHANGE is reconciled against the complete current link set; choosing one representative name or ignoring the reason is not an accepted degradation. USN reason flags accumulate, so a batch carrying both FILE_DELETE and HARD_LINK_CHANGE uses a three-state live read: a complete non-empty set wins and is reconciled, an exact proven-gone result removes all rows, and an incomplete/I/O-failed read rejects the whole batch before mutation, leaves its checkpoint unpublished, and forces a full rescan. The same preflight rule covers an ordinary rename whose exact old identity is missing while multiple links are live.
  • Initial build coalesces duplicate (full FRN, resolved parent, original name) identities before publication. Snapshot restore rejects both duplicate live identities and any live row whose parent is not a live directory.
  • Snapshot magic advances to FMFIDX08 without changing the column layout: FMFIDX07 can be structurally valid yet semantically incomplete because it may persist only one representative name for a hard-linked object, so it must trigger a fresh MFT scan.
  • The first-scan builder defers parent resolution and resolves it in bulk on the parallel path of finish() (per-lookup into the unmerged 1M tail is O(n²)). build_ms 13→64ms, invisible within the read-bound 2.1s scan

Re-examination triggers

  • If a design change lands where the search hot path requires an FRN lookup
  • If Windows ever permits directory hard links through a supported API (the one-row directory invariant would need replacement)

ADR-0006: Lazy sort permutations (only perm_name is always maintained)

Date: 2026-06-11 / Status: Accepted

Decision

The only always-maintained, persisted fast-sort permutation is perm_name. size/mtime order is a derived-cache lazy permutation (SizePerm/MtimePerm in query/memo.rs): one par_sort on the first sort query, then per-generation incremental extension via the same insertion-position merge as perm_name; not included in the snapshot (non-persistent).

Rationale

  • −8B/entry (two permutations’ worth) + ~8MB snapshot reduction. Many sessions never request a size/mtime sort
  • Initial construction is one par_sort, ~60ms-class @1M. One-off, so it does not sit on the always-on path of the query p99 gate (50ms)
  • Maintenance cost reduction: apply_batch_1k 6.67→1.96ms (−71.6%; permutations to merge go 3→1)
  • The only regression from going lazy is first_query_sorted_size +6.5% (2.0→2.1ms, within the 10% gate)

Consequences

  • The first size/mtime column click accepts a one-off construction cost (~60ms-class @1M)
  • An existing row’s size/mtime change increments a dedicated stat generation. The corresponding cached permutation is rejected immediately and rebuilt before the next sorted query; stale order is never observable, including from a query interleaved within the mutation batch.
  • After snapshot restore, the first use re-sorts.
  • Correctness is pinned by an extend oracle (byte-equality of lazy == fresh-sort). Watermark inconsistency triggers warn + lazy_perm_rebuild_fallbacks counter + full rebuild (does not go silent)

Re-examination triggers

  • Real demand for a single volume large enough that the measured first sort-click exceeds the perceptual threshold (100ms-class)

ADR-0007: size column is u32 + overflow map

Date: 2026-06-11 / Status: Accepted

Decision

Hold the size column as u32. For 4GiB and above, store sentinel u32::MAX and offload the real value to an overflow map keyed by entry.

Rationale

  • Measured on real C:: 10 of 1,268,450 files exceed 4GiB (0.0008%)
  • −4B/entry (u64→u32)
  • The sentinel branch in size() is effectively zero-cost; the map is negligible in size

Impact

  • The snapshot (FMFIDX04) gains a size-overflow section (ids+sizes). On load, structurally validate “all pairs ↔ sentinel correspondence, ascending order, truly overflowing”
  • Files that shrink below 4GiB are correctly returned to the u32 side via the sentinel

Re-examination trigger

  • If a volume where the share of files over 4GiB reaches the several-percent range (e.g. a dedicated video-archive machine) becomes a primary target

ADR-0008: USN batch merge via insertion-point binary search + in-place segment move

Date: 2026-06-11 / Status: Accepted

Decision

Applying a USN batch to sorted structures (perm_name, FRN index; index/mod.rs merge_sorted_tail) finds each batch element’s insertion point by binary search and moves the intervening segment once each with copy_within. No full-length element comparison, no full-length reallocation. Capacity is reserved with reserve_exact(max(add, len/64)).

Rationale

  • Batch ~1k vs existing ~1M. The full-length rebuild approach paid, per batch, a comparison against every existing element (for perm_name, a string comparison for every file in the index)
  • Measured: apply_batch_1k 54.6→2.0ms@1M (54.6→6.3ms from the insertion-point merge; 2.0ms with permutation lazification = ADR-0006 included). The ~30MB/batch reallocation churn on the FRN index side also disappears
  • Complexity is O(batch·log n) comparisons + one bounded memmove, no allocation
  • A doubling capacity policy puts a permanent 2× slack on the RAM gate. With a len/64 floor, the full-length copy is amortized over each ~1.6% growth, and the slack ceiling is also ~1.6%

Impact

  • Existing elements are not reordered. Because the id tie-break makes the sort result unique, byte-identity with the old code can be pinned in tests (random-batch comparison against a forward-merge reference)
  • In-place stat updates do not touch perm_name. They increment the dedicated stat generation so a cached perm_size/perm_mtime is rebuilt before it can serve another query (ADR-0006).

Re-examination trigger

  • If usage where the batch length is large relative to the existing length becomes the norm (in that regime, full-length merge wins)

ADR-0009: compaction is old-id ascending remap (no re-sort)

Date: 2026-06-11 / Status: Accepted

Decision

Compaction reclaims tombstone rows and dead bytes in the pool. Live entries are renumbered in old-id ascending order — because relative order is preserved, every (key, id)-ordered structure (perm_name, FRN index) is carried over by an O(n) filter+remap copy with no re-sort. The volume thread evaluates the threshold on each batch apply: sparse-row capacity is len≥100k AND tombstone_ratio>12.5%; absolute pool garbage (dead_name_bytes>32MiB) and dictionary churn (dict_appends_since_dedup>live_len/4) bypass that row floor. The bypass is required because a permanently small volume can otherwise accumulate unbounded abandoned names.

Rationale

  • Tombstone rows and the name bytes abandoned by renames accumulate without bound — a slow leak against the B/entry RAM gate (previously reclaimable only by a full rescan)
  • With old-id ascending remap, the relative order of live entries is preserved, so sorted structures can be filtered+remapped byte-equivalently (O(n), zero sort cost)
  • The decision inputs are dead_name_bytes observability (IndexStats.pool_garbage_ratio), tombstone capacity, and post-dedup dictionary appends. Thresholds are set on the premise of real-volume observation

Impact

  • The copy build runs under a read guard (queries run concurrently; the only writer is the single volume thread). The swap goes through install_index with a µs-scale write lock + structural generation bump
  • Result handles open across a compaction go hard STALE (FMF_E_STALE) → the UI auto-reissues the same query (existing mechanism)
  • Children of dead directories are reparented to root (same as the orphan policy of push_raw)
  • A defensive generation-check failure increments the compaction_aborts counter + discards the copy (detection of a broken single-writer invariant; does not stay silent)

Re-examination trigger

  • Observation of compaction_aborts > 0 (revisit the single-writer invariant)
  • If the thresholds show, in real operation, that compaction fires too often or reclaims too little (threshold re-tuning)

ADR-0010: snapshot is a raw POD dump + full validation, no backward compatibility

Date: 2026-06-11 / Status: Accepted

The decision below is the policy — a raw POD dump, fully validated on load, with no backward compatibility. That policy is what makes a format bump cheap, so bumps are expected and are not recorded here: each one belongs to the ADR that caused it (name/column changes in ADR-0031 / ADR-0032 / ADR-0033, semantic changes in ADR-0005), and the magic constant in force is the one in the code. FMFIDX04 below is the format as of this decision, not a current-state claim.

Decision

Persistence is a homegrown binary FMFIDX04: magic + UsnJournalID + last USN + raw column-array dumps + xxhash64. Sections are lower_pool / orig_pool / orig_off / name_off / name_len / parent / size_lo / size-overflow ids+sizes / mtime / frn / flag / perm_name. No backward compatibility — a version mismatch or validation failure is always Err → full rescan.

Rationale

  • Real C:: 92.4MiB for 1.27M entries (−28% from the old 128.6MiB format), restore p50 81ms — ample margin against the restore→ready ≤2s gate
  • A rescan is cheap at 2.0s (ADR-0011). Not worth the maintenance and test cost of migration code
  • On load, beyond the checksum, perform structural validation of all slice bounds and overflow correspondence (Err → rescan instead of panicking on corrupt input)
  • The size/mtime permutations and the FRN index are not persisted (parallel-sort rebuild at restore/first-use time is faster than a serial load: load_1m −34%, ADR-0005/0006)

Impact

  • Accept one full rescan per volume (2s-scale, requires elevation) on each format version bump
  • structural_generation is not persisted (0 at restore). Since result handles do not cross processes, in-process monotonicity is sufficient
  • Writes are temp → MoveFileEx(REPLACE_EXISTING). Failures go to the snapshot_load_failures / snapshot_save_failures counters

Re-examination trigger

  • If a scale where the initial scan takes minutes becomes a primary target and the felt cost of a rescan per version bump becomes a problem

ADR-0011: streaming scan pipeline (I/O multiplexing rejected)

Date: 2026-06-11 / Status: Accepted

Decision

The initial scan reads $MFT as buffered synchronous streaming in 16MiB chunks; a single dedicated I/O thread prefetches chunk N+1 (3 buffers fix the RAM ceiling), and within a chunk rayon parses 1MiB record-boundary subranges in parallel. Name resolution for $ATTRIBUTE_LIST (deferred) RAM-caches the extension records that carry $FILE_NAME during streaming (capped at 128Ki entries ≈ 128MiB temporary) and runs with zero disk reads. I/O multiplexing via NO_BUFFERING + overlapped is not adopted.

Rationale

  • Measured deferred path: 2.9s with the disk-read version → 8ms with the RAM cache. Random reads on \\.\C: are serialized in the kernel regardless of the number of outstanding handles, so they do not shrink with parallel I/O
  • Whole scan 5.0s→2.1s (read at 1.6s is the limiter)
  • fmf io-probe C: ($MFT 1.54GiB) measured: buffered sync 962.6 / +SEQUENTIAL_SCAN 960.9 / NO_BUFFERING sync 958.2 / NO_BUFFERING+overlapped QD2 1075.9 / QD4 1101.6 MB/s (+14.4%). Below the adoption bar (read +30% for Stage 2; adopt at whole-scan −25%). The whole-scan effect is projected at 2.0→~1.85s, not worth it given the current state already clearing the M2 scan gate (60s) by 30×
  • EntryId assignment appends worker batches in chunk order, so it matches the sequential version deterministically (admin test streaming_scan_matches_reference is the equivalence gate)

Impact

  • Temporary RAM: 3×16MiB pipeline buffers + one shared deferred-record arena (hard cap 128MiB across attribute-list base and extension records). Spills retain only the record number, increment the deferred-cache overflow counter, and fall back to targeted disk reads.
  • I/O thread startup failure increments the scan_pipeline_fallbacks counter + degrades to sequential reads (does not stay silent)
  • fmf io-probe is kept on hand as a measurement tool

Re-examination trigger

  • If a multi-volume concurrent-scan requirement arises, or the scan gate is tightened to 10s or below (re-evaluate Stage 2 overlapped multiplexing)

ADR-0012: keep the default allocator + RecordArena for scan temporaries (mimalloc rejected)

Date: 2026-06-11 / Status: Accepted

Decision

Do not swap out the global allocator; keep the default. Scan temporaries (the many ~1KiB records of the deferred/extension-record cache) stop using individual Boxes and are allocated contiguously into a slot-addressed RecordArena (scan.rs).

Rationale

  • Individual Boxes leave heap fragments after free that persist as a WS delta not visible in accounting. Going to RecordArena: real-C: steady-state WS 124.2→119.9MiB (−4.3MiB, consistent across 3 measurements)
  • mimalloc A/B measured (fmf-cli feature gate, after a real-C: scan): steady-state WS 119.9MiB → ~380MiB (+260MB). mimalloc keeps freed segments in its own cache and does not return them to the OS, so scan temporaries sit there. Query p50 improves a few percent, but it is out of the question against the WS gate (≤110B/entry) → rejected
  • RecordArena is a homegrown implementation with zero dependencies

Impact

  • RAM measurement is on the engine process WorkingSet basis (AGENTS.md performance pass line), so the allocator’s OS-return behavior lands directly on the gate — fix the premise that “small self-accounting” alone is not enough

Re-examination trigger

  • Only if mimalloc gains a stably-provided “return segments to the OS immediately” setting AND the WS gap (measured WS − self-accounting) widens beyond 10B/entry

ADR-0013: Measurement discipline (cold machine, back-to-back, real-volume absolute gate)

Date: 2026-06-11 / Status: Accepted

Decision

Performance judgments are fixed as follows: (1) baseline recording and perf-gate/bench-check only on a cold, idle machine; xtask compiles before measurement, refuses the run unless both the preflight and postflight have mean % Processor Performance >=95% and mean CPU time <=20%, and continuously monitors the same clock counter while the benchmark runs (2) criterion comparisons are limited to back-to-back A/B within the same session and run in a fresh CRITERION_HOME seeded only from the baseline (3) the micro gate requires the exact 28-report suite and reports a >10% regression only when the median 95% confidence-interval lower bound exceeds +10%; the two explicitly informational cases are present but ungated (4) the final judgment is the real-volume absolute gate plus a query p50 relative +50%. The pass line is recorded here and only here — initial index <=8s at 250k / <=60s at 1M, ready working set <=110 B/entry, query p99 <=50ms, restore p50 <=1s. These are ceilings chosen as the point below which the product stops feeling instant, not targets: the measured values sit far under them (real C: ~2s at 1.27M, p99 single-digit ms), and closing that margin is a regression even while the gate still passes. Any other document quoting a figure is quoting this list. The name distribution of the synthetic 1M benchmark is calibrated to measured real C: data (identical fold 73.2% / unique names 53.2% / mean WTF-8 length 29.7B), and build_synthetic asserts those ratios every run.

Rationale

  • This machine throttles to ~75% clock after a few minutes of all-core load, drifting p50 uniformly +30 to +46% (including snapshot restore, which is pure fixed-CPU work). Confirmed via simultaneous old/new A/B that “both equally slow = machine drift”.
  • criterion is also state-dependent: measuring the same code 40 minutes apart drifts +30% (parse_compile, a µs-class pure-CPU bench).
  • p99-of-50-runs is effectively max (a single OS hiccup trips it). Even at 200 runs it swings +-60% -> p99 is gated only by the absolute budget (50ms).
  • Synthetic criterion benches move +-12 to 23% from code layout alone (a synthetic “regression” that did not reproduce on real C: and was actually -4%). Real breakage shows up at +48% / 5x class, clearly outside the p50 relative +50% gate.
  • The pre-calibration synthetic index had all-unique, lowercase-only names, making it useless for judging pool/column layout.

Consequences

  • p50 regressions under +50% are not detected by the real-volume gate (detection is handled by the back-to-back micro gate when the median 95% CI lower bound exceeds +10%).
  • “all items including restore degrade uniformly” is treated as a thermal signature, not judged a code regression (re-measure cold).
  • The baseline is machine-dependent. A missing/dirty identity or a Cargo.lock, rustc, processor, logical-CPU, or volume-entry drift beyond 10% fails closed; re-record deliberately on the measurement host.
  • Baseline/check recipes are single xtask transactions rather than shared just dependencies. Each compiles first, checks immediately before and after its own measurement, and monitors the full run, so a real-volume run cannot heat the machine and silently invalidate the following micro run.
  • Criterion checks use a newly cleared run directory and require the complete expected report set; same-ID files from an older run cannot be consumed.
  • Baseline recording writes a candidate first. The candidate includes commit, dirty-content fingerprint, the semantic Cargo dependency graph (workspace-only version bumps are normalized; dependency/source/checksum drift is not), rustc, processor, timestamps, and counter summaries, and is promoted only after postflight succeeds. A failed or thermally invalid run cannot overwrite the previous baseline.
  • Retired by ADR-0048 (2026-07-28): the four CI measurement workflows below were deleted — their organization runner group cannot exist on a user-owned repository, so the chain never ran. The gate is now the manual just perf-gate on the reference machine, and the committed baseline is recorded with just bench-baseline and landed by PR. The measurement discipline itself — every threshold, preflight, and evidence rule above — is unchanged and still enforced by xtask. The remaining bullets in this section describe how CI would serialize the instrument, re-verify its evidence, and gate publication on it if an organization ever provides one.
  • Recording and gating are serialized on the same instrument by the default-branch performance-controller workflow and protected performance environment. The controller emits a run/attempt-only runner name and label; the external provisioner must roll the OS/workspace disk back before each job, obtain a JIT configuration, and launch the runner with --ephemeral. Jobs additionally require the static fmf-jit-ephemeral label and verify the exact Actions job label set plus RUNNER_NAME before checkout, so a standing runner is never queue-eligible.
  • Criterion baselines live on a separately attached P: volume with a protected SYSTEM/Administrators DACL. The trusted pre-checkout step rejects reparse points and same-volume storage, then copies the tree to disposable scratch and verifies a path/length/SHA-256 manifest. Repository code never benchmarks directly against the persistent source.
  • Each real and micro gate writes schema-1 deterministic evidence containing the target commit, semantic Cargo.lock identity, machine/counter identity, the complete expected case set, actual/baseline/delta/threshold/verdict, and finite/passed. A failed regression retains evidence but cannot authorize release. The hosted performance-release job downloads the exact run artifact, rejects every file outside the two-summary allowlist, independently recomputes Cargo.lock identity and every verdict, and accepts only two complete finite passing summaries.
  • Only a read-only hosted job may validate a real-volume baseline candidate. It passes the immutable candidate SHA-256 to a separate environment-gated write job, which re-downloads the exact-run artifact and verifies that digest before minting the narrow PR token.
  • Stable publishing requires a successful gate controller run for the already-created immutable release tag and an unexpired evidence artifact. Only a separate hosted workflow_run job can convert that completed result into a release dispatch; the measurement runner has no publish authority.

Re-examination triggers

  • If a thermally stable machine dedicated to measurement (constant clock >=95%) becomes available, reconsider tightening the relative gate.

ADR-0014: Build tooling rejection record and codegen-units=1

Date: 2026-06-11 / Status: Rejections recorded (codegen-units=1 accepted). Partially superseded by ADR-0041, which supersedes only the cargo-nextest rejection; the rust-lld and sccache rejections and the codegen-units=1 decision remain in force.

Decision

Do not adopt rust-lld or sccache. The release profile keeps codegen-units = 1 + lto = "thin" (engine/Cargo.toml). The historical nextest rejection below is retained as evidence but is superseded by ADR-0041.

Rationale

  • Fair A/B of rust-lld vs MSVC link.exe (3 crates in the engine workspace): fmf-cli incremental 1.72s vs 1.73s, full test link after fmf-core change 3.44s vs 3.46s — no difference. Zero measured improvement does not justify the risk of a non-standard linker (DLL output, CI divergence).
  • sccache rejected because it disables incremental compilation. cargo-nextest was initially rejected because the then-small suite showed no benefit; ADR-0041 later adopted it for bounded execution and per-test attribution.
  • codegen-units=1: rustc splits codegen units per module, so splitting the query kernel into exec/sweep/matchers/memo loses inlining and produces ~10% query latency (A/B measured in the same machine state). With 1 unit, hot-path inlining is independent of module layout.

Consequences

  • Release build time grows by the codegen-units=1 amount (acceptable).
  • The query kernel’s file-split refactoring can be done independently of runtime performance.
  • Build-speedup proposals should check this ADR first (re-proposal prevention).
  • rust-cache (Swatinem/rust-cache = GitHub Actions cache) is not a target of this ADR’s rejection: unlike sccache it does not wrap rustc invocations; it only archives/restores ~/.cargo and target as artifacts, so it does not break incremental compilation. CI’s CARGO_INCREMENTAL=0 is also CI-workflow-only and does not propagate to local incremental. CI speedups (parallel job split, shared-key cache sharing, dll artifact sharing, PR cancel-in-progress) fall under this and are permitted (ci.yml).

Re-examination triggers

  • Re-measure rust-lld only if the workspace grows and link reaches the tens-of-seconds class.

ADR-0015: WinUI 3 data virtualization (non-generic IList+INCC+IItemsRangeInfo)

Date: 2026-06-11 / Status: Accepted

Decision

Result-list virtualization uses non-generic IList + INotifyCollectionChanged + IItemsRangeInfo + placeholders (VirtualResultList). Do not use ISupportIncrementalLoading, ItemsView, or ItemsRepeater. ItemsPanel is fixed to ItemsStackPanel. VirtualResultList is a single instance with the same lifetime as the page (x:Bind OneTime), and ItemsSource is not swapped. New results are published via Reassign (apply prefetched seed + one INCC Reset); a re-query where the engine returns QueryTrace.unchanged=true (same query, ID sequence memcmp-equal across the whole volume) uses RefreshInPlace (no Reset, in-place fill of visible rows, count text unchanged).

Rationale

  • For random-access virtualization with a known count, “non-generic IList + INCC + IItemsRangeInfo + placeholders” is the explicitly supported path in current WASDK. IList<T> alone does not work (microsoft-ui-xaml#1809).
  • ISupportIncrementalLoading has crash reports, so avoid it (microsoft-ui-xaml#6883).
  • The three interfaces are one indivisible requirement, not a style choice. Each is load-bearing and each substitution has a known failure: dropping the non-generic IList (keeping only IList<T>) is the #1809 configuration and does not virtualize; reaching for ISupportIncrementalLoading instead of IItemsRangeInfo is the #6883 crash; dropping INotifyCollectionChanged leaves no way to publish a new result set without swapping ItemsSource. There is no partial adoption that degrades gracefully — the fallbacks crash or silently de-virtualize, which is why this set is fixed here rather than left to the call site.
  • ItemsView / ItemsRepeater do not support the above interfaces. Setting ItemsPanel to anything other than ItemsStackPanel disables virtualization.
  • Swapping ItemsSource discards the ListView’s virtualization state and reintroduces flicker.
  • Windows is never silent even when idle (USN batches from logs, telemetry, etc.). IndexChanged-driven re-queries return identical results every 200ms, so re-issuing Reset would churn the screen constantly — RefreshInPlace on unchanged (the MVVM setter notifies only on value change) brings redraw of the same screen to zero.

Consequences

  • IList residency contract (never falsely affirm membership). XAML consumes Contains / IndexOf / GetAt through the WinRT adapter and blindly trusts the answers, and the two ways of being wrong are not symmetric: a false “absent” is self-correcting (the container is simply re-realized), while a false “present” crashes deep inside XAML at GetAt(staleIndex), in frames this code cannot catch. Residency therefore fails closed and is defined narrowly = “index is less than Count, and the corresponding slot in the current page cache is that same instance”. A row belonging to an older result epoch, a row whose page the LRU has evicted, and a temporary row materialized for enumeration all answer absent. (Demonstrated: search with results -> clear all reliably reproduces an Int32.MaxValue-1 exception. Fix A/B: UIA stress went from 4 errors on the old code to 0.)
  • The indexer throws immediately out of range and never fetches (returns a placeholder). Enumeration/CopyTo do not disturb the page LRU (cap 4096 rows).
  • The UI-thread check in Reassign/RefreshInPlace is always enabled in Release.
  • In-place updates only update cells whose value changed (e.g. the size of a grown file).

Re-examination triggers

  • If WASDK officially provides known-count random-access virtualization (IItemsRangeInfo equivalent) for the ItemsView family.

ADR-0016: v2 service split — fmf-service + named pipe

Date: 2026-06-11 / Status: Accepted (only the duplicated contract constants + value-pin sync operation is superseded by ADR-0018)

Decision

Host the engine in a privileged service fmf-service (hosts fmf-core directly, LocalSystem), make the UI non-privileged (asInvoker), and connect over a named pipe. Wire definitions live in a new rlib fmf-proto, and PipeEngineClient becomes the third implementation of IEngineClient. The canonical spec is the wire definition itself, not a prose copy of it (later moved below fmf-proto into the fmf-contract leaf crate — ADR-0018). The FFI (fmf_engine.dll) and in-proc paths persist for now (--engine=inproc, requires manual elevation).

Rationale

  • The MVP’s requireAdministrator runs the whole app as administrator: UIPI kills Explorer→window drag & drop (known limitation in README), and “open” needed an explorer.exe de-elevation workaround
  • The design reserved this split from the start: the fmf-ffi no-logic rule, the IEngineClient swap boundary, the per-machine index under %ProgramData%, and the “shared with the pipe protocol” note in the error-code table
  • A resident service achieves “the index stays fresh via USN tracking even when the UI is not running”

Rejected transports

  • COM / RPC (out-of-process) — registry registration, marshalling definitions, and elevation-boundary complexity; worse wire observability vs. a length-prefixed named pipe
  • gRPC / HTTP (localhost) — network stack drifts toward the “won’t do” server features; dependency (tokio/tonic) clashes with fmf-core’s synchronous threading; HTTP/2 overkill for local IPC
  • Shared memory + events — fastest page transfer, but self-designing lifetime/permissions/generation loses the “1 FFI function = 1 message” mapping; unneeded since the pipe round-trip has budget headroom (ADR-0046)
  • async runtime (tokio) — at most a few connections; blocking I/O + threads fit the existing design; only adds dependency and build time

flush exposure surface (3 options)

The premise is to materialize Engine::flush() (VolumeSlot’s shared checkpoint + generation-pair dirty-skip). Three options for the exposure surface were compared:

  • Option 1: expose as a pipe opcode — rejected. Client-driven flush spamming repeatedly holds index.read(), a local DoS path that stalls USN application (SECURITY.md threat 6)
  • Option 2: not even in FFI, service-internal function only — rejected. The in-proc (–engine=inproc) path and tests cannot reproduce save timing, and it punches a hole in the contract mapping table (1 FFI function = 1 message)
  • Option 3: adopted — FFI fmf_flush is exported, while the pipe exposes no flush operation

Saving is a service-internal responsibility — periodic (default 300s, staggered across volumes, dirty only) + on SCM Stop/PRESHUTDOWN. Because the PRESHUTDOWN default grace has been shortened to 10 seconds on current Windows (docs/RESEARCH.md), set an explicit extension via SERVICE_PRESHUTDOWN_INFO at install time.

Distribution

MSIX/installer is deferred for this milestone (WindowsPackageType=None kept). Service deployment is established via fmf-service install (sc.exe cannot substitute, because SID capture, DACL setup, and privilege stripping must be done atomically) + a justfile recipe + README instructions. Switching to asInvoker is conditioned on a working service-deployment mechanism (default behavior when the service is not deployed: an InfoBar with explanation and a service-install action; search stays unavailable until setup succeeds).

Consequences

  • 2 new crates (fmf-proto / fmf-service). fmf-ffi and the DLL name fmf_engine are unchanged
  • The 3 synchronous IEngineClient methods (ListVolumes/StartIndexing/GetStatus) become Task-returning (sync across the pipe = a violation of the UI-thread “must not freeze” rule)
  • The single-writer invariant extends across processes: {index_dir}\.writer.lock + FMF_E_LOCKED=7
  • Both the Rust and C# test suites pin identical golden frames (byte sequences), fixing wire drift the same way as contract_tests
  • FfiEngineClient (--engine=inproc) remains the explicit elevated diagnostic fallback. The original one-release removal trigger is superseded by the production transport policy: the pipe is the default and in-proc is retained as an explicit elevated override rather than being removed.
  • drag-out (results→Explorer) is filed separately as a new feature outside this milestone (only the drop direction is resolved here)

Verification (measured 2026-06-11. The canonical numbers are ADR-0013’s pass line and ADR-0046’s latency budget)

Measured: first index 2.31s/1,268,560 entries; USN→event 250.9ms; kill→restart→restore 1.25s; real-C search p99 ≤5.6ms; engine working set 119.9MiB (~99B/entry). Pipe paging stays pinned at p99 ≤5ms by the loopback test. Lifecycle/security verification moved to ADR-0027.

Re-examination triggers

  • If environments where pipe page-fetch p99 exceeds 5ms become routine (re-evaluate a multi-page batch-fetch opcode, or shared-memory page transfer)
  • Real demand for concurrent multi-user use (fmf-service authorize <user> to register multiple authorization SIDs)

ADR-0017: Service security model

Date: 2026-06-11 / Status: Accepted

Decision

fmf-service runs as LocalSystem and, at install time, strips privileges to a minimal set via SERVICE_CONFIG_REQUIRED_PRIVILEGES_INFO (SCM removes undeclared privileges from the token — docs/RESEARCH.md). The pipe has 4-layer defense — (1) explicit SDDL (SYSTEM + only the user SID captured at install time) (2) PIPE_REJECT_REMOTE_CLIENTS (3) FILE_FLAG_FIRST_PIPE_INSTANCE (4) token check on connect (ImpersonateNamedPipeClient) — guaranteeing “same user only, reject remote, reject anonymous”. %ProgramData%\find-my-files gets a protective DACL at install time (SYSTEM+Administrators; user read only on the logs subdirectory). The standing threat-model document is docs/SECURITY.md (this ADR records the decision only).

Rationale

  • Adopt LocalSystem / reject a dedicated low-privilege account + SeBackupPrivilege: the verified fact only goes as far as “opening a volume handle (\.\C:) requires administrator”. There is no documented guarantee that SeBackupPrivilege grants raw volume reads (docs/RESEARCH.md — only describes ACL bypass for regular files). Rather than bet on an unverified privilege configuration, narrow the attack surface with the verified SYSTEM + privilege stripping + zero network capability + minimal pipe-surface opcodes.
  • Name the user SID / reject Authenticated Users: Authenticated Users RW lets other users on a multi-user machine search every file name (a name leak that bypasses ACLs). Allowing Administrators also fails: in a UAC-filtered token the Administrators SID becomes SE_GROUP_USE_FOR_DENY_ONLY and is not used in allow ACEs (docs/RESEARCH.md). So store the install-running user’s individual SID in service.json and use it in both the SDDL and the token check.
  • Handling OTS elevation (elevating with a different administrator account): when a standard user enters a different administrator credential at UAC, the install-running user (= the admin used to elevate) != the everyday user, and the everyday user can no longer connect to their own service. The non-elevated UI forwards its own SID to install via --owner-sid, and install validates it via validate_user_sid (accepting only SIDs for which LookupAccountSid returns SidTypeUser) before recording it alongside. The validation defends against threat 7 (injecting an arbitrary SID so someone else reads all file names) — install requires elevation and already has sc.exe-equivalent rights, but unresolvable / non-user-type SIDs are silently dropped (install itself does not fail).
  • Applying authorized_sids requires a restart: the service reads service.json once at startup and bakes that value into both DACL construction and the connect-time token check (immutable while running). So to add a SID to a running instance, install (idempotent append) must be followed by fmf-service restart (stop->start) — start alone is a no-op with ERROR_SERVICE_ALREADY_RUNNING and keeps rejecting with the old allow list (the root cause of the regression that appeared as repeated pipe client token rejected on real hardware). The app’s registration flow runs install->restart consecutively.
  • Reason for defense in depth: a mistake building the SDDL string is the accident pattern of “silently wide open”. Pin the structure of the build function with a non-elevated unit test, and place the connect-accept token check independently. Blocking anonymous access is primarily defended by the explicit DACL (no anonymous ACE = default deny) — do not rely on NullSessionPipes defaults, which are machine-type/policy-dependent (docs/RESEARCH.md). A normal SID mismatch disconnects that client. A token API failure stops the accept loop, while RevertToSelf failure aborts the process as required by Windows; continuing could execute later privileged work under the untrusted client token.
  • Protective DACL on %ProgramData%: under the default ACL a general user can directly read .fmfidx (which contains every file name) — no matter how hard the pipe is locked down, it leaks from the side. Leave user read only on logs (to keep the non-elevated F12 “copy diagnostic info” flow working).

Consequences

  • In addition to SCM registration, install atomically does SID capture -> service.json, the directory DACL, privilege stripping, and explicit SERVICE_PRESHUTDOWN_INFO (current Windows’ default grace is only 10 seconds) -> not expressible via sc.exe, so the fmf-service install subcommand is the only choice (making the logic unit-testable).
  • uninstall keeps data by default (--purge-data deletes .fmfidx/logs/service.json). The leftover artifacts are documented in README and SECURITY.md.
  • Client-connection prerequisites (verified on real hardware with the non-elevated UI): (1) the client opens the pipe at Identification level (C# TokenImpersonationLevel.Identification / Rust SECURITY_SQOS_PRESENT) — at the default anonymous level the server’s ImpersonateNamedPipeClient gets an anonymous token, and the connect-time SID check rejects even authorized users entirely. (2) The client-side fake-server check (threat 4) is done not by SYSTEM-token comparison but by PID comparison of the SCM-registered service (QueryServiceStatusEx) — the non-elevated UI cannot open a SYSTEM process’s token (ACCESS_DENIED) and cannot get the session-0 identity. Both were blind spots not exposed in console-mode tests where authorized_sids is empty and the token check is skipped; they only appear with the installed service.
  • “Reject other users” and “reject remote” are release-gated behaviors, not a manual checklist. The ignored FMF_ADMIN_TESTS=1 machine-security test creates a unique temporary local standard user through NetUserAdd, logs it on to obtain a genuinely different TokenUser SID, and proves both the production DACL denial and the independent verify_client denial behind a deliberately wide test-only DACL. It first proves the host’s remote transport with a PIPE_ACCEPT_REMOTE_CLIENTS control, then requires the otherwise-identical PIPE_REJECT_REMOTE_CLIENTS case to fail. An unavailable control is a failure, not a skip. RAII deletes the temporary account on success and unwinding.
  • Residual risk (accepted): an authorized user can also search the “name and path” of files invisible under their own ACL (a structural property of a file-name-only index; contents are unreadable). Documented in SECURITY.md.

Re-examination triggers

  • If a documented demonstration of a low-privilege indexer appears (e.g. a raw volume read means equivalent to FSCTL_READ_UNPRIVILEGED_USN_JOURNAL) -> re-evaluate demoting LocalSystem.
  • SERVICE_SID_TYPE_RESTRICTED + an explicit ACE on the index directory (a v2.1 hardening candidate).
  • Real demand for multi-user machines (UX for registering multiple authorized SIDs).

ADR-0018: Contract single source of truth (fmf-contract) + capture-first golden corpus

Date: 2026-06-11 / Status: Adopted (supersedes only ADR-0016’s “duplicate contract constants and sync by value pinning” practice. The named-pipe adoption, rejected transport alternatives, flush public surface, and distribution decisions are unchanged)

Decision

Introduce a dependency-free leaf crate fmf-contract (rlib) at the bottom of the dependency graph as the machine-readable source of truth for the engine contract (status codes, opcodes, event kinds, wire PODs, QueryOptions, limits, version numbers, pipe name), radiating a single definition to all Rust consumers (fmf-core / fmf-proto / fmf-ffi / fmf-service). For C#, the gen-contract binary inside fmf-contract radiates app/FindMyFiles/Engine/Generated/EngineContract.g.cs (constants, enums, [StructLayout(Explicit)] structs, CountersData DTO) as a checked-in generated artifact, and fmf-contract/tests/drift.rs continuously detects drift in the canonical nextest suite.

The contract semantics are carried by contract/golden/ at the repository root (manifest + byte streams + shared JSON fixtures) as an executable specification. The corpus is captured from the current implementation before the refactor begins (capture-first); thereafter both Rust (fmf-proto) and the independently hand-written C# codec (PipeProtocol/PageCodec) pin the same files. Re-capture (bless) only happens via explicit invocation with FMF_BLESS=1 — the ritual for an intentional contract change; normal test runs require a match against the existing bytes.

Additionally, limit the engine’s internal OS-effect seams to SnapshotStore / JournalSource (2 traits only) (to push the volume worker’s failure paths down into non-elevated, deterministic tests), and forbid additional porting beyond this cap.

Contract-change flow (one-directional radiation)

fmf-contract is the origin of a contract change, never a downstream copy of a prose description. An intentional change radiates in exactly one direction and in exactly this order:

  1. Edit fmf-contract. The definition itself moves first — status codes, opcodes, event kinds, wire PODs, QueryOptions, limits, version numbers, pipe name. An incompatible wire change also moves the pipe name here, not only a number.
  2. Re-capture contract/golden/ under the explicit FMF_BLESS=1 ritual (just contract-bless). Ordinary runs never re-capture; they must match the bytes already committed.
  3. Regenerate the checked-in C# binding with just contract-gen.
  4. Require both-language suites green — Rust (just test) and C# (just test-app) pin the same corpus, plus the drift test and fmf-ffi’s independent literal value pins.

The order is load-bearing and no step may be skipped: blessing before the definition moves seals the old bytes as the specification, and generating before blessing produces a binding that no captured frame proves. Prose that describes the contract is a reader of the crate, never an input to it — a document is allowed to fall out of date, whereas the crate cannot, because the generated binding, the golden corpus, and the value pins all fail when it does. The error-code table remains append-only with no renumbering.

Rationale

The duplication rationale was based on a misreading of Cargo

The current fmf-proto/src/lib.rs:3-5 and fmf-ffi/Cargo.toml claim that “fmf-ffi is a cdylib, so it cannot depend on / be depended upon, therefore the error-code table is duplicated and synced via value pinning in contract_tests.” This is false: only the direction “another crate depends on a cdylib” is impossible; a cdylib depending on an rlib is perfectly fine (fmf-ffi already depends on fmf-core, an rlib). Placing a dependency-free rlib at the bottom replaces “duplicated definitions detected after the fact in tests” with “one definition that cannot drift,” which structurally eliminates 3 of the 6 confirmed high-severity audit findings (duplicate code table, scattered event-kind magic numbers, unmet “pin the same golden bytes” claim).

capture-first (corpus first, refactor second)

Generating the golden corpus “from the new contract crate” would bake generator bugs into the spec itself (self-consistency trap: a test that only proves the generator agrees with itself). Capturing and sealing the current implementation’s bytes first means (1) “wire/ABI bytes unchanged” from S1 onward is proven by byte match rather than circumstantial evidence, and (2) the generator is put on the side required to “reproduce the captured bytes.”

Generation method: explicit command + check-in + drift test (not subject to ADR-0014)

gen-contract is not wired into MSBuild/build hooks (consistent with the no-custom-Directory.Build.props rule and ADR-0014’s rejection of build complexity). Equivalent guarantees come from explicit just contract-gen invocation + committing the artifact + drift verification inside just test. FieldOffset and similar values are taken from the offset_of! actual values of compiled Rust types, so there is zero hand calculation and value drift is impossible in the type system. Missing enum entries are detected three ways: drift + golden + a C# startup Marshal.SizeOf assert.

Rejected alternatives

  • Full Platform porting (~10 traits + new fmf-win crate): speculative generalization against the Windows-only charter; doubles I/O-seam maintenance permanently. Adopt only the 2 seams with demonstrated test value.
  • Wire version bump (pipe name v2 / event opcode cleanup / PROTOCOL_VERSION=2): contradicts the bytes-unchanged principle and ruins the captured corpus’s regression-oracle property; the benefit does not justify the ritual cost. Do it in a separate ADR if needed.
  • Macro DSL for contract definitions (contract_consts! etc.): over-machinery for ~40 constants + 6 PODs; plain definitions + a meta() function (direct offset_of!) give the same guarantee.
  • A dedicated crate just for gen: fmf-contract/src/bin/gen-contract.rs suffices and keeps the crate count at 6.
  • fmf-proto → fmf-core dependency (put the conversion layer on the core side): the contract source’s leaf property would no longer be enforced by Cargo. Unify the dependency direction to core→contract and eliminate the conversion layer itself.
  • Wholesale vocabulary replacement (scan→ingest / diag→obs etc.): permanently diverges from the language of 17 historical ADRs and degrades the “read the relevant ADR before changing structure” workflow. Adopt only “narration order = flow order”; naming is unchanged.
  • Full state-machine rewrite of the volume worker: rewriting concurrency invariants pinned only in prose (checkpoint-after-apply, compaction-generation recheck) cannot be proven old/new equivalent by new tests. Limit to behavior-preserving pure-function extraction + 2 seams.

Impact

  • 1 new crate (fmf-contract). At adoption, DLL name fmf_engine, pipe name fmf-engine-v1, ABI_VERSION=1, PROTOCOL_VERSION=1, FMFIDX04 were all bytes-unchanged (no version bump). Later ADRs have bumped these; the current values are fmf-contract::versions, which this ADR does not restate.
  • fmf-ffi’s contract_tests is promoted from “duplicate equality pin” to “literal absolute-value pin + ABI layout pin” and lives on — an independent tripwire where a downstream test catches an accidental edit of the single source itself.
  • C# decisions (user-confirmed): CountersData is also a generation target (counter additions auto-follow into C#); CancellationToken is fully propagated to ISearchResult.GetRangeAsync too (double defense with the epoch mechanism, fixed by a behavior test).
  • Migration is 11 stages (S0→S0.5→S1a→S1b→S2 strict order; S3⇔S4, S5a/S5b⇔S4/S4b may run in parallel). Each stage compiles standalone + all tests green, mergeable to main. fmf-core-touching stages (S1b/S3/S4/S4b) require just perf-gate green in an elevated shell as a merge condition.

S4 (scan.rs teardown) rollback clause

If the scan/ split exceeds the criterion 10% gate, immediately roll back to file consolidation before investigating the cause, and re-judge with ADR-0014’s measurement procedure (same-time alternating A/B against the baseline-commit worktree). Because of codegen-units=1, module boundaries should be neutral to inlining, but measurement takes priority over hypothesis.

Verification

just test and just test-app pin the same corpus and generated binding. Deterministic worker-failure tests run unelevated; just test-admin and just perf-gate cover the real-volume path before release. The stable release workflow independently reruns just verify and the elevated just test-admin suite on the exact release ref before signing.

Re-examination triggers

  • A real regression in an admin-only failure path that the 2 seams cannot cover (port addition gets its own ADR then)
  • Contract-change frequency rises and the bless ritual becomes friction (re-evaluate build integration of generation)
  • pipe page-fetch p99 > 5ms becomes the norm (inherits ADR-0016’s re-examination trigger)

Appendix: old→new path mapping (for history investigation; aids git log --follow)

OldNew
fmf-proto codes/PIPE_NAME/PROTOCOL_VERSIONfmf-contract codes/versions (proto re-exports)
fmf-proto QueryOptionsWire/WireRow/EventWirefmf-contract pod::{FmfQueryOptions, FmfRow, FmfEvent}
fmf-ffi FMF_* constants / POD definitions / volume_bytesre-exported from fmf-contract / volume::encode_label
fmf-ffi error_chain / fmf-service/dispatch error_chainfmf-core diag::error_chain (4KiB cap)
fmf-core engine::VolumePhasefmf-contract options::VolumeState (name unified too)
fmf-core scan.rs (1165 lines)scan/{mod,volume_io,pipeline,parse,deferred,probe}.rs
fmf-core engine/volume.rs thread bodyengine/worker.rs (+seams.rs+worker_tests.rs)
fmf-cli main.rs (878 lines)main.rs (135 lines)+cmd/{index,stats,bench,io_probe,criterion_gate,diag}.rs+bench_support.rs
C# NativeEngine struct / status constantsEngine/Generated/EngineContract.g.cs (generated, partial NativeEngine)
C# DTOs inside IEngineClient.csEngine/EngineTypes.cs (CountersData moves to the generated artifact)
C# connection / result handle inside PipeEngineClient.csEngine/Transport/{PipeConnection,PipeSearchResult}.cs
C# MainPage.xaml.cs (452 lines) viewport/perf/converterControls/ResultsViewportManager / Views/PerfPanel / Converters/UiConverters (181 lines remain)
C# App.xaml.cs 3 exception handlersServices/ExceptionPolicy.cs
per-test unique tempdir duplication (%TEMP%)fmf-core index::testutil::TestDir (build/engine/test-tmp, RAII)

Stage commits: S0=9f7f4a6 / S0.5=c3916df / S1a=c9eb007 / S1b=fdb5407 / S2=7ce58e7 / S3=6855336 / S4=4e99077 / S4b=261fbb7 / S5a=289e60a / S5b=540d79c / S6a=6226ea8 / S6b=287f659+9d7a30d (+doc convergence commit).

Appendix: starting-point record (at refactor start)

  • Baseline commit: 97df250 (= feat/v2-service-split complete, ff-merged to main)
  • Measured values (2026-06-11, from ADR-0016 verification section): first index real C: 2.31s @1,268,560 entries / USN→event 250.9ms / kill→restore 1.25s (restore p50 108ms) / search p99 ≤5.6ms / loopback ResultPage p99 ≤5ms / RAM ~99B/entry (WS 119.9MiB @1.27M)
  • Non-elevated gate (just verify) green confirmed: run right after branch creation 2026-06-11 — fmt-check / clippy -D warnings / nextest workspace suite / C# tests all pass

Appendix: final gate judgment (2026-06-12, all stages complete)

  • FMF_ADMIN_TESTS=1 (elevated): green — streaming_scan_matches_reference (scan/ split equivalence gate), real C: E2E, USN live, service kill→restore all pass
  • Real-volume absolute gate (just bench-check, elevated): green, no regression — 1,289,867 entries 2.05s / search p99 all queries ≤6.2ms (gate 50ms) / restore p50 79ms (gate 2s)
  • criterion 10% gate: 2 items exceeded initially → adjudicated with ADR-0013’s alternating A/B/A:
    • post_usn/apply_batch_1k +10.6%: noise. Re-measuring identical code (97df250 itself vs its own baseline) gives CI −5.9%~+10.4% — this bench’s intrinsic spread is about the same as the threshold. Re-measurement B was +2.5% (p=0.52)
    • parse_compile +13.7%→re-measure +5.7% (reproducible): a real difference but accepted — the absolute value is ~100ns/query out of 1.9µs (0.0002% of the p99 budget of 50ms). Probable cause: contract unification made SortKey/CaseMode repr(u32) (formerly rustc’s default 1 byte), or a code-layout shift from changed declaration order. Since the source of truth (real-volume absolute gate) is green with wide margins on all items, it does not meet the trigger condition for the S4 rollback (file-consolidation rollback)
  • criterion “committed” baseline re-recorded at the refactor tip (baseline for the next optimization session)

ADR-0019: Focused mode (focused search) is a pure query rewrite in the UI layer

Date: 2026-06-12 / Status: Accepted

Decision

For the request “the files a general user looks for are limited in both directory and format”, focused mode is realized as a pure query rewrite in the UI layer (ViewModels/FocusedQueryRewriter.Compose — static, no side effects). It does not touch the engine (Rust), the wire contract, or the index at all.

  • Split the user query on top-level | (do not split | inside quotes — same quoting rules as the engine’s tokenizer), append config-derived suffixes to each OR group, and rejoin:
    • Excluded paths: for each entry p, !path:"p" (quoted negation — noise areas such as \windows\)
    • Format whitelist: ext:e1;e2;… as one term (the value of ext: is OR semantics, so do not add more terms)
  • Collision avoidance (the user’s explicit intent always wins): if a group contains ext:/regex:, do not append the ext whitelist; if it contains path: or \, do not append excluded paths. The check is a simple substring test on the group string (over-matching only ever falls toward “skip the append” = the safe side).
  • An empty query is returned empty (the rule “do not throw an empty query at the engine” remains owned by the Orchestrator). An excluded-path config value containing " is unescapable in the query language, so it is ignored + warned (first time only).
  • Settings live in %APPDATA%\find-my-files\settings.json (UI-owned): focused_search (default true) / focused_exclude_paths / focused_extensions. The UI is a ToggleButton next to the search box; a toggle change is a filter-originated re-query (RequeryOrigin.Filter = reset to top).
  • SearchOrchestrator.FocusedSearch defaults to false (to keep existing tests and existing behavior intact). Only the product wiring (MainViewModel) feeds in the settings’ true.

Rationale

  • Everything is expressible in the existing query language: ext: is a 1-term OR (;-separated), path: supports quotes + ! negation, | is an OR group (fmf-core/src/query/ast.rs). No new operator or new filter mechanism is needed.
  • The residual cost is proportional to hit count: the engine’s linear sweep is unchanged, and the appended terms only work to reduce candidates. The rewrite itself is string concatenation (per keystroke, a few µs) and does not load the latency budget.
  • No engine contact = no perf-gate needed: since fmf-core is untouched, it can ship without the elevated-bench / regression-gate ritual. Wire bytes, contract, and golden corpus are also unchanged.

Rejected alternatives

  • In-engine preset filter (index bit): precomputing a “noise-area flag” per entry would make the residual cost nearly zero, but it incurs an index layout change (RAM budget, snapshot version), a full recompute on config change, and an ownership cross between UI settings and the service-owned index. This is an optimization to consider after the rewrite approach is measured to be slow, not a cost to pay upfront.
  • Ranking (relevance-order sort): the proper way to get “exactly a few hits” is scoring, not filtering, but it needs an entire scoring foundation (feature values such as usage frequency, recency, and path depth, plus a permutation cache) and is not orthogonal to the current lazy-sort permutation (ADR-0006). Noted as future work — not built while filtering suffices.

Consequences

  • Change surface: FocusedQueryRewriter (new) + 3 AppSettings keys + one rewrite point in SearchOrchestrator
    • a ToggleButton in MainPage. Engine, contract/golden, and Generated are unchanged.
  • The query notation in the F12 panel/logs is post-rewrite (the string the engine actually saw) — when investigating, keep the two lists in settings.json in mind.
  • Because it is ON by default, triage “the file should exist but does not show up” inquiries by first turning the toggle OFF (the existence of exclusions is already noted in the tooltip).

Re-examination triggers

  • Focused-ON search p99 > 50ms (exceeds the performance pass line — re-evaluate an in-engine preset filter).
  • When filtering’s “exactness” falls short and ranking (a scoring foundation) becomes necessary.

ADR-0020: Code-signing provider selection (SSL.com eSigner / individual IV)

Date: 2026-06-13 / Status: Accepted (active — IV certificate obtained 2026-06-24; workflow is the executable runbook)

Certificate holder: CN=Yasunobu Sakashita (SSL.com individual IV, code-signing EKU), issued via SSL.com Code Signing Intermediate CA RSA R1.

Update (2026-06-25): the provider, certificate, and signing Action decided here are unchangedrelease.yml still signs with the official SSLcom/esigner-codesign Action (batch_sign) and the staging map described below. What changed is the pipeline around it: see ADR-0029 — a buildsignpackagepublish split with the secrets behind an approval-gated release environment, read-only packaging, write/OIDC-only publication, and a hardened verify (signtool /pa /tw timestamp + signer-CN). (An eSigner CKA + standard signtool mechanism was trialled to drop the staging/copy-back but failed in CI and was reverted — ADR-0029.) Azure Trusted Signing has since paused individual onboarding entirely (US/CA orgs, 3+ years only), reinforcing the rejection below.

Decision

Authenticode signing of the distributed binaries is done with SSL.com eSigner (a cloud HSM signing service) + a personal Individual Validation (IV) certificate. Signing is kept as a CI-environment-specific YAML step in the trusted-main, dispatch-only release.yml (ADR-0048; originally reusable-only), not placed in xtask/; the Release Please tag and commit are validated inputs, never the workflow source. A real release is fail-closed: all four signing secrets, a valid signature, timestamp, and expected signer are mandatory. There is no credentialed unsigned rehearsal path.

The signing targets are only our own PEs, including the managed application assembly. Their executable manifest lives in xtask and is mirrored by .github/actions/verify-signatures/first-party-pes.txt; counts are deliberately not duplicated in this ADR. Bundled .NET / WindowsAppSDK runtime DLLs retain their Microsoft signatures. Same-named first-party files are staged through unique names before batch signing.

Rationale

  • Azure Artifact Signing (formerly Trusted Signing) not adopted: it is managed and easy to integrate into CI (release.yml was originally wired to this service), but as of 2026 the personal tier is limited to US/CA/EU/UK, and individuals residing in Japan cannot apply. Eliminated by the geographic requirement.
  • EV not adopted (IV adopted): since March 2024, EV no longer grants instant SmartScreen trust (Microsoft official). SmartScreen is purely reputation-based — reputation accrues from the signer certificate + file hash via download history — and “first-time warning -> cleared by track record” is the same for EV/OV/IV. This app does not ship a kernel driver (do-not-do list), so EV’s remaining practical benefits (driver signing, corporate procurement requirements) do not apply. IV, the cheapest and obtainable under a personal name, is the rational choice. The budget (100,000 yen/year) puts EV in range too, but the consideration is “title only”.
  • SSL.com eSigner adopted: cloud HSM signing needs no hardware token on the runner. Fully unattended CI signing via TOTP. A GitHub Action (SSLcom/esigner-codesign) exists. It supports both personal IV and Sole Proprietor EV (no corporate registration required), and is obtainable from Japan. Best fit for the “fully outsourced managed signing” requirement.
    • The alternative Certum personal (about $50/15 months) is cheapest but SimplySign requires a phone OTP per signature, which is a poor fit for unattended CI. SignPath Foundation (FOSS, free) requires review and may put new projects on hold. Both are inferior on the “throw-it-over-the-wall managed” requirement.
  • Keep signing as a YAML step (not in xtask): signing is CI-environment-specific processing that depends on GitHub Secrets and an Action; it is not the “portable release procedure logic” that xtask/ consolidates. Follows the precedent set by the Azure version (a YAML step).
  • Sign in-house PEs only: re-signing MS runtime DLLs wastes eSigner quota and is meaningless signing of others’ copyrighted work. Collect the manifest-defined set in a staging directory, batch_sign (1 OTP), and after copy-back hard-verify chain/timestamp with signtool /pa /tw plus signer identity with Get-AuthenticodeSignature (do not silently succeed unsigned when signing was requested = the “do not stay silent” principle).

Consequences

  • The signing step in release.yml uses SSL.com eSigner. HAVE_SIGNING requires ES_USERNAME, ES_PASSWORD, CREDENTIAL_ID, and ES_TOTP_SECRET; the run fails before signing if any is absent.
  • Publicly trusted certificates expire after at most ~460 days (CA/Browser Forum 2026). Renewal updates the release environment secrets when the credential or TOTP changes.
  • Signing is limited to release.yml dispatched from protected main against an exact tag/SHA/draft triple (ADR-0048; originally, reusable calls from the protected-main controller after exact tag/SHA performance evidence). ci.yml (PR/push) does not sign (do not distribute development intermediates, conserve quota, fork PRs cannot access Secrets).

Re-examination triggers

  • If Azure Artifact Signing opens to individuals in Japan, re-evaluate on managed-ness and CI affinity.
  • If this project comes to have a kernel driver, EV becomes a mandatory requirement.
  • If a corporate EV procurement requirement (enterprise distribution, store requirements, etc.) arises, reconsider Sole Proprietor EV / corporate EV.
  • If SmartScreen’s reputation model changes and first-time behavior again differs by signing type, revisit.

ADR-0021: Consolidate build output into a single build/ tree

Date: 2026-06-14 / Status: Adopted

Decision

Consolidate all build artifacts — both cargo target dirs, C# bin output, the publish bundle, the release package, the SBOM, and the assembled docs — into a single build/ tree at the repository root. The individual subdirectory names are not restated here; xtask/src/paths.rs is their source of truth.

Mechanism (all means that do not violate the prohibition rules):

  • Rust: per-workspace [build] target-dir in .cargo/config.toml (engine/.cargo../build/engine, xtask/.cargo../build/xtask). Relative paths resolve against the .cargo/ parent (confirmed empirically with cargo metadata). A single config at the repository root is rejected (both workspaces would share one target and break the ADR-0018 separation rule).
  • C# bin: each csproj’s BaseOutputPath (..\..\build\app\<proj>\).
  • dist/package/site: xtask/src/paths.rs as the single source of truth — every derived path is a function there, so no other file (including this one) may spell one out.
  • mdBook: build.build-dir = ../build/docs-book in docs/book.toml.

Rationale

  • Artifacts were scattered across engine/target, xtask/target, app/**/bin, root dist/, root zip, root SBOM, and site/, making them costly to track and clean. A single build/ means “delete it and everything is gone” plus an effectively one-line .gitignore.
  • The target-dir in .cargo/config.toml is not a toolchain pin, so it does not violate the rule against placing rust-toolchain.toml/global.json (avoiding double management with mise).

Consequences

  • C# obj stays put (app/**/obj/). Relocating obj requires BaseIntermediateOutputPath to take effect during pre-restore evaluation, which effectively requires Directory.Build.props, but AGENTS.md prohibits that file (it silently shadows the analyzer injection of winapp run). obj is intermediate output and already gitignored, so there is no real harm.
  • The dev-tree fmf-service.exe lookup (ServiceSetup.cs production + pipe/contract tests) follows build/engine/release.
  • Bundle internals: the dist/FindMyFiles/ root holds only the launcher + README.txt; the self-contained app and engine binaries publish into dist/FindMyFiles/app/ (the .NET apphost must stay co-located with its runtime DLLs, so it cannot move to the root). The root FindMyFiles.exe is a tiny native launcher (the fmf-launcher crate) that spawns app/FindMyFiles.exe — so a downloaded/extracted zip has one obvious thing to run. paths::app_dir() is the single source for the subfolder; the app’s own relative discovery (AppPaths, ServiceSetup.LocateServiceExe) is unchanged because everything it needs stays beside the apphost in app/.
  • The test-tmp fallback default in testutil.rs is build/engine (because the config.toml target-dir does not set the CARGO_TARGET_DIR env var).
  • CI (ci/release/pages) artifact, SBOM, package, and Pages paths are all updated to under build/. site/ remains the committed landing source; assembly output goes to build/site.
  • Tools that assumed the old engine/target etc. (rust-analyzer, etc.) follow because they respect config.toml (reload if needed).

Re-examination triggers

  • If demand to also remove C# obj from the root grows strong and the winapp run analyzer-injection mechanism changes to no longer depend on Directory.Build.props (re-evaluate whether to allow props).

ADR-0022: OS/shell/UI boundaries must use testable seams + behavioral tests

Date: 2026-06-15 / Status: Adopted, extended in place — live UI automation and mutation testing were later promoted from supporting practice to required gates, which strengthens this decision rather than changing it. Tool versions are pinned in mise.toml and the tool configs, not here.

Decision

Code that touches the OS, shell, processes, file I/O, or UI events must go through an injectable seam (an interface, or an internal core with paths/dependencies passed as arguments), and must come with tests that verify its behavior via just test / just test-app. Do not ship with only pure helpers or argument construction tested while “actual behavior is unverified.”

Canonical patterns: app/FindMyFiles/Engine/IEngineClient.cs (Fake/Ffi/Pipe), Services/IDispatcher.cs, Services/IProcessRunner.cs / Services/IRevealApi.cs, the path-parameterized core of Services/FileLog.cs. On the engine side, engine/crates/fmf-core/.../seams.rs (SnapshotStore / JournalSource; the two-seam cap is ADR-0018).

Rationale

  • “Open folder and select file” (reveal) was broken from day one: the actual behavior of ShellOps.Reveal (SHOpenFolderAndSelectItems) was never tested; only the pure helper BuildOpenStartInfo was green, and CI kept passing. The tests did not guarantee quality.
  • Root-cause type: if the runtime/OS boundary stays static + direct P/Invoke, behavior cannot be swapped with a fake and behavioral verification cannot be written. Argument/structure tests do not make “passes = not broken” hold.
  • The C# coverage gate being Threshold=15 (nominal only) also allowed unverified code to ship.

Consequences

  • New boundary code is required at review to have “seam + behavioral test” (construction-only tests are deemed insufficient).
  • UI-adjacent logic stays in ViewModels/core for deterministic unit coverage; the published bundle is additionally driven end-to-end by live UI automation (just ui-test), which is a required release/CI gate. It complements, and never substitutes for, the ViewModel/core behavioral tests — an automated click cannot assert an invariant the ViewModel does not expose.
  • Mutation testing is a required gate, not an advisory score, because it is the only mechanism that detects vacuous tests (those that pass even when the code is broken): Rust = just mutants, C# = just stryker, both = just mutation. xtask owns the fixed invocations and canonical report parsing. The Rust run is non-shuffled and must pass cargo-mutants’ copied-tree baseline; C# first passes the ordinary locked unit suite and Stryker’s own initial run.
  • Policy is the exact canonical survivor identity, never a percentage. Reviewed equivalent survivors live beside each tool config in engine/mutation-baseline.json and app/FindMyFiles.Tests/mutation-baseline.json, with a specific rationale per accepted identity. The same files pin the exact sorted source-file inventory, so a glob/config wiring omission cannot pass vacuously. For C#, xtask also resolves every exact mutate entry and requires that set to equal the baseline before Stryker starts. Stryker’s whole-project JSON is accepted only under a closed-world rule: outside-scope mutants must be Ignored for the exact exclude-filter reason, and inside-scope Ignored is allowed only for its exact redundant nested-block optimization (Block removal mutation plus Removed by block already covered filter). Those optimizer identities and all outside-scope status counts remain in gate.json; they are never silently treated as killed mutants.
  • New survivors, disappeared accepted identities, and file-inventory drift all require review. Malformed/missing JSON, duplicate keys or identities, an unexpected exact report schema, a Stryker exit/report mismatch, Rust timeouts, and C# timeout/no-coverage/non-redundant-ignored/non-terminal outcomes fail.
  • The weekly/on-demand workflow runs Rust and C# independently without continue-on-error. Stable release re-runs both gates on the exact immutable source commit in a secretless job, and signing cannot start until it passes.
  • The C# coverage gate is raised incrementally from 15% (ratchet).

Re-examination triggers

  • If the winapp ui public-preview surface changes incompatibly, keep its pinned version until the release suite is migrated and green.
  • Signs that seam proliferation distorts the design (the engine side keeps the two-seam cap = ADR-0018).

ADR-0023: First-class regular expressions (literal-prefilter driven + compile limits; trigram still not adopted)

Date: 2026-06-15 / Status: Adopted

Decision

Promote regex search from hidden syntax (regex: typed by hand) to a first-class feature. Three parts:

  1. Whole-query mode as a contract flag. Add regex_mode:u32 (16→20B) to FmfQueryOptions. bit0 = interpret the entire query as a single regex, bit1 = scope (0=name / 1=full path), upper bits reserved 0. The UI switches via a gear-menu toggle plus a “target” submenu. Hand-typed regex: coexists as before. Expressing this via query rewriting is rejected (|/!/"/whitespace would be doubly interpreted as the parser’s AND/OR/NOT, making whole-query mode impossible to express safely).
  2. literal-prefilter driven. Run the regex through regex_syntax prefix/suffix literal extraction, feed the required literal into the existing folded-pool linear sweep (Driver::Sub) to narrow candidates, and confirm with the regex body as residual. Name scope only. Cases where extraction fails (\d+, leading .*, alternations with no common factor) go full-scan + rayon. The single most recent compile is cached inside the engine (skips recompilation on USN re-query / RefreshInPlace).
  3. Compile limits. Set RegexBuilder size_limit/dfa_size_limit = 1MiB each. Overflow yields regex::Error::CompiledTooBig → existing CompileError::RegexFMF_E_QUERY_SYNTAX(5). No new error code is added.

Because this is an incompatible wire change, bump the pipe name fmf-engine-v1v2 and raise ABI_VERSION/PROTOCOL_VERSION to 2 (per the rule “incompatible changes bump the name too”).

Rationale

  • Consistency with ADR-0002 (most important): the prefilter is not a trigram inverted index. What ADR-0002 rejected was “maintaining n-gram postings as a resident index” (RAM +10–15B/file, diff maintenance per USN batch). This prefilter only extracts literals from the regex at query-compile time and linearly sweeps the existing pool — zero resident index, zero RAM increment, zero USN diff maintenance. It merely applies ADR-0002’s core “linear pool sweep” to regex too, and does not contradict the decision.
  • Linear-time guarantee: the Rust regex crate uses finite automata (lazy-DFA/Pike VM, no backtracking), so match execution is linear in input length → ReDoS runtime exponential blowup is structurally absent (corroborated: docs/RESEARCH.md). The remaining attack surface is compile time/memory (expansion of huge patterns). The index is filenames only (p99 ≈110B), and legitimate name regexes are on the order of tens of bytes and never reach a 1MiB program = without catching legitimate users, this tips toward “politely reject” rather than compiling a malicious pattern inside the elevated service. Set stricter than the defaults (10/2 MiB).
  • Prefilter correctness: what prefix/suffix extraction returns is “the literal that every match has at its start (end).” Its longest common factor S exists contiguously in every match → exists in the name. Folding S and sweeping the folded pool yields a superset in both case modes (the original matching implies folded matching, length-preserving), and the regex residual confirms exactly. Zero false negatives is the one inviolable correctness requirement, ensured by an oracle differential test (prefilter == full-scan, name/path × case3).
  • Reason for the v2 bump: in the past, “after a revert, an old-protocol service binary kept running and corrupted queries” occurred. 16→20B is incompatible, and bumping the pipe name makes old v1 services unreachable (no accident of misreading a 20B request as 16B+text), doubly guarded with Hello version matching.

Measurements (2026-06-15, real C: 1.6M entries / synthetic 1M criterion)

  • Regexes where the prefilter works stay within the hard line: regex:win.*\.dll (prefix “win”) = p99 9.1ms @1.6M. In micro too, win.*\.dll 8.5ms / \.dll$ (suffix) 7.1ms. Existing queries (substring/wildcard/ext/size) are all non-regressing.
  • literal-less regex goes full-scan: micro regex:[0-9]{4}x @1M = 28.7ms (within the 50ms hard line). Real C: regex:[0-9]{6,} is p99 ~51ms at 1.6M entries — it meets spec scale (1M) but, being linear in count, exceeds it at over-spec volumes. Whereas memmem substring full-scan (single char a/e) is 6-8ms even at 1.6M, regex matching is ~9x heavier, so only the literal-less class grows linearly and crosses 50ms.
  • Bench policy: the gated set on real volume (P99_BUDGET_US=50ms hard) holds only regex:win.*\.dll, which the prefilter can guarantee. The literal-less worst case is measured and recorded in criterion micro (query/regex_scan, ungated) — gating it on a fixed 50ms line would fail merely because “the machine has more files than spec” (not concealment, but gating only the range we can guarantee and measuring the worst case separately + documenting it in this ADR).
  • Streaming-regex optimization over the whole pool is rejected as unsound for ^/$ anchors, cross-entry-boundary spans, and greedy matches (full-scan of literal-less regex is the accepted filename-only/no-index tradeoff).

Consequences

  • Contract evolution (ADR-0018 flow): fmf-contract (pod/options/versions) → FMF_BLESS=1 golden recapture → just contract-gen → both-language tests green. contract/golden/query_req_*.bin is recaptured at 20B.
  • One line in docs/SECURITY.md threat #5 (regex compile compute DoS → reject via limit).
  • Kill switch FMF_REGEX_PREFILTER=0 (force fallback to full-scan; a field recovery valve of the same kind as FMF_QUERY_CACHE).
  • Observability: on prefilter success QueryTrace.driver is pool-scan; on extraction failure it is full-scan.
  • C# side: RegexMode/Scope on SearchOptions, 20B encoding in PipeProtocol, AppSettings persistence, gear-menu UI, RegexHighlighter (.NET re-match; if it drifts, do not highlight).

Re-examination triggers

  1. literal-less regex full-scan exceeds the 50ms hard line @1M (currently ~29ms, inside. If it breaks at 1M scale, consider a separate path for prefilter-incapable patterns — e.g., a sound subset of pool streaming or a required-byte-class prefilter). The 51ms at 1.6M is linear growth from over-spec volume and is not itself a trigger.
  2. Real demand for path-scope regex is high and full-scan breaks p99 → consider path prefilter via name-portion anchor extraction.
  3. Measurement shows typical whole-query queries skew literal-less → only then re-evaluate ADR-0002 trigram (AND’d with all triggers of that ADR).
  4. A real report of a legitimate user hitting the 1MiB compile limit.

ADR-0024: Remove the non-elevated scope index

Date: 2026-07-26 / Status: Adopted

The folder-walk + ReadDirectoryChangesW fallback was removed. It duplicated scan, freshness, persistence, configuration, and onboarding while providing weaker coverage than the product’s defining NTFS $MFT + USN path.

Production indexing now has one model: an asInvoker UI talks to the on-demand service; an already-elevated --engine=inproc remains the development/recovery fallback. If the service is absent, the UI offers its one-time installation instead of building a second per-folder engine.

Scope exclusions go with it. They existed to prune the folder walk, and there is no longer a folder-walk ingest path to prune; excluding a path from results remains available where it always belonged, as query syntax. (This absorbs the separately recorded exclusion decision, which had no content of its own once the walk was gone.)

ADR-0026: fmf CLI gets first-class DevEx polish (still a developer tool)

Date: 2026-06-20 / Status: Adopted. Superseded only in its distribution decisions by ADR-0039: fmf ships as a developer build artifact, not inside the end-user ZIP, and the generated CLI Markdown and its drift machinery are gone. Everything else — the remit, the exit-code mapping, the JSON envelope, the flag surface — is in force.

Decision

Invest in the fmf developer CLI’s ergonomics without expanding its remit. The CLI stays a developer / diagnostic / measurement tool — the WinUI app remains the end-user product — but it gains the polish a contributor (and anyone driving the engine from a terminal) expects:

  • --version on the clap surface (same CARGO_PKG_VERSION diag already prints).
  • Global presentation flags --color auto|always|never, -q/--quiet, --format human|json, threaded as a small Ctx into the commands that need them (colour is written to anstream’s global choice instead).
  • FMF_E_* exit codes. The top-level handler maps fmf-core’s typed errors to the shared fmf_contract::codes table — the same classification the FFI boundary uses — so a script can branch on $LASTEXITCODE (NOT_ADMIN=3, VOLUME=4, LOCKED=7, …); clap’s usage exit code (2) is untouched.
  • TTY-aware colour (anstream strips ANSI when redirected) over an anstyle style vocabulary in cmd::term, plus an indicatif spinner during the volume index build. Both go silent under --quiet/--format json/non-TTY.
  • Machine-readable output. --format json emits a single document on stdout (NDJSON for watch) for the commands that have a JSON shape (diag/bench/watch); failures emit a JSON error envelope on stderr. Every payload carries a format_version.
  • Generated completions. Shell completions in build/completions/ (PowerShell/bash/zsh/fish) are rendered from the shipped clap command tree. fmf --help is the only CLI reference.

To let the example and the integration tests reuse the clap surface, fmf-cli becomes a lib + bin: the parser and dispatch move to lib.rs (command() / run()), and main.rs is a one-line entry point. This preserves the “clap surface + dispatch only; logic in cmd/” rule.

Rationale

  • The two audiences the project wants to serve — people developing find-my-files and people using it from a terminal — both meet the engine through this CLI. It was the least-polished surface (no completions, no --version, monochrome, every failure exit 1), so the DevEx return is highest here. The contributor tooling (just/xtask) was already brought up to standard in the #54–58 pass.
  • Reuse, don’t redefine. Exit codes and the JSON error code come from fmf_contract::codes, the machine-readable contract source; the CLI mirrors the FFI’s per-call classification rather than inventing a parallel table.
  • No new seams or ports (ADR-0018): nothing here touches the engine’s trait seams. Generated completions land under build/ (ADR-0021).
  • anstream/anstyle already ride in via clap, so colour costs almost no new dependency surface and handles NO_COLOR/redirection correctly without hand-rolled TTY logic.

Rejected alternatives

  • A one-shot fmf search "<pattern>" command. Tempting for the “use it from my workflow” audience, but it is a new product surface (the engine builds an in-process index or needs a Rust-side pipe client to the running service), not polish. The REPL (index) and the WinUI app already cover interactive search. Deferred, not designed.
  • An end-user TUI. Out of scope — the CLI stays diagnostic; the product is the app.
  • Unifying fmf-core’s error enums behind one code() method. fmf-core has several typed error enums (MftError, EngineCreateError, EngineError, ParseError/CompileError, UsnError); the FFI classifies them per call site. The CLI downcasts the same way in one place (cmd::exit) rather than driving a cross-crate refactor of the engine for a presentation concern.

Consequences

  • fmf-cli is now lib + bin. Its unit tests run under the lib; assert_cmd behavioural tests cover the non-elevated surface (version/help/usage/diag/JSON error); volume/USN assertions stay behind FMF_ADMIN_TESTS.
  • The end-user bundle contains neither fmf.exe nor completion scripts.
  • The --format json payloads are a versioned, not frozen contract: additive fields keep format_version; a field changing meaning or being removed bumps it. Snapshot/behavioural tests pin the current shape. Version 2 replaces the misleading diag.engine_log file path with diag.engine_log_dir, matching the rolling log implementation.
  • New dev/runtime dependencies (anstream, anstyle, indicatif; dev-only clap_complete, clap-markdown, assert_cmd, predicates) pass cargo deny.

Re-examination triggers

  • If scripts come to depend heavily on exit codes and a misclassification surfaces, promote a single code() accessor into fmf-core and have both the FFI and the CLI consume it.
  • If a non-interactive, scriptable query against the running service is genuinely needed, revisit fmf search via a Rust pipe client (a new design, with its own ADR).
  • If the JSON shapes churn, bump format_version and document the change; if consumers need stability guarantees, freeze a subset.

Follow-up

ADR-0039 distributes completions, makes --format json consistent, and adds -v/--verbose. The remit is unchanged: fmf stays a developer/diagnostic tool.

ADR-0027: On-demand service lifecycle (manual start + idle stop + idle GC)

Date: 2026-06-23 / Status: Accepted (amends the “resident service” lifecycle decision of ADR-0016; the service split, transport, and security model are unchanged)

Decision

Stop running fmf-engine as a boot-time resident. Instead:

  1. Manual start — register the service SERVICE_DEMAND_START (was SERVICE_AUTO_START + delayed). It no longer starts at every boot; it runs only when something starts it.
  2. Unelevated start/stop — at install (one-time, elevated) set the service-object DACL to grant the authorized user SID(s) SERVICE_START | SERVICE_STOP | SERVICE_QUERY_STATUS (and read), so the asInvoker app starts the service on launch with no UAC. Never grant a standard user SERVICE_CHANGE_CONFIG / DELETE / WRITE_DAC / WRITE_OWNER — on a LocalSystem service that is local privilege escalation.
  3. App-launch start — SCM state is checked before probing: a definitively absent/stopped service pays no pipe timeout; only exact SERVICE_STOPPED may route to StartThenPipe, while transitions/unreadable state stay pipe-only so in-proc cannot race the writer lock. Resolve starts a marker-compatible stopped service unelevated and its supervisor waits for the pipe. Failure falls back to setup/re-registration.
  4. Idle self-stopserve() stops itself after service.json idle_stop_secs (default 300 = 5 min) with no live pipe connection. The clock starts only after a client has connected and dropped; a self-stop is held off while an initial scan is in flight. 0 disables it (the legacy “stay resident once started” behaviour).
  5. Idle GC — a daily SYSTEM Scheduled Task runs fmf-service gc, which uninstalls the service + removes the task + purges the data when last_use is older than gc_max_idle_days (default 7, 0 disables). To survive the portable app folder being deleted, install copies fmf-service.exe into the hardened data root (%ProgramData%\find-my-files\fmf-service.exe) and points the registration and the task at that copy.

Rationale

  • ADR-0016 chose a resident service so “the index stays fresh via USN tracking even when the UI is not running.” That is real, but this is a momentary-use tool: a permanent boot-time process holding the index in RAM forever does not match how it is used. The owner’s call is minimal footprint over an always-hot index.
  • A DEMAND_START service that is stopped consumes zero RAM and zero CPU — it is just an inert SCM row. So manual-start + idle-stop fully solves the “resident forever, eats memory” concern; the idle GC is housekeeping (it removes the leftover registration/data and self-heals an orphaned install after the portable app is deleted — there is no installer/uninstaller to do it).
  • Granting the user SERVICE_START/STOP on the service object is what makes the per-launch start UAC-free. It is a deliberate, minimal widening of the service ACL; the dangerous rights stay admin-only (see threats in docs/SECURITY.md).
  • A stopped service cannot run a timer, so the idle GC must be driven by an external scheduler. A Scheduled Task is the standard Windows mechanism and is far lighter than a resident process (it runs for milliseconds, only when it fires, and removes itself when the GC completes).

Trade-off

Abandoning residence means a cold start at the first search of a session: the snapshot is loaded and the USN journal replayed; if the journal has wrapped since the last run (long absence, heavy churn) it is a full rescan. Measured baselines (ADR-0016): restore→ready p50 108 ms, ~1.25 s including process spawn; full rescan ≈5 s/250k, ≈60 s/1M. Hot search p99 (<10 ms) is unchanged — the only new cost is one cold start per session.

Rejected alternatives

  • In-proc only while the app is open (no service) — MFT/USN reads need elevation, so this is a UAC prompt on every launch. One-time install then UAC-free on-demand start is strictly better UX.
  • Self-uninstall on idle instead of a Scheduled Task — a service that idle-stops after 5 min is never running at the 1-week mark to notice the absence. Time-based GC fundamentally needs an external scheduler.
  • GC task / service pointing at the portable exe — deleting the app folder breaks both, leaving an un-GC-able orphan. The stable copy in %ProgramData% is what makes “auto-delete after a week” actually work for the deleted-app case (and fixes the latent bug where the resident service’s binary path pointed into a deletable folder). Version skew between bundle and copy is detected by the pipe Hello handshake and self-heals on the next re-register.

Consequences

  • No wire-contract / golden / ABI change: everything is SCM-, filesystem-, and Scheduled-Task-level, plus two additive service.json fields (idle_stop_secs, gc_max_idle_days) read with serde #[serde(default)]. Observability is via the rolling engine logs (idle stop) and app.log (on-demand start), not new counters (an idle-stop counter dies with the process).
  • Install now copies a binary into %ProgramData% and registers a Scheduled Task; both are removed on teardown (see the cleanup guarantee below).
  • Machine footprint & cleanup guarantee. The footprint is exactly three things — an SCM service registration, one machine-wide data directory, and the GC Scheduled Task — and it is a closed set: nothing goes into HKCU, Program Files, the Start menu, firewall rules, or the Event Log (logs are files under the data dir). It returns to a clean machine two ways: (a) explicit, immediate — the app’s “Remove” + “Also delete the index and logs” runs uninstall --purge-data, deleting service + task + data at once (uninstall runs from the bundle exe, so the stable copy is not in use); uninstall without purge removes the service, task, and the stable exe (program clutter), keeping only the user’s own index/logs/config; (b) automatic — the idle GC removes service + task + data, with the still-running stable exe and its now-empty dir scheduled for deletion on the next reboot (MoveFileEx delay-until-reboot, since a running image cannot delete itself). The per-user UI settings/logs are the UI’s, independent of the service.
  • The %ProgramData% exe copy is a security requirement, not gratuitous footprint: the SCM launches it as LocalSystem, so it must live where a standard user cannot overwrite it (the user-writable portable folder would be a privilege-escalation vector now that start is unelevated).
  • The service-object DACL and the stable-exe/data-dir non-writability are new security-relevant surfaces — recorded in docs/SECURITY.md (threats 9–10) and pinned by the service_sddl unit test (start/stop present; change-config/delete/write-DAC/write-owner absent), mirroring the pipe-SDDL pin.
  • idle_stop_secs applies to the console run path too; just service-dev users who want it to stay up set idle_stop_secs = 0.

Verification

Executable tests pin the service DACL, idle/GC decisions, last_use, protocol marker routing, and unelevated idle self-stop. Install-time checks enforce SCM configuration, GC task registration, and stable-binary/data ACLs.

Re-examination triggers

  • If cold start after a long absence (journal-wrapped full rescan) becomes a routine complaint, reconsider a low-footprint “freshness-only” mode (a lightweight USN-tail that keeps the snapshot current without serving), rather than reverting to a full resident server.
  • If multi-user machines become a real target (already an ADR-0017 trigger), revisit who may start/stop the service (per-user vs. a group ACE).

ADR-0028: Do not distribute an MSIX

Date: 2026-06-24 / Status: Accepted 2026-07-07. What was rejected is the MSIX hybrid the ADR was opened to evaluate; the decision recorded here — ship one signed ZIP and do not package — is in force.

Decision

Ship one signed, self-contained ZIP. Do not package the WinUI process while leaving the LocalSystem service outside the package.

Why

  • The evaluated hybrid built, installed, launched, and searched, but had no supported unattended distribution path for the project’s signing identity.
  • MSIX cannot own the service lifecycle without discarding the custom service-object DACL, privilege reduction, data-tree hardening, and GC model required by ADR-0017 and ADR-0027.
  • Maintaining a second installation shape duplicated setup, path, update, and uninstall behavior without improving the product.

Reconsider only when

An official unattended signer supports the project identity and MSIX can preserve the service security/lifecycle model without a parallel installer.

ADR-0029: CI signing pipeline — official SSL.com Action, privilege-separated release jobs

Date: 2026-06-25 / Status: Accepted (supersedes the structure around ADR-0020’s signing step; provider/cert unchanged)

Amended 2026-07-28 by ADR-0048. The entry point changed: release.yml is no longer reusable-only from a default-branch workflow_run controller — it is dispatched directly from protected main by release-please.yml, and the release / release-please environments’ protected-main deployment policy is what makes an off-main dispatch reach no secret. The job list gains preflight (secretless admission control, first) and mutation (the ADR-0022 gate, run against the release source). Everything below about the signing split, the credential island, the approval gates, and the verification is unchanged.

Filename keeps its original -cka- slug for link stability; the eSigner CKA approach this ADR first proposed was tried in CI and reverted (see “eSigner CKA: attempted and reverted”). The accepted decision is the official SSLcom/esigner-codesign Action inside a hardened pipeline.

The signing provider and certificate stay exactly as ADR-0020 decided: SSL.com eSigner, personal Individual Validation cert CN=Yasunobu Sakashita. This ADR changes the pipeline shape and hardening around the signing step, and records why the eSigner CKA alternative was rejected after a CI trial.

Decision

  1. Sign with the official SSLcom/esigner-codesign Action (command: batch_sign). This is SSL.com’s recommended GitHub Actions integration: the Action downloads CodeSignTool, runs scan_code (pre-signing malware scan) then signs, and timestamps via SSL.com’s TSA. A fresh no-checkout job copies exactly five protected-workflow literal paths from the sealed bundle into a flat directory; the credentialed job signs only that five-file artifact into an explicit output_path (the Action ignores override). The fixed map is deliberately repeated at this credential boundary so target repository code cannot turn the certificate into a signing oracle. The Action is SHA-pinned (v1.3.2).

  2. Split release.yml into eight jobs — buildsbomsign-stagesignsign-collectpackagepublish-approvalpublish — using immutable Actions artifacts at each boundary. (ADR-0048 prepends preflight and runs mutation alongside build.) Only sign sees signing secrets and it executes no repository code. sbom scans disposable copies while preserving the sealed unsigned bundle. publish-approval is a secretless second human decision. publish receives only the completed zip/checksum/SBOM pair plus both bundle manifests. On a fresh no-checkout runner, protected inline validation binds the full source commit, controller commit, numeric Release ID, dispatching actor, four public assets, and both manifests into a custom keyless attestation before an App token publishes that exact draft ID. No toolchain or repository build code shares the write/OIDC boundary.

  3. Gate the secrets behind an approval-gated release GitHub Environment on the sign job. The eSigner secrets are Environment secrets (not repo-level), with required reviewers and deployment refs restricted to protected main only. The credentialed pipeline is startable only from protected main (ADR-0048: a workflow_dispatch on --ref main, previously a default-branch workflow_run controller); its tag and commit are validated data, never executable workflow source.

  4. Verify with signtool verify /pa /tw plus exact certificate identity. /tw makes a missing timestamp a non-zero exit (0 = chain valid + timestamped, 2 = untimestamped, 1 = invalid); the verifier pins the common name, full subject, issuer, and certificate SHA-256. Get-AuthenticodeSignature.TimeStamperCertificate is not used for the timestamp check — it is null under -FilePath on the runner (PowerShell#4060), so the timestamp guarantee comes from signtool.

  5. Concurrency guard (group: release-stable-publication, cancel-in-progress: false) so stable publications never race and a run is never cancelled mid-sign/mid-publish.

Signing is fail-closed for publication. The credentialed workflow has no unsigned rehearsal entry point; ci.yml never signs.

Rationale

  • Official Action over CKA: the Action is SSL.com’s documented, supported CI integration and is proven to sign with this exact account (it signed successfully before this work; CKA never did — see below). It is SHA-pinnable for supply-chain integrity. CodeSignTool sends only file hashes to SSL.com (source never leaves the runner) and timestamps automatically.
  • Separated privilege stages over one: defense in depth. A compromised build/SBOM step cannot read signing secrets; repository package code receives no write token; the publish write/OIDC token sees only a strict artifact allowlist and protected inline validation plus pinned official attestation/token Actions.
  • signtool /tw over TimeStamperCertificate: the runner’s PowerShell returns a null timestamper under -FilePath, so asserting it would false-fail; signtool exit codes are authoritative.

eSigner CKA: attempted and reverted

The CKA (Cloud Key Adapter) was attempted to replace the Java CodeSignTool with the standard signtool and drop the copy-back dance. It failed in CI across three dry runs and was reverted:

  • Cert not visible across steps (run 28117792530): a split load-step → sign-step left signtool with “No certificates were found…”. Merging load+sign into one shell (run 28119208195) did not fix it.
  • x64 signtool cannot load the 32-bit eSignerKSP (run 28119208195): the cert was in CurrentUser\My with HasPrivateKey=True, yet x64 signtool still reported “No certificates were found”. Switching to x86 signtool got past that.
  • KSP credential retrieval fails at sign time (run 28120321041, x86): Signing credentials not configured. Make sure certificate is issued before signing / SignerSign() failed (0x80090003). This is a CKA-internal CSC credential path, not an account problem.

Crucially, the official Action’s batch_sign succeeded on the same account/cert in run 28082306344 (scan_code → sign → Verify all green). So the account, PIN, and eSigner credentials are fully provisioned; only the CKA KSP path is the odd one out. This is the prior CKA proposal’s own re-examination trigger (“CKA proves flaky in CI → revisit the Action”) firing. The privilege-separated jobs, approval gate, hardened verify, and concurrency guard — all independent of the signing tool — were kept.

Rejected alternatives

  • eSigner CKA + standard signtool — would drop the copy-back and use the canonical signtool, but fails in CI (KSP credential retrieval, above) while the official Action works. Rejected on evidence. The copy-back dance is a small, well-commented price for a proven mechanism.
  • Migrate to SignPath (managed, GitHub-native) — arguably the most “modern managed” experience and free for OSS, but it is a provider migration with its own onboarding/review and strands the already-purchased SSL.com IV cert. Rejected: no benefit that justifies abandoning a working, paid-for cert.
  • Migrate to Azure Trusted Signing + dotnet sign — the genuine industry standard, but unavailable: individual onboarding is paused and new tenants are limited to US/CA orgs with 3+ years of history (ADR-0020; RESEARCH.md). Not a choice for a Japanese individual.
  • dotnet sign against SSL.comdotnet sign only delegates to Azure Key Vault / Trusted Signing; it cannot drive eSigner. Technically incompatible.

Consequences

  • HAVE_SIGNING requires all four release environment secrets: ES_USERNAME / ES_PASSWORD / CREDENTIAL_ID / ES_TOTP_SECRET.
  • Every release run pauses before sign and again at the secretless publish-approval; the environments accept only protected main. A signing rehearsal, if reintroduced, must be a separate credentialless workflow rather than a second mode of the production release pipeline.
  • The sealed bundle/assets cross immutable Actions-artifact boundaries between build, SBOM, signing, packaging, and publication; a few extra minutes on a release-only workflow. Every transition re-verifies the expected file set and content identity. The Authenticode signature lives inside the PE, so the round-trips preserve it.
  • .github/workflows/release.yml is the executable runbook; the irreducible human approvals are summarized in docs/RELEASING.md.
  • A future MSIX (ADR-0028) can be signed by the same Action (sign/batch_sign accept .msix).

Re-examination triggers

  • Azure Trusted Signing opens to individuals in Japan (or an eligible org is formed) → re-evaluate the whole provider per ADR-0020’s trigger; dotnet sign + OIDC would then be reachable.
  • eSigner CKA fixes the KSP credential path (or SSL.com documents a working unattended CKA recipe) → the canonical signtool flow becomes worth revisiting to drop the copy-back.
  • MSIX shipping (ADR-0028) lands → fold its signing into this same Action step.
  • Artifact round-trip cost or a single-platform regret → collapse back toward fewer jobs (the split’s value is the secret isolation, not job count).

ADR-0030: Tray-resident mode (UI process stays, hot-held engine)

Date: 2026-06-25 / Status: Accepted (an app-side, opt-in lifecycle layered on the ADR-0027 on-demand service; the service, transport, and contract are unchanged)

Decision

Add an opt-in tray-resident mode. A user-scope setting close_to_tray (default off) gates it. When on:

  1. Close (×) hides to the tray — the main window’s AppWindow.Closing is cancelled and the window is AppWindow.Hide()-den (this also removes the taskbar button). The only real exit is the tray icon’s right-click Exit.
  2. Always hot — while tray-resident the process keeps the MainWindow, its MainViewModel, and the live engine connection alive. Restore is AppWindow.Show() + Activate() — nothing is rebuilt, so the query text, results and scroll position survive, and the first search after restore is zero-latency.
  3. Self-written tray iconShell_NotifyIcon via [LibraryImport] (no third-party package). Its callback message and WM_TASKBARCREATED are received by subclassing the MainWindow HWND with SetWindowSubclass. The context menu is a Win32 TrackPopupMenuEx.
  4. Single-instanceDISABLE_XAML_GENERATED_MAIN + a hand-written Program.cs using AppInstance.FindOrRegisterForKey + Activated redirection, so a second launch (e.g. from the Start menu while tray-resident) restores the first instance instead of spawning a duplicate icon. (This redirection later collided with #107’s process relaunch — an in-app relaunch redirected back to the dying original and took the app down; ADR-0036 resolves it by re-resolving the engine in-process instead of relaunching.)
  5. Engine/service (Rust) and the wire contract are untouched. Everything lives in the C# app layer.

Rationale

  • The owner’s symptom is “re-opening the app makes it heave itself up.” The cause is the WinUI/.NET UI-process start cost, not the service: ADR-0027 already keeps the service hot for idle_stop_secs (300 s) after the last client drops and restores in ≤2 s. The lever that actually helps is keeping the UI process alive — which is exactly what a tray resident is.
  • Always-hot is a deliberate owner choice. Keeping the engine connection up while hidden means the service’s idle self-stop (idle_should_stop = … && active==0 && …, fmf-service/src/lifecycle.rs) is intentionally held off — a live pipe is never active==0. That is the correct consequence of “always hot”: the index stays fresh via the service’s USN tracking and the first search never pays a cold start. ADR-0027 chose “minimal footprint over an always-hot index” for the default lifecycle; this is the opposite preference, scoped to opt-in tray mode only and driven from the UI without touching the service.
  • Hiding, not disconnecting, is what makes restore instant and stateful. Because nothing is torn down, restore needs no EngineClientFactory.Resolve, no window rebuild, and no UI-state save/restore. This also collapses a large amount of would-be machinery (connection suspend/resume, window re-creation) that a “drop the connection while hidden” design would require.
  • The real exit path (tray Exit) still runs the existing Window.Closed teardown — EngineClient.Dispose() drops the pipe, active falls to 0, and the service returns to its normal ADR-0027 idle self-stop. So tray mode changes when we disconnect, never how.

Trade-off

While tray-resident, both the UI process and the service (the index — ~110 B/file, ≈100 MB at 1M files) stay in RAM. That is the cost of “always hot,” accepted knowingly. It is bounded by being opt-in and default-off: a user who leaves close_to_tray off gets the unchanged ADR-0027 on-demand behaviour (close → service idle-stops after 5 min → zero RAM). Turning tray mode off at any time returns to that footprint.

Rejected alternatives

  • Drop the connection while hidden, let idle-stop reclaim the index — lighter (the service falls away after 5 min and the existing idle window doubles as a hot grace period), but the first search after a >5 min absence pays a cold start. The owner chose always-hot. Kept as a documented re-examination trigger; the WindowSubclass plumbing supports adding it later as a second setting with no structural change.
  • A message-only window (HWND_MESSAGE) for the tray callbackWM_TASKBARCREATED is a broadcast, and broadcasts do not reach message-only windows, so the icon would never recover after an Explorer restart. The top-level MainWindow does receive it.
  • SetWindowLongPtr(GWLP_WNDPROC) to replace the window proc — clobbers the DesktopWindowXamlSource proc that WinUI’s top-level window relies on. SetWindowSubclass chains instead, preserving XAML’s handling.
  • A NotifyIcon NuGet (e.g. H.NotifyIcon.WinUI) — against the codebase’s minimal-dependency posture (the app references only WindowsAppSDK + CommunityToolkit.Mvvm). The Win32 surface is small and matches the existing self-written P/Invoke seams (ServiceSetup, IRevealApi, ShellOps).
  • Global hotkey launcher — declined by the owner for now. The WindowSubclass base is the natural host for a future RegisterHotKey/WM_HOTKEY, so nothing forecloses it.
  • Minimize-to-tray — folded into “× hides to tray” per the owner’s choice; one gesture, not two.

Consequences

  • No wire-contract / golden / ABI change. One additive AppSettings field (close_to_tray), picked up automatically by the source-generated AppSettingsJsonContext as snake_case JSON — mirrors ADR-0027’s additive-service.json posture.
  • The process entry point changes: DISABLE_XAML_GENERATED_MAIN plus a hand-written Program.cs. The App ctor order (ApplyLanguageOverride → InitializeComponent → ExceptionPolicy.Install) is preserved unchanged; Program.Main only wraps Application.Start.
  • New Win32 interop surface (Shell_NotifyIcon, SetWindowSubclass, TrackPopupMenuEx), pinned to System32 like every existing import. The SUBCLASSPROC delegate, the HICON, and the tray identity are held in fields for the process lifetime (AGENTS.md “FFI-callback delegates are field-held” — GC reclaim would dangle the native pointer). OnActivated (single-instance redirect) fires on a background thread, so it marshals to the UI thread via the cached App.DispatcherQueue before any window work.
  • Testability: the view-shell pieces (Program, TrayIcon, WindowSubclass, TrayMenu) are [ExcludeFromCodeCoverage] like the other window shells (ADR-0022). The close-vs-hide decision is extracted into a pure WindowLifecycle function and table-tested.
  • Security: single-instance keying and the tray HWND are local to the unelevated app; no change to the privileged service surface (docs/SECURITY.md unaffected).

Verification

WindowLifecycleTests pins the close/hide/explicit-exit table and AppSettingsTests pins the default-off persistence contract. Tray callbacks, Explorer restart recovery, and single-instance activation share the production WindowSubclass message path.

Re-examination triggers

  • If the always-hot resident footprint becomes a complaint, add a second mode that drops the connection while hidden (cold start on restore) — the WindowSubclass/AppWindow.Hide plumbing is unchanged; only the hide/show handlers gain a Dispose/Resolve pair.
  • If a global hotkey is requested, host RegisterHotKey/WM_HOTKEY on the existing WindowSubclass (no new top-level window or message pump needed).
  • If multi-instance (e.g. per-volume windows) ever becomes a goal, revisit the single-instance key (it would move from a fixed string to a per-window key).

ADR-0031: mtime as a u32 Unix-seconds column

Date: 2026-06-26 / Status: Accepted

Decision

The per-entry mtime column stores Unix-epoch seconds in a u32, not the raw Windows FILETIME tick count (100 ns since 1601) in an i64. The full FILETIME is reconstructed to the second on read (VolumeIndex::mtime), so the FFI contract (FmfRow.mtime: i64) and every consumer stay byte-unchanged. Encode/decode is the single pair query::dates::mtime_{ticks_to_secs, secs_to_ticks}. 0 is a reserved “unknown timestamp” sentinel: a 0 tick (a failed stat fetch) and every pre-1970 tick collapse to it and reconstruct back to a 0 tick. The snapshot bumps to FMFIDX05 (amends ADR-0010).

Rationale

  • −4 B/entry (8 → 4). On real C: (~1.27M entries) ≈ 5 MiB, ~4–5% of the resident index — the single largest clean column saving. The name pools dominate but are already minimized (ADR-0003/0004); size is u32+overflow (ADR-0007); frn is the FrnIndex backing store; parent/flag/perm_name are fixed.
  • No observable behavior change on real data. dm: bounds are day-aligned (filetime_at_midnight), so second-granularity storage yields byte-identical filter results. Sort order is preserved (the map is monotonic); only the sub-second tie-break between files modified in the same second is lost (it falls to the deterministic id tie-break — imperceptible for a filename search). Unix seconds cover 1970–2106; pre-1970/post-2106 saturate.
  • The 0 sentinel keeps an “unknown timestamp” (failed stat, FILETIME 0) filtering and displaying exactly as before (1601), not snapping to 1970.

Consequences

  • Dates strictly between 1601 and 1970 are no longer representable (they collapse to the 0/unknown sentinel). No real NTFS file carries such an mtime; only synthetic fixtures did, now anchored past 1970.
  • Snapshot is FMFIDX05; an FMFIDX04 file fails the magic check → full rescan (ADR-0010, no compat). The mtime section is a u32 column.
  • IndexStats mtime_bytes halves; the field and the wire contract are structurally unchanged (no contract/golden re-bless).

Re-examination triggers

  • A real need for sub-second mtime ordering, or for mtimes outside 1970–2106.

ADR-0032: name dictionary-encoding (deduplicate the folded name pool)

Date: 2026-06-26 / Status: Accepted, amended by ADR-0033, which dropped the separate dict_len column described below (lengths derive from the gapless dict_off). The dictionary encoding itself is unchanged.

Decision

Store each distinct folded name once in a dict_pool, and give each entry a name_id: u32 into a per-name (dict_off: u32, dict_len: u16) directory. The per-entry lower_pool / name_off / name_len columns are removed. orig_pool / orig_off stay per-entry (a shared folded name can back differing originals — README/readme — so originals cannot dedup; ADR-0004).

Measurement that justified it (fmf stats C: --dict-estimate, 1.76M entries)

  • 52.2% of names are duplicates (D = 840,787 distinct of 1,760,684).
  • Folded pool 49.5 MB → dict 32.8 MB: −34% swept bytes (cold-query speedup) and net −8.7 B/entry at rest. Below the −12 memory gate, but accepted for the combined memory + cold-scan-latency win (user decision, 2026-06-26).

Key design points

  • dict_off is inherently sorted. name_id is assigned in dict-append order, so dict_off[0] < dict_off[1] < … by construction. The sweep maps a hit offset → name_id with a monotonic cursor over dict_off directly — the OffsetTable derived cache (build/extend/stale-pair logic in query/memo.rs) is deleted, not ported. Stale gaps dissolve: a renamed entry points at a new name_id; the old name’s bytes are addressed by name_id, never per-entry, so they are never “stale”.
  • Sweep → name_id bitset. driver_candidates sweeps dict_pool and sets a bit per matching name_id (boundary/anchor checks per dict name, as today). Materialization walks perm_name and keeps an entry when name_id[id] ∈ bitset (an O(1) bit test fused into the existing perm walk) and live/excluded/residuals pass — so no reverse name_id → [entry] index is needed. refine is untouched (it reads name()/lower_name() through the accessors, now dict-backed).
  • Unified append-then-dedup; transient interner. Every push (initial scan and USN) appends a fresh name_id (no dedup on the hot path, no resident interner). dedup_dict() rebuilds dict_pool/dict_off/dict_len from the distinct live folded names with a transient FxHashMap interner and remaps every name_id; it runs at finish() and inside compact(). (A resident interner was rejected — it adds ~9 B/entry and erases the win.)
  • Churn trigger. USN creates append un-deduped dict entries, so a pure-create burst (no tombstones) would bloat the dict without hitting the tombstone-driven compaction. A dict_appends_since_dedup counter triggers a compact() (which dedups) once it exceeds live_len / 4, bounding the bloat.
  • dead_name_bytes / owned_name_bytes change meaning. A folded name’s bytes are dead only when its last referrer goes; with dedup they are recomputed at dedup_dict() rather than charged per-tombstone. The dead_name_bytes_tracks_pool_garbage test is updated to the new semantics (a deliberate change, not a regression).

Snapshot

FMFIDX05 → FMFIDX06. Sections gain dict_pool / dict_off / dict_len / name_id and drop lower_pool / name_off / name_len. On-load validation: name_id[i] < D, dict_off[k] + dict_len[k] ≤ dict_pool.len(), orig bounds use dict_len[name_id[i]]. An FMFIDX05 file fails the magic → full rescan (ADR-0010).

Consequences

  • apply_batch regresses (~2 → 3–5 ms): the perm_name merge comparator reads the folded name through the name_id → dict_off indirection (one extra cache miss, the same shape index/frn.rs already accepts). Gated at ≤ +25%.
  • The fortified oracles must stay green: refine == fresh (exec proptest), pool_scan/regex naive oracles, the snapshot round-trips. The sweep.rs stale-gap tests are rewritten for dict semantics.

Re-examination triggers

  • If the cold-scan win fails to materialize (sweep no faster) or the apply_batch regression exceeds +25% on the real-volume gate, revert to the per-entry pool (ADR-0031 mtime saving is independent and stays).

ADR-0033: Phase 3 memory/latency levers — gapless dictionary (FMFIDX07), predicate reorder, build-rank

Date: 2026-06-26 / Status: Accepted (amends ADR-0032)

Context

Phase 1 (mtime u32, ADR-0031) and Phase 2 (name dictionary-encoding, ADR-0032) banked the large wins: real-C: working set 95 → 77 B/entry (−18 B, ~19%) and −34% swept bytes on a cold query. Phase 3 sweeps the secondary levers a two-agent triage surfaced. Each is independent and small (−0.5…−1 B/entry, or a cold-query polish); the value of this ADR is the record of which were taken, which are gated on a measurement, and which are rejected with numbers so they are not re-proposed (the ADR-0014 precedent).

Decision (taken now)

Six levers land with Phase 3. All are byte-result-invariant and verified by the fortified oracles (refine == fresh, the pool_scan/regex naive oracles, the snapshot round-trips):

  • Predicate reorder (3a). The materialize walk tests name_id ∈ some group's sweep set before the liveness/exclusion flag gather. AND is commutative, so the result is unchanged; on a selective query (win, report, ext:dll) ~90% of entries are rejected on the O(1) bit test before they ever touch flag. (query/exec.rs.)

  • Gapless dictionary — drop the dict_len column (Lever 2). ADR-0032 gave each distinct name a (dict_off: u32, dict_len: u16) directory. Because names append contiguously and name_id is assigned in append order, dict_off is ascending and the pool is gapless, so a name’s length is the gap to the next offset (dict_pool.len() for the last). The dict_len column is removed; dict_off becomes a D-entry CSR read as dict_off[k+1] − dict_off[k]. −0.95 B/entry beyond ADR-0032, and the pool_end branch in the sweep/compact loops dissolves. The snapshot bumps FMFIDX06 → FMFIDX07; on-load validation becomes “dict_off non-decreasing and within the pool” with each name’s length derived from the next offset. This supersedes the dict_len parts of ADR-0032.

  • Interner pre-size (Lever 6). dedup_dict’s transient FxHashMap interner is with_capacity_and_hasher(n/2, …) (live-distinct ≈ 48% on real C:), skipping the rehash growth across the ~1.76M inserts. Trivial, scan-throughput only.

  • Build-rank (1A). The initial-build name sort ranks the D distinct dictionary names once by bytes, then sorts entries on a packed (rank << 32) | id u64 key. Distinct names → distinct ranks, so it is byte-identical to a full cmp_by(Name) sort (a test pins the equality) while replacing a dictionary deref per comparison with a single integer compare. Build-time only (index/builder.rs); compact() still remaps without sorting and the USN merge keeps its cmp_by insertion. The sort drops ~286 → ~170 ms — invisible to the user (a 2.3 s scan inside a 60 s budget), taken for completeness.

  • Original-spelling dedup (Lever 1, table-free). The originals that differ from their fold (README, LICENSE, every capitalized name) duplicate heavily — real C:: 562k differing entries fold to 221k distinct originals. dedup_orig interns them and points each orig_off at the one shared copy. No offset table and no format bump: the fold is length-preserving (ADR-0004), so an original’s length is its entry’s folded length (name_len_of), and the orig_pool/orig_off snapshot sections keep their shape (just a smaller, deduped pool). −4.5 B/entry at rest — the --dict-estimate gate measured −3.9 against an assumed gapless orig_dict_off table (+4·D_orig); deriving the length from the fold drops that table and beats the projection. Runs beside dedup_dict at finish/compacted (index/core.rs).

  • apply_batch decoration. The USN-batch merge sorts the new entries by name before splicing them into perm_name; ADR-0032’s dict indirection made each comparison resolve two folded names through name_id → dict_off. The sort now decorates each batch entry with its resolved name once and sorts on the borrowed slices — byte-identical order (name then id), the dict deref paid O(B) times instead of O(B·log B) (index/mutate.rs).

Triage (figures from fmf stats C: --dict-estimate, 1.76M entries)

LeverEffectPathVerdict
3a predicate reorder~90% flag gathers skipped on selective queriescold queryGO
2 gapless dict (drop dict_len)−0.95 B/entrymemoryGO (FMFIDX07)
6 interner pre-sizededup ~10-20%scanGO
1A build-ranksort 286 → 170 ms (invisible)scan throughputGO
1 orig-pool dedup (table-free)−4.5 B/entry (real C:: 562k differing → 221k distinct)memoryGO (realized): --dict-estimate orig net −3.9 ≤ −1.5; length from the fold drops the offset table → −4.5, no format bump
3b _mm_prefetch software prefetchno measurable wincold queryREJECTED (tried, removed): the one real-volume A/B was thermally confounded (the prefetch-on run’s indexing — which prefetch cannot touch — ran +45%), and a perm-order gather is already served by the hardware prefetcher; not worth a branch in the hottest loop
apply_batch decorationresolves each batch name once, not per comparisonapply_batchGO: byte-identical merge order, O(B) dict derefs vs O(B·log B)
frn 48-bit packing−2.0 B/entrymemoryNO-GO: the FFI contract freezes FmfRow.frn: u64
full-scan numeric SIMDcold queryNO-GO: FullScan gathers in perm order; the residual is not a contiguous scan
#[target_feature] multi-versioningNO-GO: no hot arithmetic loop to vectorize (the sweep is memchr, the walk is a gather)
resident name-rank columnapply_batchNO-GO: +1.9 B/entry, and the merge compares freshly-appended (un-ranked) name_ids, so a resident rank cannot serve it
perm_name / frn_map derivationNO-GO: both are load-bearing (the merge target and the FRN lookup), not caches
flag / parent bit-packinghot readNO-GO: POD snapshot columns, a 4 GiB parent ceiling, and a per-read mask on the hottest gather
8 sweep bitset shardsdense-needle alloccold query microREJECT: per-thread bitset shards regress the common sparse needle (alloc + clear a D/8 bitset to set a handful of bits); the current small Vec<Vec<u32>> wins

Consequences

  • The gapless layout was introduced as FMFIDX07; prior files fail the magic check and trigger a full rescan, the accepted cost of ADR-0010’s no-migration policy. The valid_sections fixture and the structural validator derive each name’s length from the gapless dict_off rather than a stored length.
  • compute_dict_estimate’s historical projection still prints the Phase-2 figure with the +6·D directory cost (dict_off + dict_len); Lever 2 realized +4·D, an extra −2 B/entry. The Lever-1 estimator (compute_orig_estimate) prints the realized table-free net (no offset table — the original’s length comes from the fold), so it reports ≈−4.5 where the original projection assumed a +4·D_orig table and read −3.9.
  • No contract/golden re-bless: IndexStats, the counters, and the CLI surface are unchanged (the IndexStats pool/offset fields are reused, ADR-0032).

Re-examination triggers

  • A real need for sub-second mtime ordering reverts ADR-0031, not this ADR.
  • If the real-volume gate shows apply_batch over +25% or the cold-query p99 regressing, revert the offending lever — each is independent (the gapless dict, 3a, and build-rank do not depend on one another).
  • 3b software prefetch was implemented, measured, and removed (its only A/B was thermally confounded). Re-propose it only with a clean cold-machine A/B that shows a real materialize win over the hardware prefetcher.

ADR-0034: SBOMs are consumed by osv-scanner — release gate + shipped-release re-scan

Date: 2026-06-26 / Status: Accepted (extends ADR-0029’s SBOM generation with downstream consumption)

Current-state amendment (2026-07-26): the canonical Rust SBOM is generated from the shipped fmf-service dependency closure, which covers the external runtime dependencies of fmf_engine.dll and the launcher. CI proves the FFI graph is a subset and rejects developer-only fmf-cli components; the end-user SBOM no longer describes an unshipped workspace tool.

ADR-0029 made release.yml generate and attest CycloneDX SBOMs (Rust via cargo-sbom, C# via the CycloneDX dotnet tool) and attach them to the release. But nothing consumed them: cargo-audit reads Cargo.lock, cargo-deny reads Cargo.lock, C# vulnerabilities go through CodeQL + Dependabot — none touch the SBOM. The SBOM was a write-only artifact whose only value was provenance/attestation and an OpenSSF Scorecard “Secured release” tick. “You can trace it — so what?” had no answer.

This ADR gives the SBOM a job by feeding it to osv-scanner (OSV.dev) at two points.

Decision

  1. Release gate (release.yml, isolated sbom job). After build seals the unsigned distribution, sbom downloads and verifies that exact artifact, generates the two canonical CycloneDX 1.6 documents from shipped Rust and .NET dependency evidence, validates their structure and root identities, then runs osv-scanner scan source -L fmf-engine.cdx.json -L app.cdx.json --config osv-scanner.toml on disposable copies. A finding (exit 1) fails before sign-stage, so a release with a known-vulnerable dependency in the resolved graph is never signed or published.

  2. Shipped-release re-scan (sbom-monitor.yml, weekly + workflow_dispatch). Require the latest stable release itself to be immutable, download its two canonical SBOM assets, verify their bytes against GitHub’s per-asset SHA-256 digests, validate both root identities, and re-scan them against the current OSV DB. This is the only check that covers what users already downloaded: cargo-audit / cargo-deny / Dependabot all scan HEAD, so a CVE disclosed after a release is invisible for the shipped binary. A mutable release or missing, extra, empty, corrupt, or misidentified SBOM assets fail closed. Only the absence of any published release is a clean dormant state.

  3. Report findings as a single idempotent issue, not SARIF. The monitor opens/updates one issue labelled sbom-vuln (auto-closed when the release is clean again). We deliberately do not upload SARIF to Code Scanning — that surface was just decluttered (the stale-CodeQL cleanup), and “a shipped release is vulnerable → cut a patch release” is an assignable/closable task, which an issue models better than a code-scanning alert (which is about current code).

  4. osv-scanner.toml at the repo root is the single ignore list. Accepted/unfixable advisories are recorded there (the OSV counterpart to engine/deny.toml), honoured by both the gate and the monitor, so an upstream advisory with no fix can’t permanently block releases or spam the issue. Every entry must justify itself.

  5. Tooling: osv-scanner via the already-trusted taiki-e/install-action (SHA-pinned), version-pinned @2.3.6. It remains CI/release-only, so the dev loop stays untouched.

Rationale

  • osv-scanner over grype / trivy / bomber: osv-scanner consumes CycloneDX natively, covers both crates.io and NuGet against one DB (OSV.dev) in a single pass, is Google-maintained, SHA-pinnable via an action the repo already uses, and uses simple exit codes (0 clean / 1 findings / 128 no-packages). grype/trivy are heavier and container-oriented; bomber is narrower. No reason to add a second ecosystem.
  • Consume the SBOM rather than scan lockfiles again: scanning lockfiles would just duplicate cargo-audit (Rust) and skip the .NET closure. Scanning the SBOM is what makes the artifact earn its keep and is the only way to reach the resolved NuGet/runtime graph and the shipped (not HEAD) state.
  • Gate in the isolated sbom job before signing: generation and third-party scanning do not mutate the sealed unsigned bundle, and failure before the approval-gated sign job wastes no reviewer time or signing quota.
  • Dormant-first: before any published release, the monitor no-ops cleanly (notice + exit 0) rather than failing red. The first stable release activates the strict path automatically.

Rejected alternatives

  • Leave the SBOM as provenance-only + document it — honest and near-zero effort, but leaves the “so what?” unanswered and the unique shipped-release-monitoring gap open. Rejected: the gap is real and the cost to close it is small.
  • Drop SBOM generation entirely — would remove a cargo-cult artifact, but loses the Scorecard “Secured release” credit and the one genuinely useful capability (a frozen manifest for retrospective CVE response). Rejected against the project’s security-hardening posture.
  • SARIF → Code Scanning instead of an issue — integrates with the Security tab but re-clutters the surface just cleaned, and models “current code finding” rather than “shipped release needs a patch”. Rejected for the monitor (the release gate needs neither).
  • Scan a matrix of all supported releases — correct at scale, but this is a solo, pre-1.0 project with one release line; “latest” is the whole supported surface. Deferred (see trigger).
  • Add osv-scanner to mise.toml — would unify dev/CI, but it’s never run in the dev loop; keeping it CI-only matches the SBOM tools and avoids dev-loop surface.

Consequences

  • A release can now be blocked by an OSV finding in either ecosystem; the escape hatch is a justified osv-scanner.toml entry (not disabling the gate).
  • Partial overlap with cargo-audit on the Rust side is accepted: the gate adds the .NET closure, and the monitor adds the shipped-vs-HEAD dimension neither cargo-audit nor Dependabot provide.
  • One new weekly workflow (cheap ubuntu) and one separate Contents-read sbom job on releases. Only the monitor’s isolated report job receives issues: write; release assets and osv-scanner remain read-only. The release gate adds no write permission.
  • The sbom-vuln issue is the maintainer’s signal that a shipped release needs a patch release.

Re-examination triggers

  • Multiple supported release lines (post-1.0, LTS branches) → extend the monitor to a matrix of supported tags instead of “latest”.
  • osv-scanner false positives or upstream-unfixable noise grows → the osv-scanner.toml list balloons; revisit gate strictness (gate-warn vs gate-fail) or per-ecosystem policy.
  • A second SBOM consumer becomes useful (license posture from the SBOM, VEX statements) → fold into the same osv-scanner config rather than adding a tool.

ADR-0035: Automated versioning (release-please) + dev/nightly/stable build channels

Date: 2026-06-29 / Status: Accepted (supersedes the manual xtask release flow; extends ADR-0021’s build-output layout with build identity)

ADR-0021 put every build artifact under one build/ tree, but builds had no identity: fmf --version and the app’s version label reported a bare 0.1.0 whether the binary was a contributor’s local build, a future nightly, or an official release — all indistinguishable. And cutting a release meant a human ran xtask release X.Y.Z, hand-picking the number and hand-editing engine/Cargo.toml + the csproj in lockstep. That “version management is human-driven” shape is the thing to remove pre-v0.1, while builds are still free to change.

The decision criterion, set explicitly by the maintainer, is convenience + how industry-standard/recommended a workflow isnot daily build-loop cost (a small fluctuation there is acceptable). The dual-language reality (Rust Cargo.toml + C# csproj) is the practical filter: the tool must bump both.

Decision

  1. release-please owns the version, CHANGELOG, tag, and draft release. Conventional Commits on main drive a bot (googleapis/release-please-action, SHA-pinned) that keeps a “Release PR” open; merging it bumps the version, updates CHANGELOG.md, creates vX.Y.Z immediately ("force-tag-creation": true), and creates the GitHub Release as a draft ("draft": true). The forced tag is required so later Release PR calculations can find a draft release; GitHub otherwise delays the tag until publication. The maintainer never hand-picks or hand-edits a number — they merge a PR. The Release PR diff is the release preview (no local CLI needed). release-please.yml dispatches the trusted main performance workflow with the exact tag and commit as inputs; its default-branch completed-success handler invokes the trusted reusable release.yml, which builds the explicit commit, signs, attaches assets to the draft, and publishes it (assets before publish, the order immutable releases demand). release-please-config.json + .release-please-manifest.json are the config.

  2. Version stays declared in the files; the bot edits them (not git-derived). The package is the repo root (.) with release-type: "simple" and extra-files: a toml updater sets engine/Cargo.toml $.workspace.package.version, and a generic updater (keyed on an x-release-please-version annotation) sets the csproj <Version>. We do not use release-please’s rust release-type: it can’t write a workspace-inherited version (version.workspace = true) and fails with “value at path package.version is not tagged” (googleapis/release-please#2478, #1170). Because the toml updater bumps Cargo.toml but not engine/Cargo.lock (and CI is --locked), release-please.yml runs cargo update --workspace on the Release PR branch to sync the lock (no compile; re-runs on every PR rebuild so it self-heals). The package must be the repo root, not engine: extra-files paths are resolved relative to the package dir and cannot use .., so reaching both engine/Cargo.toml and app/.../FindMyFiles.csproj requires the package to sit above both — which also lets CHANGELOG.md live at the repo root. The manifest keeps the version present and reproducible (tarball/.git-less builds, cargo metadata, debuggability) — the “shackle” was the human driver, already removed by (1), not the stored number.

  3. Three channels, stamped at build time. A new leaf crate fmf-buildstamp (depended on only by fmf + fmf-service, never fmf-core/fmf-ffi) resolves VERSION in build.rs; the C# csproj computes InformationalVersion. The base X.Y.Z is the release-please-managed number; the channel suffix is layered at build time:

    • dev (local just build) → X.Y.Z-dev+g<sha> (.dirty when the tree is dirty)
    • nightlyX.Y.Z-nightly.<date>+g<sha>
    • stable → clean X.Y.Z xtask version --channel <dev|nightly|stable> [--date] is the single source of the string format; CI exports it as FMF_BUILD_VERSION (Rust) / FmfChannel (C#).
  4. Conventional Commits are enforced. Locally via a lefthook commit-msg hook (committed, mise-pinned); on PRs via the existing amannn/action-semantic-pull-request title gate (squash-merge → the PR title becomes the commit, so the title is what release-please reads).

  5. Nightly = unsigned, 14-day GitHub Actions artifact — not a Release. nightly.yml builds the bundle from main (skipping when main is unchanged in 24h), stamps it nightly, and uploads find-my-files-nightly-<date>. Artifacts keep nightlies off the Releases list (no confusion with stable) and sidestep Immutable Releases (no rolling tag to overwrite). Nightlies are deliberately unsigned; the approval-gated signing pipeline (ADR-0029) is stable-only.

  6. GitHub App credentials are fail-closed. release-please.yml mints a short-lived, repo-scoped installation token with explicit contents/issues/pull-request permissions and hands it to release-please. Those secrets live only in a dedicated release-please environment with a main-only deployment policy; an ordinary bot run fails visibly if either credential is absent. Release mutation and workflow dispatch are separate jobs. The API-only job validates tag/draft/target and protected-main lineage, then dispatches release.yml on --ref main with that exact tag, commit, and numeric draft ID (ADR-0048; previously a hosted-only request workflow at the head of a two-hop workflow_run chain). release.yml’s own preflight job re-derives and repeats every one of those bindings before any other job starts, and it has no tag trigger. Recovery of an already-created draft deliberately needs no App token.

  7. A real release requires multiple deliberate, independent actions — defence in depth so an ambiguous instruction can’t ship one. Opening the Release PR does nothing. Cutting a release takes, in order: (a) adding release: approved; (b) merging the Release PR; (c) running just perf-gate on the reference machine (ADR-0048 replaced the automated gate with this human step); (d) approving sign; and (e) separately approving secretless publish-approval. The approval check is the independent required workflow release-gate.yml; it recognizes a Release PR from its manifest diff/bot branch as well as the mutable pending label, invalidates a surviving approval whenever the head changes, and keeps label events away from ci-required. release.yml is dispatchable only from protected main, checks out the explicit dispatched SHA, and revalidates dispatched SHA = tag = draft target in preflight and again before build, signing, attestations, and publication. A stray tag starts nothing and cannot supply workflow code to the signing chain. The agent never merges the Release PR, pushes a version tag, approves an environment, or invokes the credentialed release path without an explicit version-named instruction.

Rationale

  • release-please over in-tree (git-cliff + xtask) [the earlier lean]: once daily-loop cost is not a criterion, a bespoke in-tree release script is neither the most convenient nor the most standard option — it is a maintained reinvention. The Release-PR bot is the lower-friction, more-recommended 2024+ workflow and is what the maintainer chose. The reversal is deliberate, recorded here, not drift.
  • release-please over release-plz: release-plz is the Rust-native gold standard but only bumps Cargo crates; the C# csproj would be a bolt-on. release-please’s generic/extra-files updater bumps both languages from one config — the decisive factor for a dual-language repo.
  • Declared-and-bot-edited over git-derived (nbgv/vergen): a height/git describe version is not Conventional-Commits-semantic (it can’t turn feat: into a minor bump), breaks on .git-less source builds, and needs two separate tools for two languages. The stored number costs almost nothing and keeps reproducibility/debuggability.
  • Channel suffix at build time, base in the file: the stamp (fmf-buildstamp / InformationalVersion) is the right home for the derived part (channel + sha); the declared base never needs git at build time. fmf-buildstamp is a leaf off the two front-end binaries so the .git/HEAD rerun never rebuilds the hot engine crates.
  • Artifacts for nightly: with Immutable Releases on, a rolling nightly tag can’t overwrite assets; dated prereleases would accumulate and need GC. A 14-day artifact auto-expires and is the least-moving-parts “separate bucket”.

Rejected alternatives

  • In-tree git-cliff + xtask (self-authored release command) — maximum control and reuses the existing xtask version-edit code, but non-standard and a maintenance burden; loses on the chosen “convenience + recommended” axis. Rejected (was the prior lean; consciously overturned).
  • release-plz (Rust-native bot) — idiomatic for the engine, but Rust-only: the C# version would need a second mechanism. Rejected for a dual-language repo.
  • nbgv + vergen (git-derived, no stored version) — the purest “no version to manage” model, but non-composable with CC-semantic bumps, .git-dependent, and two-tool. Rejected.
  • Keep manual xtask release X.Y.Z — simplest diff, but it is the human-driven shackle this ADR removes. Rejected.
  • Nightly as dated GitHub pre-releases — publicly downloadable without a login, but accumulates under Immutable Releases and needs a GC workflow. Deferred behind a trigger.
  • Nightly as a rolling nightly Release — the common “always-latest” pattern, but incompatible with Immutable Releases (can’t overwrite the asset). Rejected outright.
  • CalVer (YYYY.MM.x) — used by some apps, but a filename-search engine/CLI benefits from SemVer’s change-magnitude signal, and CC→SemVer is the mainstream pairing. Rejected.

Consequences

  • The maintainer’s release ritual becomes “write Conventional Commits, merge the Release PR.” just release and the xtask version-edit modules (release.rs, version/{cargo_toml,csproj}.rs) are removed — versioning has one owner (release-please), not two.
  • release.yml is startable only from protected main and checks out the exact Release Please tag SHA as data; it stamps the stable build cleanly (FMF_BUILD_VERSION / FmfChannel=stable).
  • Release automation is split across the version bot and the release workflow it dispatches. Amended by ADR-0048 (2026-07-28): the trusted-main performance gate and hosted completion dispatcher between them are retired — their measurement instrument cannot exist on a user-owned repository — and the gate is now the manual just perf-gate. No job both executes self-hosted repository code and holds publication authority, because no job is self-hosted at all.
  • Every contributor commit must be a Conventional Commit (local hook + PR-title gate). --no-verify remains forbidden.
  • The wire contract and golden corpus are untouched — the version string is not part of the wire format, so no golden re-capture.
  • First activation must be verified: the first Release PR should show engine/Cargo.toml [workspace.package] version bumped (by the toml updater), engine/Cargo.lock synced (by the cargo update --workspace step, as a follow-up commit on the PR branch), the csproj <Version> bumped, and CHANGELOG.md written. Three real gotchas were hit and fixed during bring-up: release-please’s rust release-type cannot write workspace-inherited versions (→ simple + toml updater + the lock-sync step); extra-files paths are package-relative and reject .. (→ the package is the repo root so it can reach both engine/ and app/); and changelog-path likewise rejects ...

Re-examination triggers

  • Anonymous public nightly downloads wanted → promote nightly artifacts to dated GitHub pre-releases + a retention/GC workflow.
  • Signed nightly wanted → add a sign job to nightly.yml (reusing ADR-0029’s pipeline). Per ADR-0040, nightly now carries the rest of the supply chain (CycloneDX SBOMs, the osv-scanner gate, and keyless build-provenance + SBOM attestations); signing is the only remaining stable-only supply-chain gate, so this trigger is the sole nightly/release difference left.
  • release-please’s Cargo-workspace handling proves insufficient (version or Cargo.lock not bumped correctly) → switch the Rust side to a toml extra-file updater + an explicit cargo update -p lock-refresh, or adopt the cargo-workspace plugin.
  • crates.io / NuGet publishing begins → re-evaluate release-plz (Rust) and a real package-publish step; the current config publishes nothing to a registry.
  • The C# csproj surface grows complex (multiple version-bearing props) → reconsider Nerdbank.GitVersioning for the .NET side specifically.

ADR-0036: In-process soft restart for engine changes; single-instance-safe relaunch for language

Date: 2026-06-29 / Status: Accepted (fixes the #107 onboarding relaunch, which collided with ADR-0030 single-instancing; no service / transport / contract change)

Context

The engine transport is chosen once when the page is built (EngineClientFactory.Resolve), so after a first-time service registration the running instance is still in the unavailable setup state. #107 made onboarding “take effect” by relaunching the process with --engine=pipe (ShellOps.RelaunchWith: Process.Start(self) then Application.Current.Exit()).

That relaunch is structurally incompatible with single-instancing (ADR-0030, Program.DecideRedirection, keyed on the fixed string "find-my-files"):

  1. The relaunched process starts while the original is still alive, so AppInstance.FindOrRegisterForKey finds the original as primary → RedirectActivationTo + return 0. Application.Start/OnLaunched never run and --engine=pipe is silently dropped.
  2. The original receives OnActivatedShowFromTray (still unavailable) and then runs the queued Application.Current.Exit().
  3. Both processes are gone — the app disappears. The user reopens it manually; that fresh launch (no primary) auto-detects the now-running service and connects, masking the bug as “I had to start it twice.”

Confirmed on a real bundle: after setup → Ok there is no process relaunch; the rebuilt page resolves the pipe in auto mode. The same fix covers service register/restart and uninstall recovery. Language remains the sole process-restart case and uses AppInstance.Restart.

The #107 tests injected a fake relaunch Action, so the real process × AppInstance interaction was never exercised — the “state-transition blind spot” pattern again.

Decision

Split relaunch by the reason it exists; spawning a process to mutate in-memory transport state is the anti-pattern.

  1. Engine re-resolution → in-process soft restart (App.SoftRestart / App.SoftRestartIntoPipe, AppReload). No new process: close the diagnostics window, re-resolve App.EngineClient (the same resolve-or-unavailable behavior the launch path uses), re-navigate the root Frame to a fresh MainPage (which rebuilds its MainViewModel against the new engine), then dispose the old engine. The window, tray, and process stay alive. Used by onboarding (EnableSearchAsync), service register/restart, and uninstall recovery.
  2. Language change → true restart via AppInstance.Restart (IAppRestart / RealAppRestart). PrimaryLanguageOverride is a process-global WinRT setting applied in the App ctor, and the Loc ResourceLoader and MainWindow chrome are built once — only a fresh process re-localizes the whole shell. AppInstance.Restart fully terminates this process before the new one registers, so single-instancing lets the new instance become primary instead of redirecting back to the dying one. A non-success return is surfaced (notify, don’t go silent).

AppReload is pure over its boundaries (resolve / get-set engine / re-navigate / close-diagnostics) so the load-bearing ordering and once-only disposal are unit-tested without a real Frame or window.

Rationale

  • The soft restart is what ADR-0030 already argued for: the pain is the WinUI/.NET cold start, and a tray-resident app exists precisely to keep the process hot. Killing and respawning the process to flip an in-memory field contradicts that; re-resolving in place does not.
  • x:Bind OneTime on IsDisconnected / IsReady stays correct because the soft restart builds a fresh page and view model — the properties are re-evaluated against the new engine, exactly as a process relaunch used to give for free.
  • The “ItemsSource must not be swapped / VirtualResultList is page-lifetime” UI rules are about a live page; a fresh page is the sanctioned reset, so they are not violated.
  • Language genuinely needs a new process, and AppInstance.Restart is the purpose-built, single-instance-aware API for it — not a raw Process.Start + Exit, which is the very thing that broke.

Trade-off

A small residual race exists for the language restart: between AppInstance.Restart terminating this process and the fresh one registering the key, a third manual launch could momentarily become primary. It is rare and self-heals on the next launch. The in-process soft restart has no such window (no second process).

Rejected alternatives

  • Fix only the single-instance side — make the spawned --engine=pipe process win (e.g. AppInstance.GetCurrent().UnregisterKey() before spawn) for every relaunch. One mechanism, but it re-pays the full WinUI/.NET cold start ADR-0030 fights, tears the connection state machine down across a process boundary, drops the tray, and flashes the window. UnregisterKey is also [Experimental] in the SDK (lint friction). Kept as the documented fallback for the language path only if AppInstance.Restart proves unreliable for this unpackaged self-contained app on the pinned SDK.
  • In-process soft restart for language too. Rejected: a page rebuild updates the page body but not the MainWindow title bar / tray tooltip (resolved once at window construction) nor the process-global ResourceLoader, so the shell would be half-translated.
  • An in-place engine swap on the existing page (no page rebuild). MainViewModel deeply embeds the engine (event marshaler, search orchestrator, perf panel) behind a readonly field and OneTime bindings; a swap means rebuilding most of it — the page rebuild is the clean form of that.

Consequences

  • No wire-contract / golden / ABI change. Pure C# app layer.
  • ShellOps.RelaunchIntoPipe and the Process.Start+IAppExit RelaunchWith are removed; IAppExit/DispatcherAppExit go with them. ShellOps.Relaunch now means “true restart, language only” and goes through IAppRestart. IProcessRunner stays (it still backs ShellOps.Open).
  • ServiceProvisioner keeps one injected action seam whose production target is App.SoftRestartIntoPipe; MainViewModel owns no duplicate restart seam.
  • MainPage disposes its view model on Unloaded (the Frame does not), so a soft restart releases the old engine-event subscriptions; the disposal is idempotent with the Window.Closed engine dispose.
  • Testability: AppReload (ordering + once-only dispose + re-entry guard) and the IAppRestart seam (empty-arg restart + swallowed-failure) are unit-tested. App / Program / MainWindow / MainPage stay [ExcludeFromCodeCoverage] view-shell (ADR-0022); just ui-test covers ordinary navigation and the short release procedure retains only the secure-desktop UAC check automation cannot drive.
  • Security: unchanged — all local to the unelevated app; the privileged service surface (docs/SECURITY.md) is untouched.

Verification

AppReloadTests pins close→resolve→swap→navigate→dispose ordering and re-entry; ShellOpsTests pins restart failure handling. Action-injection and UI suites cover setup/transport recovery; language remains the sole AppInstance.Restart caller.

Re-examination triggers

  • If AppInstance.Restart proves unreliable for the unpackaged self-contained bundle on the pinned WindowsAppSDK, switch the language path to the UnregisterKey-before-spawn fallback (with a justified experimental-API suppression).
  • If a future feature needs to change transport without losing live page state (e.g. results), revisit the in-place engine swap rejected above.

ADR-0037: logfmt diagnostics, retention caps, and cross-process correlation

Date: 2026-06-30 / Status: Accepted (no wire-contract / golden / ABI change; the correlation reuses ids already on the wire)

Current-state amendment (2026-07-25): persisted diagnostics are now a strict privacy boundary. Arbitrary strings are redacted at the Rust sink; C# records only exception type/HRESULT/Win32 code; crash markers and panic hooks never persist messages, payloads, locations, stacks, or backtraces. Diagnostic-copy output applies the same redaction.

Context

Logging was already disciplined — tracing + a non-blocking daily appender + a DiagLayer fanning WARN+ to the diag ring and the UI on the engine side (ADR-0018’s degrade!), and a hand-rolled FileLog with crash markers and exception funnels on the app side. But against industry-standard structured logging four gaps remained:

  1. No retention cap (a real bug). tracing_appender::rolling::daily never deleted the old dated engine-log files — they accumulated forever. The app kept a single .old generation.
  2. Not structured. Both sides wrote freeform human strings; tracing’s spans were wired but unused. Neither log was machine-parseable (grep/awk), and fields were not first-class.
  3. No cross-process correlation. A single user query produces lines in both app.log and the engine log (two processes on the pipe path, two files even in-process on the FFI path) with nothing tying them together.
  4. No injection / redaction policy. Query text and filenames — the product’s sensitive asset (the whole index is filenames) — were logged verbatim, and nothing sanitised CR/LF or control characters out of values (log-injection / forged-line risk).

Decision

Adopt one logfmt line schema as the canonical format for both languages, cap retention, and correlate the two logs using ids that already exist — so the contract is untouched.

  1. logfmt schema (the canonical surface). Each line is ts level area [field=value …] msg="…" [err="…"]:
    • ts = RFC3339 with the local UTC offset (2026-06-30T12:34:56.789+09:00); level is a width-5 tag; area is the subsystem (query/scan/snapshot/pipe/…).
    • A value is emitted bare unless it contains a space, =, ", \, or a control char (< 0x20); then it is "…"-quoted with "\", \\\, \r/\n/\t, and other control chars → \uXXXX. Values are capped at 1 KiB with a marker.
    • Engine: a custom tracing_subscriber::FormatEvent (LogfmtFormat in fmf-core::diag) plus a matching FormatFields so span fields render the same way. App: a Serilog ITextFormatter (LogfmtFormatter).
  2. Retention caps. Engine moves to RollingFileAppender::builder().max_log_files(N) (N = 14 for the resident service, 7 for FFI/CLI). App uses Serilog’s File sink with fileSizeLimitBytes = 5 MiB, rollOnFileSizeLimit, retainedFileCountLimit = 5.
  3. Cross-process correlation — contract-unchanged. The engine groups a request’s log lines under a qid span (pipe: the frame request_id, already client-generated and echoed; FFI: an in-process counter). The per-query “query served” line — emitted once by each transport, where the result handle exists — carries rid: the resultId on the pipe, the boxed result handle’s address on the in-process FFI path. The UI logs the same rid from SearchAsync on both transports. rid is the universal app↔engine join key; qid adds intra-engine request grouping. The query line is skipped for an unchanged idle USN requery, mirroring the UI’s RefreshInPlace.
  4. Security. The logfmt quoting is the log-injection defence (CR/LF can never escape a value). Query text is never logged — only qlen — because filenames/queries are the sensitive asset (redaction); the existing %ProgramData% DACL + no-telemetry posture still apply. The app facade (FileLog) takes scalar strings only and the ADR forbids Serilog destructuring ({@obj}) so an object graph can never be expanded into the log.
  5. C# adopts Serilog, used directly (no Microsoft.Extensions.Logging / DI) to stay closest to the existing static FileLog facade. FileLog keeps its public surface (Info/Warn/Error + a new Debug and a structured Event) and routes through Serilog; the crash marker and Tail stay hand-rolled (a marker must survive a hard crash that never flushes the logger).

The change flow is one-directional and stops short of the contract: prose here → LogfmtFormat/LogfmtFormatter → both languages’ tests green. fmf-contract / fmf-proto / contract/golden are not touched, proven by the golden suites staying green unmodified.

Rationale

  • logfmt over JSON-lines: the consumers are a human reading the file and the F12 “copy diagnostics” dump; logfmt keeps human readability while making fields machine-parseable. NDJSON would win only for an ingestion pipeline we explicitly do not have.
  • Reuse request_id/rid over a new field: the pipe frame header already carries a client-generated request_id, and the result handle is already returned to the UI — correlation is therefore a logging change, not a wire change. Adding a qid to FmfQueryOptions (the alternative) would have been a golden-breaking contract change for no extra capability.
  • Span-based qid: a per-request span means every line a request emits (including a degrade! warn mid-query) inherits the id automatically — no threading an id through every call site.
  • Transport-level “query served”: rid is allocated by the transport, not by Engine::query; emitting the line there is the one place that has the trace and the handle, giving one fully-correlated line instead of two.

Trade-off

The engine timestamp caches the local UTC offset once at process start (resolving the zone per line would dominate the formatter), so a DST boundary crossed mid-process stamps subsequent lines with the pre-transition offset — harmless for logs. On the FFI path the engine’s qid counter and the UI’s logs do not share a qid (no wire id exists in-process); they join on rid instead, which is sufficient. Query errors (which produce no result handle) are not rid-correlated; the engine still logs them under its qid span and the UI logs them separately.

Rejected alternatives

  • OpenTelemetry / OTLP export, or any collector. Rejected: the product is local-only with a permanent no-telemetry posture, the on-disk index is the sensitive asset behind a DACL, and the query hot path holds a single-digit-ms p99 budget — a collector/exporter on that path is unjustifiable. On-machine logs + the diag ring + fmf_engine_stats cover every need.
  • NDJSON (one JSON object per line). Rejected for the file format: it halves human readability for a tool we do not run. (The diag ring is already Serialize-able if a JSON view is ever wanted.)
  • Adding qid to the query contract (FmfQueryOptions / QueryTrace). Rejected: it breaks golden bytes / the C# DTO for a correlation we get for free from the existing request_id + rid.
  • Microsoft.Extensions.Logging abstraction in the app. Rejected: it pressures a DI container into a hand-wired WinUI composition root; direct Serilog maps 1:1 onto the existing static FileLog calls.
  • Serilog.Sinks.Async. Rejected: an async sink can lose the last lines on a hard crash, violating “don’t go silent”; the synchronous File sink keeps them.

Consequences

  • No wire-contract / golden / ABI change; init_diag grows a max_log_files argument (internal). The former fixed engine-log name gains a date (engine.<date>.log); F12 “open log folder” is unaffected, and Tail still reads the fixed app.log.
  • New deps: Serilog + Serilog.Sinks.File (managed-only, ~1 MB; bundle size unaffected). No new Rust dependency — the formatter is hand-rolled on std + the index’s existing civil-date math.
  • A new counter is not added; no new degrade! path is introduced, so the metrics.rs / COUNTER_NAMES / contract-gen triple is untouched.

Verification

Formatter tests pin quoting, CRLF neutralization, truncation, field order, correlation, and exception-text rejection in both languages. Crash-marker, diagnostic-copy, retention, and golden tests own the remaining privacy and contract guarantees.

Re-examination triggers

  • If a genuine off-machine aggregation need ever appears (it should not, given the no-telemetry posture), revisit the NDJSON/OTLP rejections — but only behind an explicit opt-in.
  • If query-error correlation becomes important, add an rid-less error line under the engine’s qid span and a matching app-side field.

ADR-0038: Build identity discoverability in the shipped artifact

Date: 2026-06-30 / Status: Accepted (no wire-contract / golden / ABI change; radiates the existing FMF_BUILD_VERSION / fmf-buildstamp identity to the artifact surface)

Context

The dev / nightly / stable build lanes were complete (ADR-0035): one format authority (xtask version) computes a channel-aware string, CI exports it as FMF_BUILD_VERSION, and the Rust binaries (fmf-buildstamp::VERSION) and the C# app (InformationalVersion) stamp it. The single source of truth was clean — but it did not reach the surface of a downloaded artifact. A user who downloaded a build could not tell, at a glance, which channel/version it was:

  1. The zip filename was the only signal, and it is lost the moment the zip is extracted (the bundle folder is always FindMyFiles, paths.rs).
  2. No in-bundle version file — the bundle shipped only an instructional README.txt, with no version/channel/commit/date.
  3. The root FindMyFiles.exe (the launcher) carried no real version resourcewinresource defaulted it to the internal crate name fmf-launcher at a static 0.1.0.0, identical across all channels (misleading).
  4. No in-app version display — the GUI showed only the engine version (F12, pipe-only); the app’s own version was reachable only via the F12 “copy diagnostics” dump.
  5. SHA256SUMS.txt was non-standard — a bare uppercase hash with no filename (mirroring PowerShell Get-FileHash), so the ubiquitous sha256sum -c SHA256SUMS.txt could not verify it.
  6. Inconsistencies in the version identity itself: fmf diag reported the bare CARGO_PKG_VERSION (no channel/sha), disagreeing with fmf --version; the C# InformationalVersion carried Source Link’s full 40-char sha with no g prefix and even leaked +sha into stable, diverging from the Rust +g<7> / clean-stable shape.

Decision

Radiate the existing build identity (no new version source) to four artifact surfaces, following industry-standard mechanisms, and fix the identity inconsistencies so every surface agrees.

  1. In-bundle BUILDINFO.txt (the strongest at-a-glance, survives extraction). xtask publish writes a Notepad-friendly, grep-able key: value file (product, version, channel, commit, date, source, license) beside README.txt. The version uses the same precedence as the binaries (FMF_BUILD_VERSION else local -dev+g<sha>); the date is the git commit date (reproducible, no wall clock), with the nightly’s embedded date preferred. Parsing/rendering is pure and unit-tested in xtask/src/version.rs (parse_identity / render_buildinfo).
  2. Launcher Win32 VERSIONINFO. fmf-launcher/build.rs sets the resource via winresource: numeric FileVersion = X.Y.Z.0 (Win32 requires a.b.c.d) and string ProductVersion = FMF_BUILD_VERSION (the channel-aware value), plus ProductName=FindMyFiles, description, copyright and source URL — so Explorer → Properties → Details identifies the build without running it.
  3. In-app About / version block. The Settings dialog’s Status section shows the app version (always, selectable to copy) and the engine version (pipe mode), and raises a warning InfoBar when their X.Y.Z bases differ (BuildInfo.SameBase) — surfacing a stale app/service pairing that nothing previously detected.
  4. Standardised release artifacts. SHA256SUMS.txt moves to coreutils format (lowercase hash, two spaces, filename), directory-driven over build/package, verifiable with sha256sum -c. The nightly Actions artifact is named with its date (find-my-files-nightly-<date>).
  5. Identity consistency. fmf diag now reports fmf_buildstamp::VERSION (matches --version). The C# side disables Source Link’s auto-append (IncludeSourceRevisionInInformationalVersion=false) and constructs +g<short7> itself via an MSBuild target, exactly mirroring xtask version (+g<7>; stable stays clean).

The change flow stops short of the contract: fmf-contract / fmf-proto / contract/golden are untouched (no wire/ABI/golden change).

Rationale

  • Radiate, don’t add a source. Every surface derives from the one FMF_BUILD_VERSION / fmf-buildstamp value; the format authority remains xtask version. This preserves the ADR-0035 single-source discipline — no surface can drift.
  • BUILDINFO.txt over relying on the zip name. The filename is the strongest signal until extraction, after which a plain-text file is the only thing that survives — and it doubles as machine-readable (key: value), consistent with the project’s logfmt direction (ADR-0037).
  • coreutils SHA256SUMS is the de-facto standard. No stable release had shipped (.release-please-manifest.json = 0.0.0), so there were no consumers of the old uppercase/no-filename shape to break — the right moment to standardise.
  • Mismatch detection is cheap and real. Both sides already stamp the same fmf-buildstamp shape, so comparing the X.Y.Z base is trivial and catches a genuine support-time problem (which app is talking to which service).

Trade-off

The launcher’s dev fallback (~5 lines: FMF_BUILD_VERSION else -dev+g<sha>) is duplicated in fmf-launcher/build.rs and fmf-buildstamp/build.rs. Build scripts cannot share a runtime const, and a shared leaf crate for five lines is over-engineering; the duplication is annotated with a cross-reference and the format authority stays in xtask version. The C# short-sha is resolved at MSBuild target-execution time (the sha is unknowable at property-evaluation time); when git is absent (source tarball) it falls back to the channel tag without a sha, mirroring the Rust None branch. Local C# dev builds append .dirty under the same tree-dirtiness rule as the Rust/xtask identity.

Rejected alternatives

  • A shared fmf-buildmeta leaf crate used as a build-dependency by both build scripts: rejected — the only real duplication is the 5-line dev fallback, and parsing lives once in xtask; a crate to dedupe five lines fails the dsa-first cost test.
  • Naming the extracted bundle folder with the version (so the folder itself signals the build): rejected — the zip stores contents at the root (matching the historical Compress-Archive shape), and BUILDINFO.txt covers the post-extraction case without restructuring the archive.
  • Embedding SBOMs in SHA256SUMS.txt: deferred — SBOMs are attached as release assets and used as predicates in the ZIP’s SBOM attestations; they are not themselves build-provenance subjects. SHA256SUMS stays directory-driven over build/package, so a future SBOM dropped there is covered automatically.
  • Structured version= / channel= logfmt fields on every line: deferred — the version is already on the launch line; first-class fields are a refinement, not part of artifact discoverability, and would touch the freshly-landed logfmt infra.

ADR-0039: CLI DevEx pass 2 — completions distribution, drift-in-CI, format consistency

Date: 2026-06-30 / Status: Accepted (no wire-contract / golden / ABI change; the fmf remit is unchanged — still a developer/diagnostic tool, ADR-0026). Decision 1’s bundling half is superseded: the end-user ZIP ships neither fmf.exe nor completion scripts, because the CLI is not an end-user surface. The fmf completions <shell> subcommand — the always-fresh half of that decision — is the remaining distribution path, and the rest of this ADR stands.

Context

ADR-0026 brought fmf to a first-class developer CLI (--version, --color/-q/--format, FMF_E_* exit codes, a versioned JSON envelope, generated completions + docs/cli.md). A second audit against industry-standard CLI ergonomics found gaps — some where the documentation claimed a behaviour the implementation never had:

  1. Completions were not actually distributed. ADR-0026 and the codegen example both said completions were “bundled at release time”, but neither xtask publish nor package copied build/completions/ into the bundle — and there were no install instructions anywhere.
  2. Generated CLI Markdown duplicated live help. Keeping a generator, committed output, and drift test added machinery without serving a distinct consumer.
  3. --format json was inconsistent. stats ignored --format entirely (it always dumped pretty JSON, even in human mode, as several separate documents); io-probe, spike and criterion-gate did not even receive the format context, so --format json was silently ignored.
  4. Help was thin. Positional drive arguments had no help on any command, there were no usage examples, long_about was unused, and wrap_help was off (long help did not wrap to the terminal).
  5. A confusing flag name. bench --json <path> (write a report file) collided with the global --format json (stream to stdout).
  6. No CLI control of log level. The level was hard-coded to info; verbosity could only be raised via the FMF_LOG env var.

Decision

A second polish pass, entirely within ADR-0026’s remit (no fmf search, no TUI, no new engine seams; the clap surface stays logic-free).

  1. Completions: distributed + on-demand subcommand. A new fmf completions <shell> subcommand prints a completion script to stdout (the gh/rustup pattern: eval "$(fmf completions bash)"), rendered from the single command() tree. clap_complete moves from a dev-dependency to a normal one. xtask publish now ships completions/{fmf.bash,_fmf,fmf.fish,_fmf.ps1} by invoking the just-built app/fmf.exe completions <shell> — so the bundled scripts are produced by the exact binary they ship beside and cannot drift. Install steps are documented in the repo README and the bundled README.txt.
  2. No generated CLI Markdown. fmf --help is the live reference; the generator, committed copy, and drift test are removed.
  3. --format json everywhere. Every result-producing command honours --format: stats emits one combined format_version-stamped document in json mode (human keeps the per-column dump); io-probe, spike and criterion-gate receive Ctx and emit JSON when asked. The interactive index REPL and completions are text-only by nature.
  4. Help quality. drive help on every command, help on the remaining io-probe flags, a root long_about (stating this is a developer/diagnostic tool — the product is the WinUI app) and an after_help examples block, and the clap wrap_help feature.
  5. bench --json <path>bench --out <path>, removing the collision with the global --format json. (just bench-baseline updated.)
  6. -v/--verbose (repeatable) maps to info/debug/trace; FMF_LOG still overrides it (init_diag).

These are additive to the JSON envelope (format_version unchanged).

Rationale

  • Generate the bundled completions from the shipped binary: any other source (the codegen example, a committed copy) could drift from the binary’s real surface; fmf.exe completions cannot.
  • Live help over generated Markdown: one executable surface cannot drift from itself and needs no documentation-only dependency.
  • --format json consistency over “not every command has JSON”: a flag that is silently ignored is a worse experience than one that always means the same thing; the dev/measurement commands all have structured results worth emitting.
  • completions subcommand AND bundled files: the subcommand is the portable, always-fresh path (and what power users expect); the bundled files mean a downloaded copy needs nothing built to install completions.

Rejected alternatives

  • A man page (clap_mangen). Rejected: find-my-files is Windows-only and ships no man reader, so a man page would have no consumer. Following the Unix convention here would add a build artdefact nobody can use — the project’s dsa-first discipline says evaluate and decline, not follow blindly.
  • A committed generated CLI reference. Rejected: it duplicates fmf --help and requires generator/drift machinery with no separate consumer.
  • Erroring on --format json for commands without a JSON form. Rejected in favour of actually giving every result-producing command a JSON form — the consistent, less surprising outcome.
  • Reviving fmf search / a TUI. Out of scope here; still governed by ADR-0026’s deferral (needs its own ADR + a pipe client).

Re-examination triggers

  • If a command’s JSON shape needs to change meaning (not just add fields), bump format_version (ADR-0026).
  • If completion scripts grow shell-specific install complexity, consider a fmf completions --install helper.
  • If the CLI ever needs to be a scriptable end-user search surface, that remains an ADR-0026 question (fmf search via a pipe client, new ADR) — not a DevEx-polish change.

ADR-0040: Nightly carries full supply-chain provenance (signing stays stable-only)

Date: 2026-06-30 / Status: Accepted (CI-only; no contract/golden/ABI change)

Context

ADR-0035 §5 made nightly an unsigned 14-day GitHub Actions artifact (not a Release), and ADR-0029 keeps Authenticode signing tag-driven and approval-gated (release environment), so signing is deliberately stable-only. That part is industry-normal.

But auditing the channels showed the supply-chain gap was wider than signing. Only release.yml generated CycloneDX SBOMs, ran the osv-scanner gate (ADR-0034), and issued keyless build-provenance + SBOM attestations. nightly.yml shipped only SHA256SUMS.txt — no SBOM, no attestation, no scan. Nothing in the ADRs justified that gap; those steps simply only existed in release.yml. The documented rationale covered signing (eSigner quota + human approval), not provenance.

By SLSA, every distributed artifact — nightlies included — should carry build provenance. GitHub’s actions/attest provenance mode is keyless (Sigstore Fulcio/Rekor via the workflow OIDC token): no stored secret, no human approval, no eSigner quota. So a nightly could be gh attestation verify-able at essentially zero cost, and its absence was an implementation gap, not a decision.

Decision

Give nightly the same supply-chain artefacts as a release, minus signing:

  1. CycloneDX 1.6 SBOMs (Rust + C#) + the osv-scanner gate run in nightly.yml, exactly as in release.yml. A known-vulnerable dependency in either release dependency graph fails the nightly too (don’t ship a vulnerable nightly).
  2. Keyless build-provenance attestation over the zip + SHA256SUMS.txt, and an SBOM attestation per SBOM (provenance + 2 × SBOM = 3 attestations). The Windows build/test job is Contents-read only; a separate job downloads the completed immutable Actions artifact and alone receives id-token: write / attestations: write. No secrets, no approval gate.
  3. The SBOMs are added to the 14-day artifact so a tester gets them alongside the zip.
  4. Signing is unchanged — still tag-driven, approval-gated, stable-only (ADR-0029). A nightly stays unsigned; the only remaining stable-only gate is the Authenticode signature.

To avoid drift, SBOM generation + the osv-scanner gate are extracted into a composite action (.github/actions/sbom-scan) shared by release.yml’s isolated sbom job and nightly.yml, so the generation path and its external pins (cargo-sbom 0.10.0 and osv-scanner 2.3.6) live in one place. The deterministic app SBOM is assembled by the repository’s xtask, not a second generator CLI. Attestation remains in dedicated workflow jobs because OIDC permissions are job-level and must not coexist with repository build/test code.

Rationale

  • SLSA expects provenance on every distributed build. Keyless attestation is free and unattended — there was no cost reason to withhold it from nightly. This closes the real standards gap.
  • Signing is the legitimate stable-only gate, not SBOM/provenance: eSigner has a quota and a human approval gate (ADR-0029); attestation/SBOM have neither.
  • Composite action over copy-paste: two workflows pinning the SBOM/scanner toolchain independently would drift; one shared action keeps them identical (the project already uses composite actions for single-source, e.g. rust-toolchain).
  • Separate the OIDC boundary: an immutable artifact handoff is cheap on a nightly and prevents build/test code from ever seeing attestation-write authority.

Trade-off

A nightly’s SBOM is attached + attested but not re-scanned by sbom-monitor.yml — that monitor only tracks the latest Release, and a nightly artifact expires in 14 days, so post-hoc monitoring of it would be pointless. The osv-scanner gate at build time still applies. The nightly’s keyless attestations persist in the repo’s Attestations tab even after the artifact expires (harmless, and they remain verifiable for anyone who kept the download).

Rejected alternatives

  • Sign nightlies too. Rejected: keeps the eSigner quota + approval-gate cost that ADR-0029/0035 deliberately reserve for stable. The “Signed nightly wanted” trigger in ADR-0035 still governs that, and is now the only supply-chain difference between nightly and release.
  • Duplicate the SBOM steps into nightly.yml. Rejected: tool-version drift between the two workflows; the composite action is the single source.
  • Document the gap as intentional and leave nightly checksum-only. Rejected: it would be documenting a non-decision; SLSA says ship the provenance, and it’s free.

Re-examination triggers

  • Signed nightly wanted → add a sign job to nightly.yml (ADR-0035 §re-examination; reuses ADR-0029’s pipeline). This is now the sole remaining nightly/release supply-chain gap.
  • If a third workflow needs SBOMs, it uses: ./.github/actions/sbom-scan (don’t re-inline).

ADR-0041: nextest as the canonical Rust test runner

Date: 2026-07-25 / Status: Accepted. Supersedes only the cargo-nextest rejection in ADR-0014.

All Rust unit/integration tests use cargo nextest run; stable-Rust doctests remain the separate cargo test --doc gate because nextest cannot enumerate them. The two Cargo workspaces own separate .config/nextest.toml files, while the executable version is pinned once in mise.toml.

Retries are disabled and flaky passes fail. Slow tests are terminated after a bounded number of timeout periods, and every run has a global timeout. CI, lefthook, coverage, mutation testing, targeted recipes, and ignored admin tests share this executor.

ADR-0014 rejected nextest when a small pure suite showed no speed benefit. The release pass added a real overlapped-I/O lifecycle test and exposed an indefinitely stuck cargo test binary. Per-test attribution, process isolation, Windows Job Object termination, and JUnit evidence now outweigh the extra tool. This adoption is for bounded, diagnosable tests—not a speed claim. Raising global timeouts or enabling retries to hide one unstable test is rejected.

ADR-0042: u32 result-row string lengths

Date: 2026-07-26 / Status: Accepted. Superseded only in its version numbers by ADR-0043 (FFI ABI) and ADR-0044 (pipe protocol and pipe name); the row layout decided here is unchanged. Current values are fmf-contract::versions, not this text.

FmfRow.name_len and parent_path_len are u32; the row is 56 bytes with an explicit zero reserved tail word. This bumps both the FFI ABI and named-pipe protocol to 3 (fmf-engine-v3). Golden frames are intentionally recaptured.

The former u16 lengths silently wrapped parent paths above 65,535 WTF-8 bytes even though Windows permits longer extended-length paths. Rejecting those paths would make valid NTFS entries unsearchable, so widening the shared row is the only lossless choice. Path reconstruction is separately bounded at the maximum possible WTF-8 size of a valid 32,767-unit NT path and rejects cycles, out-of-range parents, and larger corrupt acyclic graphs before materialization.

Both codecs validate every blob window and the zero reserved field. Page row count, encoded payload size, and indexing volume count remain contract-bounded before allocation.

ADR-0043: Monotonic FFI allocation-owner IDs

Date: 2026-07-26 / Status: Accepted. Superseded only in its version numbers by ADR-0044, which raised the FFI ABI again; the owner-ID ownership rule and the descriptor layouts decided here are unchanged. Current values are fmf-contract::versions, not this text.

FmfPage and FmfBlob carry a nonzero owner_id: u64. Their free exports accept only that ID:

int32_t fmf_page_free(uint64_t owner_id);
int32_t fmf_blob_free(uint64_t owner_id);

Page and blob owners remain in separate live-allocation registries, while their IDs come from one process-wide monotonic namespace. ID zero is the no-allocation/free-no-op sentinel. Unknown, already-freed, forged, stale, and cross-kind IDs return FMF_E_INVALID_ARG without dereferencing caller memory. IDs are never reused; exhaustion fails closed with FMF_E_IO.

The previous address-keyed registries rejected ordinary forged, cross-kind, and double frees, but could not distinguish an old pointer from a newer allocation placed at the same recycled address (ABA). Reading a cookie through the stale pointer would itself be undefined behavior. Returning a generation-bearing owner ID and freeing by ID alone removes foreign addresses from ownership transfer and closes that gap.

This is an incompatible FFI-only POD/signature change, so ABI_VERSION is 4: FmfPage is 40 bytes with owner_id at offset 32, and FmfBlob is 24 bytes with owner_id at offset 16. The named-pipe page/blob encoding, pipe name, and PROTOCOL_VERSION=3 are unchanged. Runtime HelloResp.abi_version naturally reports 4. The shared golden HelloResp is deliberately a literal version-1 representative rather than a snapshot of current constants, so its wire bytes remain unchanged.

Contract tests pin layout/signatures and prove zero no-op, forged/double and cross-kind rejection, and the ABA property: freeing an old ID after allocating a replacement cannot release the replacement.

ADR-0044: Cooperative query cancellation and explicit presentation basis

Date: 2026-07-26 / Status: Accepted.

Queries are cancellable end to end. fmf-core exposes a cloneable QueryCancellation backed by an AtomicBool; it is checked at every phase boundary and at bounded intervals inside sweeps, refinement, materialization, derived-path construction, lazy sorting, and merge. Cancellation returns FMF_E_CANCELLED=8, creates no result handle, records no served-query metric, and never commits a partial per-volume refinement cache.

The pipe adds one-way opcode QueryCancel=13. Its payload is empty and its frame request_id names the Query request to cancel. Each connection owns the request-ID registry. A new Query cancels older queued and running queries before it is queued (latest-query-wins); an explicit cancel is handled by the reader without entering or waiting behind the work queue; disconnect cancels every registered query. Normal Query completion still emits exactly one response, including a cancelled response when cancellation wins.

The in-process ABI creates a monotonic opaque query-control ID before managed code starts native work. Cancellation addresses that control ID; fmf_query borrows its cancellation token, and the control is freed only after token callback deregistration and query return. Unknown, forged, stale, reused, and double-freed IDs fail closed. Cancellation itself is idempotent while the control is live. This ordering eliminates the pre-cancel registration race.

QueryTrace.unchanged is no longer inferred from VolumeSlot::last_query. That cache is only an internal refinement accelerator and may contain useful work from another connection or a result that was never presented. A Query instead carries an optional live presentation-basis result handle. Pipe and FFI boundaries validate that the basis belongs to the same connection/engine and is still live, then core compares the complete ordered ID column with the new result. Only that exact comparison may set unchanged=true. A freed, stale, cross-connection, cross-engine, cancelled, or missing basis behaves as no basis. This makes the UI’s RefreshInPlace decision an explicit capability-based comparison rather than global ambient state.

The contract changes are intentionally incompatible:

  • ABI_VERSION 4 → 5.
  • PROTOCOL_VERSION 3 → 4 and pipe fmf-engine-v3fmf-engine-v4.
  • FmfQueryOptions grows from 20 to 32 bytes: the existing five u32 fields, a required-zero u32 reserved field, then presentation_basis:u64.
  • status 8 is appended as CANCELLED; opcode 13 is appended as QUERY_CANCEL.

HelloResp.abi_version remains informational on the pipe. Named-pipe compatibility and service probing require only an exact protocol version; direct FFI loading continues to require an exact ABI version. The SCM description marker therefore identifies the protocol and pipe only.

Rejected alternatives:

  • Managed cancellation only. It hides stale UI work but leaves expensive native scans running and lets superseded work consume the bounded service queue.
  • A new core trait seam. Cancellation is execution state, not an external dependency; adding a port would violate the two-seam architecture ceiling.
  • Global last-query identity. It crosses connection/publication boundaries and can falsely authorize in-place refresh.
  • Thread interruption or killing a worker. It cannot preserve Rust lock and allocation invariants. Cooperative checks provide deterministic cleanup with bounded latency.

ADR-0045: System32-only static imports for the elevated service

Date: 2026-07-26 / Status: Accepted

Context

The UI launches the bundled fmf-service.exe through UAC for initial installation. At that instant the signed EXE and its parent directories are locked and identity-checked, but the extracted bundle is still user-writable. An EXE-only check does not protect a statically imported DLL resolved from the application directory. Microsoft identifies this as DLL planting and documents /DEPENDENTLOADFLAG:0x800 (LOAD_LIBRARY_SEARCH_SYSTEM32) as the linker-level mitigation:

The service previously imported VCRUNTIME140.dll, so merely selecting System32 would also create an undeclared VC Redistributable prerequisite.

Decision

  • All Windows MSVC Rust artifacts link the CRT statically.
  • The fmf-service binary alone receives /DEPENDENTLOADFLAG:0x800 from its crate build script. It is the only binary elevated while still in the extracted bundle.
  • xtask parses IMAGE_LOAD_CONFIG_DIRECTORY.DependentLoadFlags without trusting dumpbin availability. Publish checks the source service before the app embeds its image digest and checks the copied service again. Package repeats the check independently before writing the ZIP. The required value is exactly 0x800.
  • Release signing may alter only Authenticode-excluded bytes; collection checks every first-party PE before any bundle overwrite and again afterward.

Consequences

The elevated helper has no adjacent private runtime DLL to load, and its remaining static imports resolve only from System32. A missing linker flag, truncated/malformed PE, stale pre-change bundle, substituted signer result, or future extra load flag fails the release pipeline closed.

This governs static imports. Any future explicit DLL load must use an absolute trusted path or safe LoadLibraryEx flags and requires a new threat-model review; plugins remain out of scope.

Rejected alternatives

  • Rely on Authenticode and EXE locks. They authenticate the image, not DLLs selected later by the loader.
  • Ship a private VC runtime beside the helper. That restores the writable adjacent-DLL attack surface.
  • Apply the linker flag to every PE. Only the pre-install service crosses this UAC boundary; a crate-scoped flag keeps unrelated load behavior explicit.
  • Trust a workflow-only dumpbin check. Local publish/package must enforce the same invariant, and release packaging must not be able to bypass publish.

ADR-0046: The change-to-screen path is one allocated latency budget

Date: 2026-07-27 / Status: Accepted

Context

“A filesystem change is visible in the result list within 1 second” is an acceptance criterion, but no single component owns it. The path crosses four owners — the USN tail loop, the volume worker’s batch apply, the engine’s event debounce, and the UI’s re-query plus render. Each stage’s own number exists in code (a park duration, a debounce interval, a test budget), and each is individually defensible. What has never been recorded anywhere is that they are one budget: that the stages must sum to less than the AC, how much of it each stage is allowed to spend, and — most importantly — that adding a plausible-looking delay in any one layer silently spends someone else’s share.

The concrete failure this guards against is throttle accretion. Every layer on this path has a locally reasonable argument for coalescing events (“the UI is re-querying too often”), and a second throttle is invisible in that layer’s own tests while roughly doubling the observed end-to-end delay.

Decision

Treat change → on-screen as a single budget, allocated once and in one place:

stagebudgetowner
idle-edge USN discovery≤250msthe tail loop’s non-blocking-read park (0 on a busy volume, which never parks)
USN batch commit≤100msvolume worker
IndexChanged debounce200msengine
UI re-query≤100msapp
render≤100msapp
total≤750ms worst case(≤500ms once the volume is active)

Two invariants follow, and they — not the individual numbers — are the decision:

  1. Exactly one event-rate throttle exists on the whole path, and it is the engine-side IndexChanged debounce. No additional throttle, coalescing timer, or “settle” delay may be added on the UI side or at the transport. The debounce is placed in the engine because that is the single point where every path (in-proc FFI and pipe push alike) already converges; a second one downstream would be uncoordinated with it and could only ever add.
  2. The budget is allocated, not accumulated. A stage that needs more must take it from another stage in this table and this ADR must be updated. A change that adds a stage-local delay without a corresponding reduction elsewhere is a regression against the AC even when every stage-local test still passes.

The pipe transport adds no new stage. Its page round trip is charged against the existing re-query allowance: ResultPage 64-row round trip p99 ≤5ms, which is enforced by the loopback integration test and continuously observed as PageRttEwma in the diagnostics panel. Event push is one hop after the debounce above, so the budget structure is identical on both transports — the service split does not cost a stage.

Consequences

  • The 200ms debounce cannot be tuned in isolation to “reduce flicker”; flicker is addressed structurally instead, by the unchanged-result in-place refresh path (ADR-0015), which redraws nothing rather than by delaying the update.
  • Because the largest single term is the idle-edge park, worst case is only observed on an otherwise quiet volume; the active-volume figure (≤500ms) is the one users normally experience, and quoting the worst case is the conservative choice on purpose.
  • Regressions here are not caught by any one component’s tests. The transport-level term is the part with a mechanical gate (the ≤5ms loopback assertion); the rest is upheld by this allocation and by the single-throttle rule being reviewable.
  • The numbers live here rather than being restated per component, so a stage cannot quietly redefine its own share.

Re-examination triggers

  • The AC itself changes (a tighter than 1s change-visibility requirement), at which point the whole table is re-allocated rather than one stage shaved.
  • Pipe page-fetch p99 exceeds 5ms as the norm — this is the shared trigger with ADR-0016 / ADR-0018, since a transport that no longer fits inside the re-query allowance breaks the “the split costs no stage” premise.
  • A measured need for a second throttle (e.g. a device whose USN churn saturates the UI even after the engine debounce). Then the debounce moves or is re-parameterized — a second throttle is still not added.

ADR-0047: The NTFS byte grammar lives outside the Windows gate

Date: 2026-07-27 / Status: Accepted

Context

fmf-core splits into #[cfg(windows)] modules and pure ones, and lib.rs has always justified that split by fuzz reachability: the pure parsers compile on Linux, which is what lets engine/fuzz drive them under libFuzzer with the address sanitizer (ADR-0022’s property tests plus the coverage-guided pass).

That justification did not survive contact with the module list. The largest and most exposed parser surface in the product sat inside the gate:

modulewhat it decodes
scan/ntfs.rsboot sector, FILE record header, attribute headers, $FILE_NAME
scan/attribute_list.rs$ATTRIBUTE_LIST entries, non-resident run maps, extent closure
scan/record.rswhole-attribute-chain validation of one fixed-up record
scan/volume_io.rs::apply_fixupthe update-sequence array

Together roughly 2,700 lines of decoder plus its tests — the bulk of the untrusted-byte surface in the engine.

None of these had any Windows dependency — ntfs.rs imported only thiserror, record.rs imported nothing at all, and attribute_list.rs was already generic over impl Read + Seek. There was no cfg(windows) anywhere inside scan/; the gate existed solely at the pub mod scan; declaration in lib.rs, and these files were gated by accident of where they were filed.

The cost was not theoretical. .github/workflows/ci.yml lists engine/crates/fmf-core/** in its fuzz path filter and the contract-lint job claimed contract/proto were “the only engine crates that compile off-Windows”. Together those asserted a coverage story the layout did not deliver: a change to the NTFS grammar re-ran the fuzz job, and the fuzz job could not see the NTFS grammar.

This is the highest-value attack surface in the product. A crafted VHDX or a hostile USB stick lets an attacker choose every byte these decoders read, and fmf-service parses them as LocalSystem. #![forbid(unsafe_code)] means the language rules out memory unsafety, so the residual hazard is precisely what coverage-guided fuzzing is good at finding and property tests are not: an out-of-bounds slice or an arithmetic overflow that panics, which is a denial of service against a LocalSystem service.

Decision

Move the four pure surfaces above into a new ungated fmf_core::ondisk module — ondisk::ntfs, ondisk::record, ondisk::attribute_list, ondisk::fixup — and make them pub so a separate fuzz crate can reach them.

The #[cfg(windows)] boundary is drawn at acquisition, not at subject matter. A module is gated when it opens a handle or issues an FSCTL (scan/volume_io.rs, usn/session.rs, mft.rs, engine/), not because it is “about NTFS”. scan/ keeps acquisition and orchestration; ondisk/ owns the grammar those modules feed.

No decoder logic changed. The move is file relocation, visibility, and the doc comments that missing_docs (deny) requires on a newly public surface. Every existing test — including the proptest no-panic sweeps — moved with its module and still runs unchanged, and apply_fixup’s tests moved out of volume_io.rs’s Windows-only test module so they run on any host.

This does not touch ADR-0018’s two-seam limit

ADR-0018 caps the engine at two trait seams (SnapshotStore / JournalSource in engine/seams.rs) and forbids further port-ification. That limit is about trait indirection: each new port adds a dynamic-dispatch boundary, a set of test doubles, and a place where production and test behaviour can silently diverge.

This ADR adds no trait, no generic parameter, no injection point, and no test double. It moves concrete functions between files and changes which mod statement they hang from. The call graph after the move is identical to the call graph before it — scan::parse still calls attributes_complete directly, monomorphically, with no seam in between. The seam budget is unaffected and remains at two.

scan/parse.rs is deliberately not included

parse.rs is a parser too, and it is the natural next candidate, but it is excluded because it is not pure:

  • use crate::mft::collect_searchable_namesmft is #[cfg(windows)]
  • use crate::index::{EncodedEntry, Frn, VolumeIndexBuilder} — it does not merely decode bytes, it appends rows to the index under construction

Dragging it across would mean either ungating mft or pulling index construction into the grammar module, both of which would make ondisk mean something looser than “decodes untrusted bytes, touches nothing”. The bytes parse.rs decodes are already reachable through ondisk::ntfs and ondisk::record, which is where the byte-level attack surface actually lives; what parse.rs adds on top is name-selection policy and builder calls.

Consequences

  • fmf-core’s public API grows by the whole ondisk tree. This is the point — a fuzz target in a separate crate cannot reach pub(crate) — but it does mean the NTFS grammar is now a documented, semver-relevant surface rather than an internal detail. All of it is now documented, which it was not before.
  • ondisk::fixup::fixup_layout stays pub(crate): it is apply_fixup’s helper and is exercised through it, so it is not part of the surface being widened.
  • The fuzz targets themselves are not added by this ADR. Reachability is a prerequisite, not the coverage; until the targets land, the honest statement is that ondisk can be fuzzed, and fuzz.yml’s surface list is left describing only what actually runs.
  • ci.yml’s contract-lint comment is corrected. fmf-core is still not linted on Linux, now for a stated reason: the Linux view of it is a strict subset of the Windows clippy run, so it would duplicate an existing gate.
  • The Linux build of ondisk is verified by CI (the fuzz job builds fmf-core on ubuntu-24.04), not locally: this machine’s toolchain is mise-pinned to the MSVC target and adding a rustup target ad hoc is against the machine’s tooling rules.

Re-examination triggers

  • A cfg(windows) becomes necessary inside ondisk/. That would mean the acquisition/grammar line was drawn in the wrong place, and the module should be re-split rather than gated.
  • mft.rs’s Windows dependency is isolated. mft is gated only for peak_working_set / current_working_set / current_private_bytes (windows-sys ProcessStatus); its name-selection policy (collect_searchable_names, is_searchable_namespace) is pure. If those process-memory helpers move out, parse.rs’s blocker reduces to VolumeIndexBuilder alone and the exclusion above should be revisited.
  • A fuzz target finds a defect in a module that was moved here. That retroactively prices the move and argues for extending the same treatment to the next-most-exposed decoder rather than stopping at this boundary.
  • The public surface becomes a maintenance burden — e.g. an external consumer starts depending on ondisk types, or doc churn on the grammar becomes a routine cost. Then the alternative is a #[doc(hidden)] or fuzz-only feature gate, accepting that it weakens the “it is documented” benefit above.

ADR-0048: The release workflow is dispatched directly, not reached through workflow_run

Date: 2026-07-28 / Status: Accepted (amends ADR-0029; retires the CI measurement chain in ADR-0013 and ADR-0035)

Context

Publication used to be four stages deep:

workflow_dispatch → performance-gate-request
  → (workflow_run) → performance-controller     ← requires a self-hosted JIT runner
  → (workflow_run) → performance-release
  → (workflow_call) → release

The reasoning was sound and is worth restating, because the shape is right for the world it was designed for. workflow_dispatch loads workflow YAML from the selected ref, so a dispatchable workflow must never contain a self-hosted job: anyone who can push a branch could otherwise define one. The measurement runner is not disposable, so that is a real escalation. Splitting into a hosted-only request plus a default-branch workflow_run controller closes it correctly.

Two facts overtook it.

The chain is unstartable. performance-controller.yml targets a restricted organization runner group, fmf-performance, and this is a user-owned repository where such a group cannot be created. just performance-doctor failed by design for exactly that reason, as its own recipe comment said. The chain therefore stopped at stage one, and release.yml was never reachable through it. (main’s older, pre-chain release.yml did publish v0.1.0 and v0.1.1; what has never been reachable is this four-hop arrangement.)

The cost is seven CodeQL alerts per PR. actions/cache-poisoning/poisonable-step (CachePoisoningViaPoisonableStep.ql) requires an externally triggerable event that either satisfies runsOnDefaultBranch or is a workflow_call from a caller that does. runsOnDefaultBranch is a literal list of 21 event names in CachePoisoningQuery.qll, and workflow_run is on it. Every live alert binds (workflow_run) through caller resolution up to performance-release.yml. CodeQL cannot follow data across workflow boundaries — the input is in fact constrained, since the dispatcher verifies the source run’s conclusion, event, head branch, head SHA, workflow ID, and repository before passing it — and it never will. Seven false alerts on every PR drown the real ones; they were the only failing required check on PR #164.

workflow_dispatch is not in that list, is not push, is not pull_request_target, and is not workflow_call. Removing the trigger removes the alerts because the construct is gone, not because they were dismissed.

Decision

release.yml becomes a workflow_dispatch from the default branch, taking tag_name, commit_sha, and release_id as required string inputs. release-please.yml’s renamed dispatch-release job — still the place where release_id is derived from release-please’s own documented upload_url — runs gh workflow run release.yml --ref main with that triple.

Four things carry the security property the chain used to carry.

  1. Dispatching with --ref main loads main’s YAML. A tag identifies build data, never workflow code, directly rather than through two hops.
  2. A new secretless preflight job is admission control. Before any other job starts it re-derives and asserts the whole identity: exact vX.Y.Z tag shape, GITHUB_REF is refs/heads/main, GITHUB_EVENT_NAME is workflow_dispatch, controller and workflow SHAs are equal 40-hex commits on main’s lineage, commit_sha is 40-hex, the tag resolves live to commit_sha, the tag is on main’s lineage, and release_id is the exact numeric non-prerelease draft whose target_commitish is that tag SHA.
  3. Every later job keeps revalidating for itself. build, sign-stage, package, and publish each repeat the draft-identity check they already had. preflight narrows the window; it does not become the single point the rest of the pipeline trusts.
  4. The environments are the secret boundary for an off-main dispatch. Both release (eSigner secrets, required reviewers) and release-please (App credentials) restrict deployments to protected main. Selecting a non-main ref would run that ref’s copy of this file, and that copy would reach no signing credential, no App token, and no publication authority.

github.triggering_actor is asserted against ^[A-Za-z0-9-]+(\[bot\])?$, written to the run summary and a ::notice::, and attested. Under the chain, “who started it” was structurally irrelevant; a dispatch makes it the primary authorization fact, and an unrecorded primary fact is not a control.

Authorization accounting

The 37 authorization checks in the old path were classified individually: 22 ported, 15 dropped. Every drop is specific to the instrument being removed — the workflow_run source-run identity checks, the evidence-artifact download and re-verification (~460 lines of Python), the display_title parsing, the organization runner-group assertions, and performance_run_id itself. Every universal identity binding survives: ref is refs/heads/main, the tag resolves live to commit_sha, both on main’s lineage, release_id is the exact numeric draft with matching target_commitish, the pre-publication revalidation, the monotonic-version policy, and the fail-closed signing-secret behaviour. The ported checks cost zero new lines in build, whose first step was already a verbatim superset of them.

The attestation changes meaning, so it changes version

The custom release predicate goes to schemaVersion: 2. The predicate-type URL is unchanged, so consumers must read schemaVersion:

  • release.performanceRunId is removed; the run it identified no longer exists.
  • controller.triggeringActor is added.
  • controller.runId and controller.runAttempt change referent. Under schemaVersion 1 they identified performance-release.yml’s workflow_run run; they now identify release.yml’s own dispatch run. The field names are identical and the values are equally well-formed, which is exactly why this is recorded rather than left to be inferred — a silent referent change in a signed attestation is the drift class this ADR exists to remove.

Threat-model delta, stated plainly

release.yml goes from unstartable by anyone to startable by anyone holding Actions: write on this repository. That is a real widening and it is accepted deliberately.

What an unauthorized dispatch can reach: preflight, and — only if it supplies a genuine tag, its exact commit, and the matching numeric draft ID, all three already on protected main — build (60-minute timeout), sbom, and mutation (16 shards, 360-minute timeout). All three execute before the first human approval. That is compute, not authority: none of those jobs holds a secret, a write token, or an environment.

What it cannot reach: sign and publish-approval sit behind the required-reviewer release environment, and publish behind release-please. Both are restricted to protected main.

preflight is the mitigation for the compute exposure: a dispatch that cannot name a real tag/commit/draft triple dies in a five-minute ubuntu job. The release-stable-publication concurrency group (cancel-in-progress: false) and release-please.yml’s title-based dedupe prevent a second run for a triple that already has one.

prevent_self_review = true is rejected

The obvious hardening for “the dispatcher should not approve their own release” does not apply here and would brick every release. Verified live: the release environment has exactly one reviewer (P4suta), and the dispatching actor is that same person — either directly, or as the identity behind the release-please App that cuts the tag. Enabling prevent_self_review would make every release unapprovable by the only person able to approve it. It is rejected explicitly rather than listed as a consideration, and only becomes available when a second maintainer exists (which is also CODEOWNERS’ condition; see docs/RELEASING.md).

Consequences

  • The real-volume performance gate becomes a human step. just perf-gate, run on the reference machine before approving sign, replaces a mechanical precondition that could not start. The trade is an unreachable automated gate for a reachable manual one; the intent lives in DEV-287’s elevated-session checklist. docs/RELEASING.md carries the step.
  • The Criterion/real-volume baseline is recorded locally. just bench-baseline writes engine/benches/baseline.json on the reference machine and it lands through an ordinary reviewed PR. The hosted validate-then-propose split, the P: volume, and the baseline-writer App token are gone with the workflows.
  • xtask/src/performance_doctor.rs is deleted. It audited live GitHub state for the instrument — runner-group membership, restricted_to_workflows, selected_workflows, can_admins_bypass, the fmf-jit-ephemeral label — and has nothing left to audit. just performance-doctor goes with it.
  • The performance environment and the fmf-performance runner group never existed. Nothing needs to be torn down; the documentation that described provisioning them is removed rather than marked stale.
  • Two release guard tests are deleted and one is rewritten. The replacement pins the new shape, including a repo-wide sweep asserting that no workflow carries a workflow_run: trigger and that the only dangerous-triggers suppression left is the unrelated pull_request_target auto-merge guard.
  • The four deleted workflows remain in git history if an organization migration ever makes them relevant again.

Verification

  • CodeQL: the query’s where clause cannot bind workflow_dispatch through either disjunct, so release.yml cannot match at all. The same query against the default ref already returns []; the seven alerts on refs/pull/164/merge all bind (workflow_run) through the deleted caller.
  • zizmor (ci.yml:95, a leaf of the required ci-required check) has its own cache-poisoning audit. Its triggers_used_when_publishing_artifacts recognises only release, tag-filtered push, and release-branch push; workflow_dispatch falls to the empty arm. The trigger swap does not trade one finding for another, and the zizmor: ignore[dangerous-triggers] suppressions the chain required are deleted rather than moved.

Residual risk

If a future CodeQL bundle adds workflow_dispatch to defaultBranchTriggerEvent(), the alerts return. codeql-action is SHA-pinned, so that can only arrive through a reviewed Dependabot bump. The disposition then is dismissal with evidence, not restructuring: grepping cache across all six composite actions, release.yml, and mutation-controller.yml returns exactly one hit, a prose comment. There is no actions/cache, no Swatinem/rust-cache, and no setup-* invoked with a cache input anywhere on the release path, so the rule’s premise does not hold on this lane.

Re-examination triggers

  • The repository migrates to an organization and a hardened runner group becomes creatable. The original split was the right shape for that world, and the deleted workflows are recoverable from history. Restoring them means restoring the workflow_run alerts, so weigh a mechanical performance gate against seven suppressions per PR before doing it.
  • A second maintainer joins. prevent_self_review on the release environment becomes usable, and the dispatch/approval split stops being nominal.
  • A dispatch is used to burn CI minutes. The compute exposure above becomes real rather than theoretical; the response is to narrow who holds Actions: write, not to reintroduce an unreachable gate.