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
| # | Threat | Defense |
|---|---|---|
| 1 | ACL-bypass name leak — the privileged indexer exposes file names invisible under the user’s own ACL to another user | Restrict 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 |
| 2 | Remote connection | PIPE_REJECT_REMOTE_CLIENTS (+ server features are permanently out of scope per the won’t-do list) |
| 3 | Anonymous connection | No anonymous ACE in the explicit DACL = deny by default (the NullSessionPipes default is policy-dependent, so do not rely on it) |
| 4 | Pipe-name squatting / spoofed server | Server: 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, GetNamedPipeServerProcessId → match 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) |
| 5 | Malicious 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) |
| 6 | Local 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) |
| 7 | Leak/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 |
| 8 | Residual 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 |
| 9 | Privilege 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 |
| 10 | Tampering 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:
- a temporary local standard user’s distinct
TokenUserSID is denied by the production pipe DACL, while the authorized user connects to the same pending pipe instance; - a deliberately wide test-only DACL admits that other user at the kernel, but
server-side
verify_clientrejects and disconnects it; and \\COMPUTERNAME\pipe\...succeeds withPIPE_ACCEPT_REMOTE_CLIENTS, then fails withPIPE_REJECT_REMOTE_CLIENTSunder 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/V1as input, starting fromStartFileReferenceNumber=0. The returnedUSN_RECORD_V2has 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_JOURNALto get UsnJournalID/NextUsn →FSCTL_READ_USN_JOURNAL(READ_USN_JOURNAL_DATA_V0, blocking subscription possible withBytesToWaitFor>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 withFSCTL_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 undocumentedFSCTL_READ_UNPRIVILEGED_USN_JOURNALallows 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_CHANGEreports 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_NAMEand 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:(rangesa..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-reader0.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 inengine/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 infmf-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-sys0.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).ISupportIncrementalLoadinghas 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
QueryServiceStatusExfor the SCM-registeredfmf-engineservice. 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 standardsc sdset/SetServiceObjectSecuritypattern). The dangerous bits (SERVICE_CHANGE_CONFIG,DELETE,WRITE_DAC,WRITE_OWNER) must stay admin-only —SERVICE_CHANGE_CONFIGlets the holder rewritelpBinaryPathName, 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:
CreateServicewithdwStartType = SERVICE_DEMAND_STARTregisters 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=HighestAvailableruns unattended with full privilege;StartWhenAvailable=trueruns a missed daily trigger when the machine is next on. Registering from XML (schtasks /Create /XML) keeps<Command>/<Arguments>as separate elements, avoiding/TRcommand-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
regexcrate 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 providesRegexBuilder::size_limit(byte cap on the compiled program, default 10 MiB) anddfa_size_limit(byte cap on the lazy DFA cache, default 2 MiB); on overflowbuild()returns anError(CompiledTooBigequivalent).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 build→sign→publish 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 signonly 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-codesignAction (batch_sign) is SSL.com’s recommended CI integration, and it works with this account. It downloads CodeSignTool, runsscan_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 (run28082306344). It is SHA-pinnable (v1.3.2).sign/batch_signalso 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
signtoolcan 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’sbatch_signsigns 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 /twis 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/signtoolGet-AuthenticodeSignature.TimeStamperCertificateis unreliable for this assertion: it is null under-FilePathon the runner (a long-standing PowerShell bug), so the timestamp guarantee must come fromsigntool, 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
- Land Conventional Commits on
main. Release Please maintains the version, lockfile, changelog, and draft Release PR. - Confirm CI is green and run
just ui-testunelevated. - 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.
- Add
release: approvedto 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 av*tag manually. Release Please creates the draft/tag, andrelease-please.ymlimmediately dispatchesrelease.ymlfrom protectedmainwith that exact tag, commit, and draft Release ID. - Run
just perf-gateon 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. - Approve
signand then the secretlesspublish-approvaljob in the protectedreleaseenvironment. Confirm the expectedvX.Y.Zand immutable SHA each time. The subsequent API-only publication job obtains App credentials fromrelease-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 viaQueryTrace.cache(miss/refine/partial)
Re-examination triggers (only if all hold)
- Cache-MISS cold 3-char scan_us p99 > 25ms @1M
- Measured estimate from
fmf stats --trigram-estimate≤15B/file and total ≤110B/file - Posting diff maintenance ≤2ms/batch
- 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_offsentinel 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_CHANGEis 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 bothFILE_DELETEandHARD_LINK_CHANGEuses 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_fallbackscounter + 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 cachedperm_size/perm_mtimeis 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_indexwith 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_abortscounter + 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_referenceis 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_fallbackscounter + degrades to sequential reads (does not stay silent) fmf io-probeis 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-gateon the reference machine, and the committed baseline is recorded withjust bench-baselineand 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-controllerworkflow and protectedperformanceenvironment. 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 staticfmf-jit-ephemerallabel and verify the exact Actions job label set plusRUNNER_NAMEbefore 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 hostedperformance-releasejob 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_runjob 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
~/.cargoand target as artifacts, so it does not break incremental compilation. CI’sCARGO_INCREMENTAL=0is 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). ISupportIncrementalLoadinghas 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 onlyIList<T>) is the #1809 configuration and does not virtualize; reaching forISupportIncrementalLoadinginstead ofIItemsRangeInfois the #6883 crash; droppingINotifyCollectionChangedleaves 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/GetAtthrough 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 atGetAt(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 anInt32.MaxValue-1exception. 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_flushis 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_engineare 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 viavalidate_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_sidsrequires 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 byfmf-service restart(stop->start) —startalone 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 repeatedpipe client token rejectedon 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
RevertToSelffailure 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 thefmf-service installsubcommand is the only choice (making the logic unit-testable). - uninstall keeps data by default (
--purge-datadeletes .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/ RustSECURITY_SQOS_PRESENT) — at the default anonymous level the server’sImpersonateNamedPipeClientgets 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 whereauthorized_sidsis 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=1machine-security test creates a unique temporary local standard user throughNetUserAdd, logs it on to obtain a genuinely differentTokenUserSID, and proves both the production DACL denial and the independentverify_clientdenial behind a deliberately wide test-only DACL. It first proves the host’s remote transport with aPIPE_ACCEPT_REMOTE_CLIENTScontrol, then requires the otherwise-identicalPIPE_REJECT_REMOTE_CLIENTScase 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:
- 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. - Re-capture
contract/golden/under the explicitFMF_BLESS=1ritual (just contract-bless). Ordinary runs never re-capture; they must match the bytes already committed. - Regenerate the checked-in C# binding with
just contract-gen. - 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 (directoffset_of!) give the same guarantee. - A dedicated crate just for gen:
fmf-contract/src/bin/gen-contract.rssuffices 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 namefmf-engine-v1, ABI_VERSION=1, PROTOCOL_VERSION=1, FMFIDX04 were all bytes-unchanged (no version bump). Later ADRs have bumped these; the current values arefmf-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.GetRangeAsynctoo (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-gategreen 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)
| Old | New |
|---|---|
fmf-proto codes/PIPE_NAME/PROTOCOL_VERSION | fmf-contract codes/versions (proto re-exports) |
fmf-proto QueryOptionsWire/WireRow/EventWire | fmf-contract pod::{FmfQueryOptions, FmfRow, FmfEvent} |
fmf-ffi FMF_* constants / POD definitions / volume_bytes | re-exported from fmf-contract / volume::encode_label |
fmf-ffi error_chain / fmf-service/dispatch error_chain | fmf-core diag::error_chain (4KiB cap) |
fmf-core engine::VolumePhase | fmf-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 body | engine/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 constants | Engine/Generated/EngineContract.g.cs (generated, partial NativeEngine) |
C# DTOs inside IEngineClient.cs | Engine/EngineTypes.cs (CountersData moves to the generated artifact) |
C# connection / result handle inside PipeEngineClient.cs | Engine/Transport/{PipeConnection,PipeSearchResult}.cs |
C# MainPage.xaml.cs (452 lines) viewport/perf/converter | Controls/ResultsViewportManager / Views/PerfPanel / Converters/UiConverters (181 lines remain) |
C# App.xaml.cs 3 exception handlers | Services/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 ofext:is OR semantics, so do not add more terms)
- Excluded paths: for each entry p,
- Collision avoidance (the user’s explicit intent always wins): if a group contains
ext:/regex:, do not append the ext whitelist; if it containspath: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.FocusedSearchdefaults 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) + 3AppSettingskeys + one rewrite point inSearchOrchestrator- 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 unchanged —
release.ymlstill signs with the officialSSLcom/esigner-codesignAction (batch_sign) and the staging map described below. What changed is the pipeline around it: see ADR-0029 — abuild→sign→package→publishsplit with the secrets behind an approval-gatedreleaseenvironment, read-only packaging, write/OIDC-only publication, and a hardened verify (signtool /pa /twtimestamp + signer-CN). (An eSigner CKA + standardsigntoolmechanism 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.ymlwas 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 withsigntool /pa /twplus signer identity withGet-AuthenticodeSignature(do not silently succeed unsigned when signing was requested = the “do not stay silent” principle).
Consequences
- The signing step in
release.ymluses SSL.com eSigner.HAVE_SIGNINGrequiresES_USERNAME,ES_PASSWORD,CREDENTIAL_ID, andES_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
releaseenvironment secrets when the credential or TOTP changes. - Signing is limited to
release.ymldispatched from protectedmainagainst an exact tag/SHA/draft triple (ADR-0048; originally, reusable calls from the protected-maincontroller 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-dirin.cargo/config.toml(engine/.cargo→../build/engine,xtask/.cargo→../build/xtask). Relative paths resolve against the.cargo/parent (confirmed empirically withcargo 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.rsas 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-bookindocs/book.toml.
Rationale
- Artifacts were scattered across
engine/target,xtask/target,app/**/bin, rootdist/, root zip, root SBOM, andsite/, making them costly to track and clean. A singlebuild/means “delete it and everything is gone” plus an effectively one-line.gitignore. - The target-dir in
.cargo/config.tomlis not a toolchain pin, so it does not violate the rule against placingrust-toolchain.toml/global.json(avoiding double management with mise).
Consequences
- C# obj stays put (
app/**/obj/). Relocating obj requiresBaseIntermediateOutputPathto take effect during pre-restore evaluation, which effectively requiresDirectory.Build.props, but AGENTS.md prohibits that file (it silently shadows the analyzer injection ofwinapp run). obj is intermediate output and already gitignored, so there is no real harm. - The dev-tree
fmf-service.exelookup (ServiceSetup.csproduction + pipe/contract tests) followsbuild/engine/release. - Bundle internals: the
dist/FindMyFiles/root holds only the launcher +README.txt; the self-contained app and engine binaries publish intodist/FindMyFiles/app/(the .NET apphost must stay co-located with its runtime DLLs, so it cannot move to the root). The rootFindMyFiles.exeis a tiny native launcher (thefmf-launchercrate) that spawnsapp/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 inapp/. - The test-tmp fallback default in
testutil.rsisbuild/engine(because the config.toml target-dir does not set theCARGO_TARGET_DIRenv 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 tobuild/site. - Tools that assumed the old
engine/targetetc. (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 runanalyzer-injection mechanism changes to no longer depend onDirectory.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 helperBuildOpenStartInfowas 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.jsonandapp/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 exactmutateentry 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 beIgnoredfor the exact exclude-filter reason, and inside-scopeIgnoredis allowed only for its exact redundant nested-block optimization (Block removal mutationplusRemoved by block already covered filter). Those optimizer identities and all outside-scope status counts remain ingate.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 uipublic-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:
- Whole-query mode as a contract flag. Add
regex_mode:u32(16→20B) toFmfQueryOptions. 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-typedregex: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). - literal-prefilter driven. Run the regex through
regex_syntaxprefix/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). - Compile limits. Set
RegexBuildersize_limit/dfa_size_limit= 1MiB each. Overflow yieldsregex::Error::CompiledTooBig→ existingCompileError::Regex→FMF_E_QUERY_SYNTAX(5). No new error code is added.
Because this is an incompatible wire change, bump the pipe name fmf-engine-v1→v2 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
regexcrate 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
Sexists contiguously in every match → exists in the name. FoldingSand 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.*\.dll8.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 chara/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=50mshard) holds onlyregex: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=1golden recapture →just contract-gen→ both-language tests green.contract/golden/query_req_*.binis recaptured at 20B. - One line in
docs/SECURITY.mdthreat #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 asFMF_QUERY_CACHE). - Observability: on prefilter success
QueryTrace.driverispool-scan; on extraction failure it isfull-scan. - C# side:
RegexMode/ScopeonSearchOptions, 20B encoding inPipeProtocol,AppSettingspersistence, gear-menu UI,RegexHighlighter(.NET re-match; if it drifts, do not highlight).
Re-examination triggers
- 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.
- Real demand for path-scope regex is high and full-scan breaks p99 → consider path prefilter via name-portion anchor extraction.
- Measurement shows typical whole-query queries skew literal-less → only then re-evaluate ADR-0002 trigram (AND’d with all triggers of that ADR).
- 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:
--versionon the clap surface (sameCARGO_PKG_VERSIONdiagalready prints).- Global presentation flags
--color auto|always|never,-q/--quiet,--format human|json, threaded as a smallCtxinto 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 sharedfmf_contract::codestable — 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 anindicatifspinner during the volume index build. Both go silent under--quiet/--format json/non-TTY. - Machine-readable output.
--format jsonemits a single document on stdout (NDJSON forwatch) for the commands that have a JSON shape (diag/bench/watch); failures emit a JSON error envelope on stderr. Every payload carries aformat_version. - Generated completions. Shell completions in
build/completions/(PowerShell/bash/zsh/fish) are rendered from the shipped clap command tree.fmf --helpis 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
codecome fromfmf_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-cliis now lib + bin. Its unit tests run under the lib;assert_cmdbehavioural tests cover the non-elevated surface (version/help/usage/diag/JSON error); volume/USN assertions stay behindFMF_ADMIN_TESTS.- The end-user bundle contains neither
fmf.exenor completion scripts. - The
--format jsonpayloads are a versioned, not frozen contract: additive fields keepformat_version; a field changing meaning or being removed bumps it. Snapshot/behavioural tests pin the current shape. Version 2 replaces the misleadingdiag.engine_logfile path withdiag.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 searchvia a Rust pipe client (a new design, with its own ADR). - If the JSON shapes churn, bump
format_versionand 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:
- Manual start — register the service
SERVICE_DEMAND_START(wasSERVICE_AUTO_START+ delayed). It no longer starts at every boot; it runs only when something starts it. - 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 userSERVICE_CHANGE_CONFIG/DELETE/WRITE_DAC/WRITE_OWNER— on a LocalSystem service that is local privilege escalation. - App-launch start — SCM state is checked before probing: a definitively absent/stopped service pays no pipe timeout; only exact
SERVICE_STOPPEDmay route toStartThenPipe, while transitions/unreadable state stay pipe-only so in-proc cannot race the writer lock.Resolvestarts a marker-compatible stopped service unelevated and its supervisor waits for the pipe. Failure falls back to setup/re-registration. - Idle self-stop —
serve()stops itself afterservice.jsonidle_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.0disables it (the legacy “stay resident once started” behaviour). - Idle GC — a daily SYSTEM Scheduled Task runs
fmf-service gc, which uninstalls the service + removes the task + purges the data whenlast_useis older thangc_max_idle_days(default 7,0disables). To survive the portable app folder being deleted, install copiesfmf-service.exeinto 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_STARTservice 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/STOPon 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.jsonfields (idle_stop_secs,gc_max_idle_days) read with serde#[serde(default)]. Observability is via the rolling engine logs (idle stop) andapp.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);uninstallwithout 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 (MoveFileExdelay-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_sddlunit test (start/stop present; change-config/delete/write-DAC/write-owner absent), mirroring the pipe-SDDL pin. idle_stop_secsapplies to the consolerunpath too;just service-devusers who want it to stay up setidle_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.ymlis no longer reusable-only from a default-branchworkflow_runcontroller — it is dispatched directly from protectedmainbyrelease-please.yml, and therelease/release-pleaseenvironments’ protected-main deployment policy is what makes an off-main dispatch reach no secret. The job list gainspreflight(secretless admission control, first) andmutation(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 officialSSLcom/esigner-codesignAction 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
-
Sign with the official
SSLcom/esigner-codesignAction (command: batch_sign). This is SSL.com’s recommended GitHub Actions integration: the Action downloads CodeSignTool, runsscan_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 explicitoutput_path(the Action ignoresoverride). 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). -
Split
release.ymlinto eight jobs —build→sbom→sign-stage→sign→sign-collect→package→publish-approval→publish— using immutable Actions artifacts at each boundary. (ADR-0048 prependspreflightand runsmutationalongsidebuild.) Onlysignsees signing secrets and it executes no repository code.sbomscans disposable copies while preserving the sealed unsigned bundle.publish-approvalis a secretless second human decision.publishreceives 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. -
Gate the secrets behind an approval-gated
releaseGitHub Environment on thesignjob. The eSigner secrets are Environment secrets (not repo-level), with required reviewers and deployment refs restricted to protectedmainonly. The credentialed pipeline is startable only from protectedmain(ADR-0048: aworkflow_dispatchon--ref main, previously a default-branchworkflow_runcontroller); its tag and commit are validated data, never executable workflow source. -
Verify with
signtool verify /pa /twplus exact certificate identity./twmakes 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.TimeStamperCertificateis not used for the timestamp check — it is null under-FilePathon the runner (PowerShell#4060), so the timestamp guarantee comes fromsigntool. -
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 /twoverTimeStamperCertificate: the runner’s PowerShell returns a null timestamper under-FilePath, so asserting it would false-fail;signtoolexit 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 (run28119208195) did not fix it. - x64 signtool cannot load the 32-bit
eSignerKSP(run28119208195): the cert was inCurrentUser\MywithHasPrivateKey=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 signagainst SSL.com —dotnet signonly delegates to Azure Key Vault / Trusted Signing; it cannot drive eSigner. Technically incompatible.
Consequences
HAVE_SIGNINGrequires all fourreleaseenvironment secrets:ES_USERNAME/ES_PASSWORD/CREDENTIAL_ID/ES_TOTP_SECRET.- Every release run pauses before
signand again at the secretlesspublish-approval; the environments accept only protectedmain. 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.ymlis the executable runbook; the irreducible human approvals are summarized indocs/RELEASING.md.- A future MSIX (ADR-0028) can be signed by the same Action (
sign/batch_signaccept.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
signtoolflow 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:
- Close (×) hides to the tray — the main window’s
AppWindow.Closingis cancelled and the window isAppWindow.Hide()-den (this also removes the taskbar button). The only real exit is the tray icon’s right-click Exit. - Always hot — while tray-resident the process keeps the
MainWindow, itsMainViewModel, and the live engine connection alive. Restore isAppWindow.Show()+Activate()— nothing is rebuilt, so the query text, results and scroll position survive, and the first search after restore is zero-latency. - Self-written tray icon —
Shell_NotifyIconvia[LibraryImport](no third-party package). Its callback message andWM_TASKBARCREATEDare received by subclassing theMainWindowHWND withSetWindowSubclass. The context menu is a Win32TrackPopupMenuEx. - Single-instance —
DISABLE_XAML_GENERATED_MAIN+ a hand-writtenProgram.csusingAppInstance.FindOrRegisterForKey+Activatedredirection, 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.) - 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 neveractive==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.Closedteardown —EngineClient.Dispose()drops the pipe,activefalls 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
WindowSubclassplumbing supports adding it later as a second setting with no structural change. - A message-only window (
HWND_MESSAGE) for the tray callback —WM_TASKBARCREATEDis a broadcast, and broadcasts do not reach message-only windows, so the icon would never recover after an Explorer restart. The top-levelMainWindowdoes receive it. SetWindowLongPtr(GWLP_WNDPROC)to replace the window proc — clobbers theDesktopWindowXamlSourceproc that WinUI’s top-level window relies on.SetWindowSubclasschains 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
WindowSubclassbase is the natural host for a futureRegisterHotKey/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
AppSettingsfield (close_to_tray), picked up automatically by the source-generatedAppSettingsJsonContextas snake_case JSON — mirrors ADR-0027’s additive-service.jsonposture. - The process entry point changes:
DISABLE_XAML_GENERATED_MAINplus a hand-writtenProgram.cs. TheAppctor order (ApplyLanguageOverride → InitializeComponent → ExceptionPolicy.Install) is preserved unchanged;Program.Mainonly wrapsApplication.Start. - New Win32 interop surface (
Shell_NotifyIcon,SetWindowSubclass,TrackPopupMenuEx), pinned to System32 like every existing import. TheSUBCLASSPROCdelegate, 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 cachedApp.DispatcherQueuebefore 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 pureWindowLifecyclefunction 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.Hideplumbing is unchanged; only the hide/show handlers gain a Dispose/Resolve pair. - If a global hotkey is requested, host
RegisterHotKey/WM_HOTKEYon the existingWindowSubclass(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);
sizeis u32+overflow (ADR-0007);frnis the FrnIndex backing store;parent/flag/perm_nameare 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
0sentinel 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
u32column. - IndexStats
mtime_byteshalves; 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_offis inherently sorted.name_idis assigned in dict-append order, sodict_off[0] < dict_off[1] < …by construction. The sweep maps a hit offset →name_idwith a monotonic cursor overdict_offdirectly — theOffsetTablederived cache (build/extend/stale-pair logic inquery/memo.rs) is deleted, not ported. Stale gaps dissolve: a renamed entry points at a newname_id; the old name’s bytes are addressed byname_id, never per-entry, so they are never “stale”.- Sweep →
name_idbitset.driver_candidatessweepsdict_pooland sets a bit per matchingname_id(boundary/anchor checks per dict name, as today). Materialization walksperm_nameand keeps an entry whenname_id[id] ∈ bitset(an O(1) bit test fused into the existing perm walk) and live/excluded/residuals pass — so no reversename_id → [entry]index is needed.refineis untouched (it readsname()/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()rebuildsdict_pool/dict_off/dict_lenfrom the distinct live folded names with a transientFxHashMapinterner and remaps everyname_id; it runs atfinish()and insidecompact(). (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_dedupcounter triggers acompact()(which dedups) once it exceedslive_len / 4, bounding the bloat. dead_name_bytes/owned_name_byteschange meaning. A folded name’s bytes are dead only when its last referrer goes; with dedup they are recomputed atdedup_dict()rather than charged per-tombstone. Thedead_name_bytes_tracks_pool_garbagetest 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_batchregresses (~2 → 3–5 ms): theperm_namemerge comparator reads the folded name through thename_id → dict_offindirection (one extra cache miss, the same shapeindex/frn.rsalready accepts). Gated at ≤ +25%.- The fortified oracles must stay green:
refine == fresh(exec proptest),pool_scan/regexnaive oracles, the snapshot round-trips. Thesweep.rsstale-gap tests are rewritten for dict semantics.
Re-examination triggers
- If the cold-scan win fails to materialize (sweep no faster) or the
apply_batchregression 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 setbefore the liveness/exclusionflaggather. 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 touchflag. (query/exec.rs.) -
Gapless dictionary — drop the
dict_lencolumn (Lever 2). ADR-0032 gave each distinct name a(dict_off: u32, dict_len: u16)directory. Because names append contiguously andname_idis assigned in append order,dict_offis ascending and the pool is gapless, so a name’s length is the gap to the next offset (dict_pool.len()for the last). Thedict_lencolumn is removed;dict_offbecomes aD-entry CSR read asdict_off[k+1] − dict_off[k]. −0.95 B/entry beyond ADR-0032, and thepool_endbranch in the sweep/compact loops dissolves. The snapshot bumps FMFIDX06 → FMFIDX07; on-load validation becomes “dict_offnon-decreasing and within the pool” with each name’s length derived from the next offset. This supersedes thedict_lenparts of ADR-0032. -
Interner pre-size (Lever 6).
dedup_dict’s transientFxHashMapinterner iswith_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
Ddistinct dictionary names once by bytes, then sorts entries on a packed(rank << 32) | idu64 key. Distinct names → distinct ranks, so it is byte-identical to a fullcmp_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 itscmp_byinsertion. 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_originterns them and points eachorig_offat 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 theorig_pool/orig_offsnapshot sections keep their shape (just a smaller, deduped pool). −4.5 B/entry at rest — the--dict-estimategate measured −3.9 against an assumed gaplessorig_dict_offtable (+4·D_orig); deriving the length from the fold drops that table and beats the projection. Runs besidededup_dictatfinish/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 throughname_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)
| Lever | Effect | Path | Verdict |
|---|---|---|---|
| 3a predicate reorder | ~90% flag gathers skipped on selective queries | cold query | GO |
2 gapless dict (drop dict_len) | −0.95 B/entry | memory | GO (FMFIDX07) |
| 6 interner pre-size | dedup ~10-20% | scan | GO |
| 1A build-rank | sort 286 → 170 ms (invisible) | scan throughput | GO |
| 1 orig-pool dedup (table-free) | −4.5 B/entry (real C:: 562k differing → 221k distinct) | memory | GO (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 prefetch | no measurable win | cold query | REJECTED (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 decoration | resolves each batch name once, not per comparison | apply_batch | GO: byte-identical merge order, O(B) dict derefs vs O(B·log B) |
| frn 48-bit packing | −2.0 B/entry | memory | NO-GO: the FFI contract freezes FmfRow.frn: u64 |
| full-scan numeric SIMD | — | cold query | NO-GO: FullScan gathers in perm order; the residual is not a contiguous scan |
#[target_feature] multi-versioning | — | — | NO-GO: no hot arithmetic loop to vectorize (the sweep is memchr, the walk is a gather) |
| resident name-rank column | — | apply_batch | NO-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 derivation | — | — | NO-GO: both are load-bearing (the merge target and the FRN lookup), not caches |
| flag / parent bit-packing | — | hot read | NO-GO: POD snapshot columns, a 4 GiB parent ceiling, and a per-read mask on the hottest gather |
| 8 sweep bitset shards | dense-needle alloc | cold query micro | REJECT: 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_sectionsfixture and the structural validator derive each name’s length from the gaplessdict_offrather than a stored length. compute_dict_estimate’s historical projection still prints the Phase-2 figure with the+6·Ddirectory 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_origtable and read −3.9.- No contract/golden re-bless:
IndexStats, the counters, and the CLI surface are unchanged (theIndexStatspool/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_batchover +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-servicedependency closure, which covers the external runtime dependencies offmf_engine.dlland the launcher. CI proves the FFI graph is a subset and rejects developer-onlyfmf-clicomponents; 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
-
Release gate (
release.yml, isolatedsbomjob). Afterbuildseals the unsigned distribution,sbomdownloads 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 runsosv-scanner scan source -L fmf-engine.cdx.json -L app.cdx.json --config osv-scanner.tomlon disposable copies. A finding (exit 1) fails beforesign-stage, so a release with a known-vulnerable dependency in the resolved graph is never signed or published. -
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. -
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). -
osv-scanner.tomlat the repo root is the single ignore list. Accepted/unfixable advisories are recorded there (the OSV counterpart toengine/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. -
Tooling:
osv-scannervia the already-trustedtaiki-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
sbomjob before signing: generation and third-party scanning do not mutate the sealed unsigned bundle, and failure before the approval-gatedsignjob 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.tomlentry (not disabling the gate). - Partial overlap with
cargo-auditon the Rust side is accepted: the gate adds the .NET closure, and the monitor adds the shipped-vs-HEAD dimension neithercargo-auditnor Dependabot provide. - One new weekly workflow (cheap ubuntu) and one separate Contents-read
sbomjob on releases. Only the monitor’s isolated report job receivesissues: write; release assets and osv-scanner remain read-only. The release gate adds no write permission. - The
sbom-vulnissue 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.tomllist 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 is — not 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
-
release-please owns the version, CHANGELOG, tag, and draft release. Conventional Commits on
maindrive a bot (googleapis/release-please-action, SHA-pinned) that keeps a “Release PR” open; merging it bumps the version, updatesCHANGELOG.md, createsvX.Y.Zimmediately ("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.ymldispatches the trustedmainperformance workflow with the exact tag and commit as inputs; its default-branch completed-success handler invokes the trusted reusablerelease.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.jsonare the config. -
Version stays declared in the files; the bot edits them (not git-derived). The package is the repo root (
.) withrelease-type: "simple"andextra-files: atomlupdater setsengine/Cargo.toml$.workspace.package.version, and agenericupdater (keyed on anx-release-please-versionannotation) sets the csproj<Version>. We do not use release-please’srustrelease-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 thetomlupdater bumpsCargo.tomlbut notengine/Cargo.lock(and CI is--locked),release-please.ymlrunscargo update --workspaceon 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, notengine:extra-filespaths are resolved relative to the package dir and cannot use.., so reaching bothengine/Cargo.tomlandapp/.../FindMyFiles.csprojrequires the package to sit above both — which also letsCHANGELOG.mdlive 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. -
Three channels, stamped at build time. A new leaf crate
fmf-buildstamp(depended on only byfmf+fmf-service, neverfmf-core/fmf-ffi) resolvesVERSIONinbuild.rs; the C# csproj computesInformationalVersion. The baseX.Y.Zis the release-please-managed number; the channel suffix is layered at build time:- dev (local
just build) →X.Y.Z-dev+g<sha>(.dirtywhen the tree is dirty) - nightly →
X.Y.Z-nightly.<date>+g<sha> - stable → clean
X.Y.Zxtask version --channel <dev|nightly|stable> [--date]is the single source of the string format; CI exports it asFMF_BUILD_VERSION(Rust) /FmfChannel(C#).
- dev (local
-
Conventional Commits are enforced. Locally via a lefthook
commit-msghook (committed, mise-pinned); on PRs via the existingamannn/action-semantic-pull-requesttitle gate (squash-merge → the PR title becomes the commit, so the title is what release-please reads). -
Nightly = unsigned, 14-day GitHub Actions artifact — not a Release.
nightly.ymlbuilds the bundle frommain(skipping whenmainis unchanged in 24h), stamps it nightly, and uploadsfind-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. -
GitHub App credentials are fail-closed.
release-please.ymlmints 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 dedicatedrelease-pleaseenvironment with amain-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-mainlineage, then dispatchesrelease.ymlon--ref mainwith that exact tag, commit, and numeric draft ID (ADR-0048; previously a hosted-only request workflow at the head of a two-hopworkflow_runchain).release.yml’s ownpreflightjob 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. -
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) runningjust perf-gateon the reference machine (ADR-0048 replaced the automated gate with this human step); (d) approvingsign; and (e) separately approving secretlesspublish-approval. The approval check is the independent required workflowrelease-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 fromci-required.release.ymlis dispatchable only from protectedmain, checks out the explicit dispatched SHA, and revalidates dispatched SHA = tag = draft target inpreflightand 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-filesupdater 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 describeversion is not Conventional-Commits-semantic (it can’t turnfeat: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-buildstampis a leaf off the two front-end binaries so the.git/HEADrerun never rebuilds the hot engine crates. - Artifacts for nightly: with Immutable Releases on, a rolling
nightlytag 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
xtaskversion-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
nightlyRelease — 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 releaseand thextaskversion-edit modules (release.rs,version/{cargo_toml,csproj}.rs) are removed — versioning has one owner (release-please), not two. release.ymlis startable only from protectedmainand 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-verifyremains 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] versionbumped (by thetomlupdater),engine/Cargo.locksynced (by thecargo update --workspacestep, as a follow-up commit on the PR branch), the csproj<Version>bumped, andCHANGELOG.mdwritten. Three real gotchas were hit and fixed during bring-up: release-please’srustrelease-type cannot write workspace-inherited versions (→simple+tomlupdater + the lock-sync step);extra-filespaths are package-relative and reject..(→ the package is the repo root so it can reach bothengine/andapp/); andchangelog-pathlikewise 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
signjob tonightly.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.locknot bumped correctly) → switch the Rust side to atomlextra-file updater + an explicitcargo update -plock-refresh, or adopt thecargo-workspaceplugin. - 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"):
- The relaunched process starts while the original is still alive, so
AppInstance.FindOrRegisterForKeyfinds the original as primary →RedirectActivationTo+return 0.Application.Start/OnLaunchednever run and--engine=pipeis silently dropped. - The original receives
OnActivated→ShowFromTray(still unavailable) and then runs the queuedApplication.Current.Exit(). - 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.
- Engine re-resolution → in-process soft restart (
App.SoftRestart/App.SoftRestartIntoPipe,AppReload). No new process: close the diagnostics window, re-resolveApp.EngineClient(the same resolve-or-unavailable behavior the launch path uses), re-navigate the rootFrameto a freshMainPage(which rebuilds itsMainViewModelagainst 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. - Language change → true restart via
AppInstance.Restart(IAppRestart/RealAppRestart).PrimaryLanguageOverrideis a process-global WinRT setting applied in theAppctor, and theLocResourceLoaderandMainWindowchrome are built once — only a fresh process re-localizes the whole shell.AppInstance.Restartfully 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
OneTimeonIsDisconnected/IsReadystays 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 /
VirtualResultListis 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.Restartis the purpose-built, single-instance-aware API for it — not a rawProcess.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=pipeprocess 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.UnregisterKeyis also[Experimental]in the SDK (lint friction). Kept as the documented fallback for the language path only ifAppInstance.Restartproves 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
MainWindowtitle bar / tray tooltip (resolved once at window construction) nor the process-globalResourceLoader, so the shell would be half-translated. - An in-place engine swap on the existing page (no page rebuild).
MainViewModeldeeply embeds the engine (event marshaler, search orchestrator, perf panel) behind areadonlyfield andOneTimebindings; 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.RelaunchIntoPipeand theProcess.Start+IAppExitRelaunchWithare removed;IAppExit/DispatcherAppExitgo with them.ShellOps.Relaunchnow means “true restart, language only” and goes throughIAppRestart.IProcessRunnerstays (it still backsShellOps.Open).ServiceProvisionerkeeps one injected action seam whose production target isApp.SoftRestartIntoPipe;MainViewModelowns no duplicate restart seam.MainPagedisposes its view model onUnloaded(theFramedoes not), so a soft restart releases the old engine-event subscriptions; the disposal is idempotent with theWindow.Closedengine dispose.- Testability:
AppReload(ordering + once-only dispose + re-entry guard) and theIAppRestartseam (empty-arg restart + swallowed-failure) are unit-tested.App/Program/MainWindow/MainPagestay[ExcludeFromCodeCoverage]view-shell (ADR-0022);just ui-testcovers 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.Restartproves unreliable for the unpackaged self-contained bundle on the pinned WindowsAppSDK, switch the language path to theUnregisterKey-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:
- No retention cap (a real bug).
tracing_appender::rolling::dailynever deleted the old dated engine-log files — they accumulated forever. The app kept a single.oldgeneration. - 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. - No cross-process correlation. A single user query produces lines in both
app.logand the engine log (two processes on the pipe path, two files even in-process on the FFI path) with nothing tying them together. - 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.
- 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);levelis a width-5 tag;areais 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(LogfmtFormatinfmf-core::diag) plus a matchingFormatFieldsso span fields render the same way. App: a SerilogITextFormatter(LogfmtFormatter).
- 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 withfileSizeLimitBytes = 5 MiB,rollOnFileSizeLimit,retainedFileCountLimit = 5. - Cross-process correlation — contract-unchanged. The engine groups a request’s log lines under a
qidspan (pipe: the framerequest_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 — carriesrid: the resultId on the pipe, the boxed result handle’s address on the in-process FFI path. The UI logs the sameridfromSearchAsyncon both transports.ridis the universal app↔engine join key;qidadds intra-engine request grouping. The query line is skipped for an unchanged idle USN requery, mirroring the UI’sRefreshInPlace. - 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. - C# adopts Serilog, used directly (no
Microsoft.Extensions.Logging/ DI) to stay closest to the existing staticFileLogfacade.FileLogkeeps its public surface (Info/Warn/Error + a new Debug and a structuredEvent) and routes through Serilog; the crash marker andTailstay 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/ridover a new field: the pipe frame header already carries a client-generatedrequest_id, and the result handle is already returned to the UI — correlation is therefore a logging change, not a wire change. Adding aqidtoFmfQueryOptions(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 adegrade!warn mid-query) inherits the id automatically — no threading an id through every call site. - Transport-level “query served”:
ridis allocated by the transport, not byEngine::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_statscover 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
qidto the query contract (FmfQueryOptions/QueryTrace). Rejected: it breaks golden bytes / the C# DTO for a correlation we get for free from the existingrequest_id+rid. Microsoft.Extensions.Loggingabstraction in the app. Rejected: it pressures a DI container into a hand-wired WinUI composition root; direct Serilog maps 1:1 onto the existing staticFileLogcalls.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_diaggrows amax_log_filesargument (internal). The former fixed engine-log name gains a date (engine.<date>.log); F12 “open log folder” is unaffected, andTailstill reads the fixedapp.log. - New deps:
Serilog+Serilog.Sinks.File(managed-only, ~1 MB; bundle size unaffected). No new Rust dependency — the formatter is hand-rolled onstd+ the index’s existing civil-date math. - A new counter is not added; no new
degrade!path is introduced, so themetrics.rs/COUNTER_NAMES/contract-gentriple 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’sqidspan 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:
- 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). - No in-bundle version file — the bundle shipped only an instructional
README.txt, with no version/channel/commit/date. - The root
FindMyFiles.exe(the launcher) carried no real version resource —winresourcedefaulted it to the internal crate namefmf-launcherat a static0.1.0.0, identical across all channels (misleading). - 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.
SHA256SUMS.txtwas non-standard — a bare uppercase hash with no filename (mirroring PowerShellGet-FileHash), so the ubiquitoussha256sum -c SHA256SUMS.txtcould not verify it.- Inconsistencies in the version identity itself:
fmf diagreported the bareCARGO_PKG_VERSION(no channel/sha), disagreeing withfmf --version; the C#InformationalVersioncarried Source Link’s full 40-char sha with nogprefix and even leaked+shainto 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.
- In-bundle
BUILDINFO.txt(the strongest at-a-glance, survives extraction).xtask publishwrites a Notepad-friendly, grep-ablekey: valuefile (product, version, channel, commit, date, source, license) besideREADME.txt. The version uses the same precedence as the binaries (FMF_BUILD_VERSIONelse 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 inxtask/src/version.rs(parse_identity/render_buildinfo). - Launcher Win32 VERSIONINFO.
fmf-launcher/build.rssets the resource viawinresource: numericFileVersion = X.Y.Z.0(Win32 requiresa.b.c.d) and stringProductVersion = FMF_BUILD_VERSION(the channel-aware value), plusProductName=FindMyFiles, description, copyright and source URL — so Explorer → Properties → Details identifies the build without running it. - 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.Zbases differ (BuildInfo.SameBase) — surfacing a stale app/service pairing that nothing previously detected. - Standardised release artifacts.
SHA256SUMS.txtmoves to coreutils format (lowercase hash, two spaces, filename), directory-driven overbuild/package, verifiable withsha256sum -c. The nightly Actions artifact is named with its date (find-my-files-nightly-<date>). - Identity consistency.
fmf diagnow reportsfmf_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 mirroringxtask 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 remainsxtask version. This preserves the ADR-0035 single-source discipline — no surface can drift. BUILDINFO.txtover 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
SHA256SUMSis 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.Zbase 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-buildmetaleaf 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 inxtask; 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-Archiveshape), andBUILDINFO.txtcovers 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.SHA256SUMSstays directory-driven overbuild/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:
- Completions were not actually distributed. ADR-0026 and the
codegenexample both said completions were “bundled at release time”, but neitherxtask publishnorpackagecopiedbuild/completions/into the bundle — and there were no install instructions anywhere. - Generated CLI Markdown duplicated live help. Keeping a generator, committed output, and drift test added machinery without serving a distinct consumer.
--format jsonwas inconsistent.statsignored--formatentirely (it always dumped pretty JSON, even in human mode, as several separate documents);io-probe,spikeandcriterion-gatedid not even receive the format context, so--format jsonwas silently ignored.- Help was thin. Positional
drivearguments had no help on any command, there were no usage examples,long_aboutwas unused, andwrap_helpwas off (long help did not wrap to the terminal). - A confusing flag name.
bench --json <path>(write a report file) collided with the global--format json(stream to stdout). - No CLI control of log level. The level was hard-coded to
info; verbosity could only be raised via theFMF_LOGenv 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).
- 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 singlecommand()tree.clap_completemoves from a dev-dependency to a normal one.xtask publishnow shipscompletions/{fmf.bash,_fmf,fmf.fish,_fmf.ps1}by invoking the just-builtapp/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 bundledREADME.txt. - No generated CLI Markdown.
fmf --helpis the live reference; the generator, committed copy, and drift test are removed. --format jsoneverywhere. Every result-producing command honours--format:statsemits one combinedformat_version-stamped document in json mode (human keeps the per-column dump);io-probe,spikeandcriterion-gatereceiveCtxand emit JSON when asked. The interactiveindexREPL andcompletionsare text-only by nature.- Help quality.
drivehelp on every command, help on the remainingio-probeflags, a rootlong_about(stating this is a developer/diagnostic tool — the product is the WinUI app) and anafter_helpexamples block, and the clapwrap_helpfeature. bench --json <path>→bench --out <path>, removing the collision with the global--format json. (just bench-baselineupdated.)-v/--verbose(repeatable) maps toinfo/debug/trace;FMF_LOGstill 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
codegenexample, a committed copy) could drift from the binary’s real surface;fmf.exe completionscannot. - Live help over generated Markdown: one executable surface cannot drift from itself and needs no documentation-only dependency.
--format jsonconsistency 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.completionssubcommand 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
manpage (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 --helpand requires generator/drift machinery with no separate consumer. - Erroring on
--format jsonfor 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 --installhelper. - If the CLI ever needs to be a scriptable end-user search surface, that remains an ADR-0026 question (
fmf searchvia 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:
- CycloneDX 1.6 SBOMs (Rust + C#) + the osv-scanner gate run in
nightly.yml, exactly as inrelease.yml. A known-vulnerable dependency in either release dependency graph fails the nightly too (don’t ship a vulnerable nightly). - 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 receivesid-token: write/attestations: write. No secrets, no approval gate. - The SBOMs are added to the 14-day artifact so a tester gets them alongside the zip.
- 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
signjob tonightly.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_VERSION4 → 5.PROTOCOL_VERSION3 → 4 and pipefmf-engine-v3→fmf-engine-v4.FmfQueryOptionsgrows from 20 to 32 bytes: the existing fiveu32fields, a required-zerou32reserved field, thenpresentation_basis:u64.- status 8 is appended as
CANCELLED; opcode 13 is appended asQUERY_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-servicebinary alone receives/DEPENDENTLOADFLAG:0x800from its crate build script. It is the only binary elevated while still in the extracted bundle. xtaskparsesIMAGE_LOAD_CONFIG_DIRECTORY.DependentLoadFlagswithout trustingdumpbinavailability. 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 exactly0x800.- 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
dumpbincheck. 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:
| stage | budget | owner |
|---|---|---|
| idle-edge USN discovery | ≤250ms | the tail loop’s non-blocking-read park (0 on a busy volume, which never parks) |
| USN batch commit | ≤100ms | volume worker |
| IndexChanged debounce | 200ms | engine |
| UI re-query | ≤100ms | app |
| render | ≤100ms | app |
| total | ≤750ms worst case | (≤500ms once the volume is active) |
Two invariants follow, and they — not the individual numbers — are the decision:
- 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.
- 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:
| module | what it decodes |
|---|---|
scan/ntfs.rs | boot sector, FILE record header, attribute headers, $FILE_NAME |
scan/attribute_list.rs | $ATTRIBUTE_LIST entries, non-resident run maps, extent closure |
scan/record.rs | whole-attribute-chain validation of one fixed-up record |
scan/volume_io.rs::apply_fixup | the 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_names—mftis#[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 wholeondisktree. This is the point — a fuzz target in a separate crate cannot reachpub(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_layoutstayspub(crate): it isapply_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
ondiskcan be fuzzed, andfuzz.yml’s surface list is left describing only what actually runs. ci.yml’scontract-lintcomment is corrected.fmf-coreis 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
ondiskis verified by CI (thefuzzjob buildsfmf-coreonubuntu-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 insideondisk/. 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.mftis gated only forpeak_working_set/current_working_set/current_private_bytes(windows-sysProcessStatus); its name-selection policy (collect_searchable_names,is_searchable_namespace) is pure. If those process-memory helpers move out,parse.rs’s blocker reduces toVolumeIndexBuilderalone 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
ondisktypes, 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.
- Dispatching with
--ref mainloads main’s YAML. A tag identifies build data, never workflow code, directly rather than through two hops. - A new secretless
preflightjob is admission control. Before any other job starts it re-derives and asserts the whole identity: exactvX.Y.Ztag shape,GITHUB_REFisrefs/heads/main,GITHUB_EVENT_NAMEisworkflow_dispatch, controller and workflow SHAs are equal 40-hex commits on main’s lineage,commit_shais 40-hex, the tag resolves live tocommit_sha, the tag is on main’s lineage, andrelease_idis the exact numeric non-prerelease draft whosetarget_commitishis that tag SHA. - Every later job keeps revalidating for itself.
build,sign-stage,package, andpublisheach repeat the draft-identity check they already had.preflightnarrows the window; it does not become the single point the rest of the pipeline trusts. - The environments are the secret boundary for an off-main dispatch. Both
release(eSigner secrets, required reviewers) andrelease-please(App credentials) restrict deployments to protectedmain. 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.performanceRunIdis removed; the run it identified no longer exists.controller.triggeringActoris added.controller.runIdandcontroller.runAttemptchange referent. Under schemaVersion 1 they identifiedperformance-release.yml’sworkflow_runrun; they now identifyrelease.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 approvingsign, 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.mdcarries the step. - The Criterion/real-volume baseline is recorded locally.
just bench-baselinewritesengine/benches/baseline.jsonon the reference machine and it lands through an ordinary reviewed PR. The hosted validate-then-propose split, theP:volume, and the baseline-writer App token are gone with the workflows. xtask/src/performance_doctor.rsis deleted. It audited live GitHub state for the instrument — runner-group membership,restricted_to_workflows,selected_workflows,can_admins_bypass, thefmf-jit-ephemerallabel — and has nothing left to audit.just performance-doctorgoes with it.- The
performanceenvironment and thefmf-performancerunner 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 onlydangerous-triggerssuppression left is the unrelatedpull_request_targetauto-merge guard. - The four deleted workflows remain in git history if an organization migration ever makes them relevant again.
Verification
- CodeQL: the query’s
whereclause cannot bindworkflow_dispatchthrough either disjunct, sorelease.ymlcannot match at all. The same query against the default ref already returns[]; the seven alerts onrefs/pull/164/mergeall bind(workflow_run)through the deleted caller. - zizmor (
ci.yml:95, a leaf of the requiredci-requiredcheck) has its own cache-poisoning audit. Itstriggers_used_when_publishing_artifactsrecognises onlyrelease, tag-filteredpush, and release-branchpush;workflow_dispatchfalls to the empty arm. The trigger swap does not trade one finding for another, and thezizmor: 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_runalerts, so weigh a mechanical performance gate against seven suppressions per PR before doing it. - A second maintainer joins.
prevent_self_reviewon thereleaseenvironment 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.