rsconstruct 0.9.46

Rust based fast build system
# Architecture Review Findings

Full-codebase architecture review (2026-08-02). This supersedes the 2026-07-18 audit — all of that audit's findings were closed (or are carried forward below), and its full remediation record lives in this file's git history.

The recurring failure pattern named by the previous audit still holds and is still the active failure mode: **knowledge duplicated across hand-synchronized places with nothing enforcing agreement**. Every bug below is an instance of two places that must agree by hand and don't. The structural asset to keep leaning on is the project's habit of converting invariants into tests (the `is_native`-agreement test, the schema-consistency tests, the bare-`println!` scanner): every time two places must agree, either merge them into one (preferred) or pin them with a test that names the offender.

Verification status: bugs V1–V6 were verified by hand against the source during this review. Items marked *(scan)* carry file:line evidence from the subsystem scans but were not independently re-verified — re-confirm when fixing.

## Verified bugs (fix before any refactoring)

- [x] **V1. `cache remove-stale` is a disguised `cache clear` — it deletes the entire cache.** *(Fixed 2026-08-03: `valid_cache_keys` now computes `descriptor_key` over the combined input checksum; `Product::cache_key()` deleted, its tests retargeted at `descriptor_key`; regression test `remove_stale_keeps_current_entries` added.)* `Builder::valid_cache_keys` (`src/builder/mod.rs:783-786`) collects `Product::cache_key()` strings — plaintext `processor:digest:inputs>outputs`. `remove_stale` (`src/object_store/management.rs:110-145`) reconstructs each on-disk key from the sharded descriptor path, which is a 64-hex-char `descriptor_key` hash. No element of the valid set can ever equal a descriptor filename, so every descriptor is classified stale and deleted; the `trim()` that follows (`cache_cmd.rs:59-63`) then removes every now-unreferenced object. The read-only `cache stale` listing (`cache_cmd.rs:113-147`) has the same defect — it reports 100% of entries as stale. Fix: `valid_cache_keys` must compute `product.descriptor_key(&combined_input_checksum(ctx, &product.inputs)?)` (ctx is already a parameter). After that, `Product::cache_key()` (`graph.rs:162-178`) has zero non-test callers and must be deleted — it is the last surviving second key namespace that `cache_key.rs` was created to kill; retarget the `graph.rs:809-833` tests at `descriptor_key`.

- [x] **V2. Watch mode silently ignores cache config.** *(Fixed 2026-08-03: `apply_config_to_context` folded into `Builder::new`/`new_with_overrides` (now private) — the constructors take `&BuildContext`, so no path can construct a Builder without the config bridge.)* `Builder::apply_config_to_context` (`src/builder/mod.rs:192-197`, pushes `mtime_check` and `webcache_ttl_secs` into `BuildContext`) is called on every build path in `main.rs` (`:237,242,342,414,474`) and in `cache_cmd.rs:38` — but never in `watcher.rs`. Under `rsconstruct watch`, `cache.mtime_check = false` does nothing. This is the predictable failure of a "remember to call this after constructing Builder" doc-comment contract; the real fix is folding the call into `Builder::new` so no path can miss it.

- [x] **V3. `markdownlint` declares `can_fix: true` with no fix implementation.** *(Resolved 2026-08-04 by decision: fix capability is unused, so ALL nine `can_fix: true` flags were set to false — `rsconstruct fix` now selects only `script` processors with a configured `fix_command`. The bash completion fixer list is now generated from the registry at emission time (closes S33's drift permanently). The `can_fix ⟺ fix path` invariant test is moot while the flag is false everywhere; the sibling `supports_batch ⟺ execute_batch` invariant test remains open — tracked here.)* `checkers/markdownlint.rs:69` sets the flag, but the hand-rolled `Processor` impl has no `fix()` override, so `rsconstruct fix` selects it via `is_fixable` and hits the trait default `bail!("fix not implemented")` — one error per markdown file. Same class as the already-fixed black bug (old B6). Fix the flag or add the override, **and add the missing invariant test** (`can_fix` ⟺ a working fix path) in the style of the existing `is_native`-agreement test (`processors/mod.rs:1310`). Add the sibling test for `supports_batch` ⟺ an `execute_batch` override while there (currently three facts must agree — plugin flag, config `batch`, actual override — and nothing checks the third).

- [x] **V4. `--retry` silently does not apply to batched processors.** *(Fixed 2026-08-04: batch path retries the failing subset per attempt with FLAKY reporting, matching non-batch semantics; the batch restore path now emits `product_complete` fail events too.)* `max_attempts = 1 + self.retry` exists only in the non-batch path (`executor/execution.rs:552`); the batch path calls `execute_batch` exactly once. Batching is on by default for batch-capable processors, so the documented flag is a no-op for exactly the processors most likely to run, and a batched processor can never be reported flaky. Related *(scan)*: a restore failure inside a batch group emits no `product_complete{status:"failed"}` JSON event (`emit_fail_event=false` at `:383` vs `true` at `:505`) — a `--json` contract hole. Both fall out of the deeper issue that `process_batch_group` (`:368-484`) and `process_non_batch_chunk` (`:487-627`) are two hand-synchronized copies of the same lifecycle (see M5).

- [x] **V5. Two dead `expected_field_type` arms the consistency tests are provably blind to.** *(Fixed 2026-08-04: "checker"/"linter" arms deleted along with S33's `("a2x","a2x")`; new `every_expected_type_arm_field_is_declared_by_its_processor` extends the source-text-recovery technique to the field half, making the dead-arm check total.)* `config/mod.rs:1407-1409` still carries arms for `"checker"` and `"linter"` — fields no config struct declares (`grep` of `processor_configs.rs`: zero hits). These are the renames the 2026-07-18 audit itself listed as proven drift. The tests miss them because `every_expected_type_arm_is_a_live_field` enumerates candidate fields from `known_fields()`, so a field that exists nowhere is never probed, and `every_expected_type_arm_names_a_real_processor` checks only the processor half. Delete the arms, and extend the source-text-recovery technique of the latter test (`config/tests.rs:762-791`) to the field half so the dead-arm check becomes total. This is also the strongest remaining argument for the FieldSpec consolidation (H1).

- [x] **V6. Marp's defaults are encoded twice and disagree — the double-encoding mechanism regressed.** *(Fixed 2026-08-04: `MarpConfig::default()` carries the args, with a comment pinning it to `processor_defaults_for`. The generic agreement test still belongs to H1.)* `processor_defaults_for("marp")` (`config/mod.rs:716`) has `args: &["--html", "--allow-local-files"]`; `MarpConfig::default()` (`processor_configs.rs:303-316`) has no `args`. This is the identical mechanism that produced the marp `args` divergence the previous audit fixed once — the mechanism itself was never fixed, so it regressed. Cheap interim guard: one test asserting `processor_defaults_for(name)` agrees with `C::default()` for every plugin. Real fix is inside H1.

- [x] **V7. Dead executor shared state, paid for under a mutex per product.** *(Fixed 2026-08-04: `unchanged_products`, `failed_details`, and `FailedProduct` deleted.)* `SharedState.unchanged_products` (`executor/mod.rs:84`) is allocated (`execution.rs:261`) and inserted into (`handlers.rs:207`) on every byte-identical no-op product, and read nowhere. `SharedState.failed_details`/`FailedProduct` (`executor/mod.rs:82`, `processors/stats.rs:44`) is built (`handlers.rs:29-37`) "for `rsconstruct edit`" — a command that does not exist — and never read. Both are safe deletes.

- [~] **V8. `--quiet` is unenforced outside the executor.** *(Partial 2026-08-04: `tables.rs` routed through `output::info` (all ~39 table sites gain quiet+json compliance at once, per S30's warning that the scanner extension alone would miss them); `fix.rs`/`clean.rs`/`toml check`/`webcache clear`/`cache clear`/`status --breakdown` prose migrated, with JSON documents where a machine answer makes sense. Remaining: the rest of the ~260 `src/builder/` sites (notably `smart.rs`) and the scanner extension itself.)* *(scan; spot-checked plausible)* The `output.rs` sink and its `build_path_has_no_bare_println` test cover `src/executor/` and `src/object_store/`, but `src/builder/` command bodies contain ~260 raw `println!` sites gated only on `is_json_mode()` — a bare `println!` still prints under `--quiet` (e.g. `clean.rs:18,20,79-89`; `fix.rs:40-57,103-108`; all 21 sites in `cache_cmd.rs`). Fix is mechanical (migrate to `output::info`) and enforceable by extending the scanner (`output.rs:122`) to `src/builder/` with an allowlist for the intentional JSON-document prints.

- [x] **V9. `CacheKey::material()` is injectable through analyzer values.** *(Fixed 2026-08-04: `digest()` and `descriptor_key()` hash length-prefixed parts via the new `checksum::hash_parts` (material() deleted), with injection tests; `tool_identity` hashing and the analyzer piece join got the same treatment. Cache-breaking by design — one full rebuild.)* *(scan)* `material()` (`cache_key.rs:104-109`) joins components with `|` on a prose argument that values can't contain it — but `Component::Analyzer` values embed arbitrary file paths (e.g. `glob:{sorted paths}`, `analyzers/tera.rs:49`), which can contain `|`. The sibling `hash_checksums` (`checksum.rs:184-191`) length-prefixes for exactly this reason, with a test. Length-prefix `material()` too. Related: `processor_version` is spliced inline into `descriptor_key` (`cache_key.rs:141-146`) rather than being a named component — it never appears in `product show`'s attribution, and `unwrap_or(0)` collapses "unregistered" with "version 0".

- [x] **V10. Documentation contradicts observable behavior.** *(Fixed 2026-08-04: checksum-cache.md's cache-clear and joining claims corrected; all 9 processor doc pages' `*_bin` fields renamed to `command`.)* *(scan)* `docs/src/internal/checksum-cache.md:64` says `cache clear` preserves the mtime database; `docs/src/internal/cache.md:219` says the opposite. The code deletes the whole `.rsconstruct/` directory (`cache_cmd.rs:20-23`) — `cache.md` is right, `checksum-cache.md` is wrong. Also stale: `checksum-cache.md:73` still describes `:`-joining (the code length-prefixes), and 8 processor docs pages document renamed `*_bin` fields that no longer exist (`drawio.md`, `markdown2html.md`, `marp.md`, `libreoffice.md`, `protobuf.md`, `mermaid.md`, `sass.md`, `chromium.md`). Nothing links docs pages to `field_descriptions()` — see H1's docs bullet.

## High severity (structural)

- [ ] **H1. The plugin schema split — carried forward as #1, and wider than previously stated.** Registering a processor touches 7 mandatory code sites, and three of them are name-keyed `match` tables in `src/config/mod.rs` (`scan_defaults_for` ~80 arms at `:575`, `processor_defaults_for` ~80 arms at `:701`, `expected_field_type` ~90 arms at `:1295`). The plugin entry (`registries/processor.rs:19-64`) already carries `known_fields`/`checksum_fields`/`field_descriptions` as fn pointers — those three tables are the same kind of data, left behind. The planned data/constructor split of the plugin entry (which breaks the `processors → config → registries` cycle) should absorb them: that collapses three registration touch-points into the plugin file itself, and the cycle fix pays for itself in add-a-processor cost, not just layering hygiene. The same refactor should absorb:
  - **The defaults double-encoding** (V6's mechanism): every default is written twice, in two representations (serde default fn + hand-written `Default` impl; `Default` impl + `processor_defaults_for` arm), with nothing checking agreement.
  - **The checksum-membership inversion** (from the old audit, still not done, independent of the rest): `checksum_fields()` completeness is the one schema property no test can check, and its failure mode is silently stale outputs. Flip to in-by-default, opt-out with a stated reason.
  - **`known_fields()` re-listing**: ~20 custom-config plugins each re-type the 13 inherited `StandardConfig` field names by hand (`processor_configs.rs:318-325` etc.); a new standard field silently fails to become valid for all of them. Derive by union.
  - **Docs generation or verification**: generate the config table in each `docs/src/processors/*.md` from the field metadata, or at minimum test that every documented key is a known field (V10's `*_bin` drift is the proof of need).
  - Cheap unbundled wins *(scan)*: `registries::apply_all_defaults` (`registries/processor.rs:118-125`) is a pass-through that exists only so `config` can call back into itself via `registries` — delete the edge; `output_config_hash` (`config/mod.rs:176-196`) is cache-key material living in `config`, imported by ~14 processor files — move it to `cache_key.rs`.

- [ ] **H2. Lua plugins are second-class citizens, including a real cache-correctness hole.** Everything a native processor gets from the link-time `ProcessorPlugin` static, a Lua plugin silently lacks: **no `version`, so editing a Lua plugin script does not invalidate its cached results** (`registries/processor.rs:99-103`); no batching (`effective_supports_batch` finds no plugin → always false, `executor/execution.rs:40`); no `max_jobs_cap` (uncapped); no fix; no field validation (raw TOML passthrough); no description/defconfig. There is also a documented Lua `processor_type()` hook that no Rust code reads (`processors/mod.rs:851-854`). Root cause is singular: capabilities live on the link-time registry entry, not the trait. Fix: build a synthetic `ProcessorPlugin` at Lua discovery time — `version` from a content hash of the script (closes the cache hole), batch/fix/cap/description from optional Lua globals. Delete or implement the phantom `processor_type()` hook.

- [ ] **H3. Analyzer configs are a parallel, weaker copy of the processor config path.** No type validation at all (`config/mod.rs:1675-1690` checks only key names), no `must_fields`, no `max_jobs` guard, and multi-instance detection still uses the bare `all(is_table)` heuristic (`:1656`) — the exact silent-reinterpretation bug class that three-way `SectionShape` was built to eliminate on the processor side (an analyzer instance named like a field silently reparses the section). `AnalyzerConfig` (`:1185-1276`) is a near-copy of `ProcessorConfig` (`:858-1134`). Whatever the processor path earns, the analyzer path should share, not re-implement at a lower standard.

- [ ] **H4. Marker/Blob/Tree normalization — promoted from deferred-refactor to correctness.** Two reasons the deferral was wrong:
  - A Blob descriptor content-verifies only `output[0]`; outputs `[1..]` are existence-checked forever ("their checksums aren't recorded", `restore.rs:97-98`), and `explain_descriptor` must replicate the tier with an `if i == 0` (`restore.rs:156-176`). A generator's second output is uncached and unverified for the life of the cache.
  - The descriptor kind is chosen by product *shape* at write time (`handlers.rs:189-203`) but by serialized *tag* at read time, held together by "a shape change usually changes the config hash" — usually, not by construction. `restore.rs:22` silently reports success for a Blob descriptor read by a product that now has zero outputs.
  The unified `Vec<TreeEntry>` model (Marker = empty, Blob = single entry) deletes both, plus the 21-arm match sprawl across 7 functions. The enum is fully encapsulated (zero uses of `CacheDescriptor::` outside `object_store/`), so the change is internal to two files.

## Medium severity

- [ ] **M1. `Builder` is not a god object — it is twelve commands wearing one struct.** It holds only three fields (`config`, `file_index`, `object_store`; `builder/mod.rs:181-185`) and is the receiver for ~33 entry points across 10 impl files, each with the same body shape: `create_processors()` (16 call sites) → `build_graph*` (23 call sites) → 50–200 lines of printing. Nothing needs `Builder` as an object; commands need the three-field bundle. Fix: a `Project { config, file_index, object_store }` value plus free-function command modules — mechanical, no behavior change, dissolves the god object, and finally makes command bodies unit-testable (today `src/builder/` has essentially no unit tests because everything needs a constructed `Builder`). Cheap adjacent deletes *(scan)*: `is_processor_active` (`mod.rs:532`) is an always-true predicate with two ignored parameters (its removal also kills the dead `include_all` parameter of `build_graph_filtered`); `filter_checksum_fields` (`mod.rs:321-340`) touches no `self`.

- [ ] **M2. Reporter interface — carried forward, smaller than feared, and the payoff is not daemon mode.** The execute half has only ~7 gated print sites left to abstract (`build.rs:301-385`). The real payoff: `watcher.rs`, `fix.rs`, and `clean.rs` are each hand-rolled mini build-drivers — watch prints errors to stdout in red (`watcher.rs:81,145`) unlike everything else; `fix` re-implements group-by-processor batch-vs-single dispatch (`fix.rs:72-100`) with its own accounting, no interrupt checking, and no JSON events (`rsconstruct fix --json` emits nothing machine-readable); `clean` constructs a full `Executor` with six dummy option fields to call a serial for-loop. A `BuildReporter` trait threaded through `build()` and the `Executor` (replacing its `verbose` bool, which is already force-disabled under json at `executor/mod.rs:212` — that *is* a reporter decision made once) is what collapses the three onto the main pipeline.

- [ ] **M3. `main.rs` and `builder/*` share a split-brain dispatch contract.** `main.rs:376-402` handles 9 of 15 `ProcessorAction` variants before constructing a `Builder`; `builder/processors.rs:337-339` re-lists those same 9 names in `unreachable!` arms. Same for 6 of 12 `AnalyzersAction` (`main.rs:309-326` vs `analyzers.rs:175-176`). Two name lists duplicated across a module boundary with nothing enforcing agreement — the audit's own opening pattern. Fix: `fn needs_config(&self) -> bool` on the action enums (one place, `cli.rs`); the `unreachable!` arms delete. Related: "is there a project here?" is answered in three places (`main.rs:374`, `:575`, `Config::require_config`).

- [ ] **M4. Processor trait accretion.** The four fix methods (`config_has_fix`/`fix`/`supports_fix_batch`/`fix_batch`) exist for one hand-rolled processor (`script.rs`) plus `SimpleChecker` — four trait slots for one non-generic user. A `Fixer` capability trait reached via `as_fixer() -> Option<&dyn Fixer>` turns "can this fix?" from a flag question into a type question, permanently closing the V3 bug class. `tool_version_commands` has zero real overrides (the sole "override", `lua_processor.rs:535-540`, duplicates the default body verbatim) — delete it. `ProcessorBase` (`mod.rs:885-899`) is three static methods wrapping already-public free functions — delete. `SimpleChecker` is still monomorphic over its config while `SimpleGenerator<C>` is generic — this is the direct cause of 20 hand-rolled checker impls, and `SimpleChecker` also lacks the `config_json` override `SimpleGenerator` has, so extra checker config fields never reach config-change detection. Also: `ijq`/`ijsonlint`/`itaplo`/`iyamllint` are four 80-line files differing in 4 lines each — a `SimpleInternalChecker` deletes ~240 lines and 4 identical trait impls.

- [ ] **M5. The batch/non-batch executor split is two hand-synchronized lifecycles, and the batch seam is the weakest contract in the executor.** Beyond V4: `execute_batch`'s "one result per product" invariant is enforced by a release-mode `assert_eq!` in the hot path (`execution.rs:443`); the three batch helpers (`execute_checker_batch_per_file`/`execute_checker_batch`/`execute_generator_batch`, `processors/mod.rs:751-804`) differ in failure semantics (per-file vs one-exit-code-fanned-out) distinguished only by comments — choosing wrong changes `--keep-going` correctness; and `set_declared_tools` does not wrap `fix`/`fix_batch` (`builder/fix.rs:78,89`), so the undeclared-tool debug assertion skips the fix path. Fix direction: extract the shared lifecycle (announce → run-with-retry → record-timing → finish) so the two paths differ only in the execute call — this is also what unblocks unit-testing the retry loop, whose `last_error` threading carries a "Safety:" fallback (`execution.rs:613-617`) admitting the state machine isn't obviously total.

- [ ] **M6. Three "processors" are embedded subsystems.** `tags.rs` (1,960 lines) is ~20% processor and ~80% a tag database + hand-rolled YAML-subset parser + Levenshtein engine + 17 CLI command functions reached from `main.rs` — two of which (`check_tags`, `suggest_tags`) bypass the trait entirely, and one of which rebuilds the whole `FileIndex` from disk inside a build (`tags.rs:1178`). `terms.rs` (822) and `tera.rs` (740, embedding a 9-function template-function library) are the same shape smaller. Extract `src/tags/`, `src/terms/`, `src/tera_functions/` with thin processor shims.

- [ ] **M7. The persistence layer has no owner.** `.rsconstruct/` is written by six independent mechanisms with hardcoded path literals in five files, no shared constant, no schema-version marker (one table is versioned by name — `webcache_v2` — the rest not). Corruption of the same descriptor gets four different answers: `cache trim` hard-fails, `cache list` silently omits, `cache stats` silently undercounts, a build silently rebuilds — one `read_descriptor -> Result<_, DescriptorError>` with an explicit `Corrupt` variant and per-caller documented policy. `db.redb` is opened eagerly in `ObjectStore::new` (`mod.rs:186`), so `rsconstruct cache size` in one terminal blocks a build in another, for a table only config-diffing uses — open it lazily (this also removes the reason `new_at` needs its test-only `db_name` parameter). And **decide the single-vs-multi-process question as *single* and document it**: the blob layer's pid-tagged temp-file discipline is dead effort while the redb exclusive locks already make two concurrent processes impossible. Sharding arithmetic (`split_at(CHECKSUM_PREFIX_LEN)` / `format!("{prefix}{rest}")`) is open-coded at ~9 sites — one `shard`/`unshard` pair. Smaller *(scan)*: `object_store/checksums.rs` is a 1-line tombstone still `mod`-declared; `stats_by_processor` buckets everything under the literal `"all"` so `cache stats`'s per-processor table always has one row; `deps.redb` has no prune-for-deleted-sources (mtime got one); `word_manager.rs` `flush()` read-modify-writes the user's committed words file with no temp+rename; `tool_lock` writes `.tmp-{pid}` files into the repo root with no cleanup filter.

## Low severity

- [ ] **L1. `resolve_dependencies` is non-idempotent** — it pushes edges without clearing (`graph.rs:458-459`); calling it twice silently doubles every edge, doubles `in_degree`, and yields a bogus "Cycle detected". Currently safe only because every caller happens to start from a cleared graph. Make it clear-then-build, or rename to state the contract.

- [ ] **L2. `graph_unreferenced` walks the filesystem directly** with hand-rolled recursion hardcoding `target`/dot-dir skips (`builder/graph.rs:144,320`) — bypassing `FileIndex` and `.rsconstructignore`; the same contract violation the previous audit fixed for pdfunite/ipdfunite, at a different site.

- [ ] **L3. `FileIndex::query` is a linear scan with up to 3 `to_string_lossy` allocations per file per call** (`file_index.rs:96-160`), invoked per-pass × per-processor × per-`src_dirs` entry — `O(passes × processors × src_dirs × files)` with allocations. The vec is already sorted, so the `root` filter is one `partition_point` away from `O(log n + matches)`; hoisting the lossy conversion and precomputing an extension index are the other two wins. Not urgent; it is the dominant cost term for large projects.

- [ ] **L4. Small cleanups.** `graph.rs:663,678` still spell `crate::cli::DisplayOptions` (the type moved to `crate::display`; works via re-export, but falsifies the "no core module imports crate::cli" claim). `inject_bash_processor_completions` is ~170 lines of codegen post-processing sharing `cli.rs` with the arg structs — move to `src/completions.rs`. ~180 lines of command bodies still inline in `main.rs::run()` (`Version`, `WebCache`, `Tags`, `Functions` arms). `detect_config_changes` performs a redb *write* from inside `plan_build`, so `--stop-after=discover` mutates the object store. `--stop-after=resolve` runs more than its name implies and the `GraphSnapshot` enum's "type-checker keeps call sites in sync" doc-claim is false. Interrupt-vs-error exit-code precedence is implemented twice by ordering at two layers (`build.rs:370-378` vs `collect_build_stats`) with no shared statement of the policy; and `classify_error`'s io-chain rule likely misclassifies a mid-build `Command::spawn` failure as exit 5 rather than a build/tool error — worth one deliberate test.

## Carried forward from the 2026-07-18 audit, unchanged

- **Path-incremental watch mode** — still open, still blocked on discovery producing a partial graph (a new design, not a parameter). One piece got cheaper and is worth splitting out: `watch` discards not just `event.paths` but the whole `Builder` per event (`watcher.rs:137`), re-paying `Config::load` + `ObjectStore::new` + `FileIndex::build`. Comparing `event.paths` against the two known config paths and reusing the `Builder` when config didn't change is a real latency win requiring no discovery redesign.
- **Ready-queue scheduler** — profiled 2026-08-02 and rejected; reopen only with a trace from a genuinely deep graph (long producer→consumer chains) showing measured idle slots.
- **FieldSpec consolidation** — folded into H1; V5 shows its safety argument was not, in fact, fully covered by the enforcement tests.

## Suggested order of attack

1. **V1** (data loss, small) — then the rest of V2–V10; each is under an hour and V3's fix includes the two new invariant tests that prevent recurrence.
2. **V8** — quiet enforcement via the scanner extension; mechanical, makes an advertised flag real.
3. **H1** — the plugin data/constructor split absorbing the three config match-tables, the defaults double-encoding, and the checksum-default inversion. The one refactor that reduces every future processor's cost; prerequisite for a workspace split.
4. **H4** — Marker/Blob/Tree normalization (promoted to correctness).
5. **M1 → M2** — `Project` + free-function commands, then the reporter trait, then collapse watch/fix/clean onto the main pipeline (M2's list).
6. **H2** — Lua synthetic plugin entry (closes the script-edit cache hole).
7. **H3, M4–M7** as bounded independent pieces; L-items opportunistically.

---

# 2026-08-03 follow-up scan

Five parallel subsystem scans (processors+tools, executor+builder+watcher+graph, config+registries+cli, object_store+cache_key+checksum+remote, crate root+plumbing+tests), each briefed to exclude everything above. Items marked **(verified)** were re-confirmed by hand against source during consolidation; items marked **(reproduced)** were demonstrated by running the built binary; the rest carry file:line evidence from the scans — re-confirm when fixing.

## Critical

- [ ] **S1. `rsconstruct watch` self-triggers an infinite rebuild loop.** *(reproduced: 69 rebuild cycles in ~8 s with zero external edits)* The event loop (`watcher.rs:97-104`) dispatches on `event.paths` only and never inspects `event.kind`; notify's inotify backend delivers `Access(Open)` events, so the build's own reads of `rsconstruct.toml` and watched sources re-trigger the loop. `should_ignore` cannot help — the offending paths are the legitimately watched ones. Fix: discard `EventKind::Access(_)` (keep Modify/Create/Remove) before the ignore check.

- [ ] **S2. Ctrl+C does not stop `watch`; it exits 0 or hangs.** *(reproduced: SIGINT'd watch alive >30 s)* Exit 130 is constructed at exactly one site (`builder/build.rs:373`, the build path). Inside watch, the interrupt surfaces as a build error caught at `watcher.rs:144` ("Build error: …") and the loop continues; the `is_interrupted()` check at `:94` is only reached between events, which S1 guarantees never happens. An interrupted watch that does exit prints `EXIT_SUCCESS (0)` (`main.rs:227-229` lists Watch in `show_status`).

- [x] **S3. `processor_version()` doesn't strip the instance suffix — every multi-instance processor is cache-keyed at v0.** *(verified; fixed 2026-08-03: routed through `find_plugin` like every sibling accessor, plus invariant test `accessors_resolve_instance_names_like_type_names` covering all registry accessors over instance names — verified to fail against the old lookup. Note: for existing multi-instance projects this corrects their descriptor keys from v0 to the true version, forcing a one-time rebuild.)* `registries/processor.rs:105-107` does `all_plugins().find(|p| p.name == name)` on what is an *instance* name (`Product.processor` carries `instance_name`), while every sibling accessor routes through `find_plugin`, which strips `.suffix` (`:72-77`). For `[processor.pylint.core]`, lookup misses, `unwrap_or(0)` in `cache_key.rs:141` silently yields `v0`, and a version bump never invalidates that instance's cached results. Fix is one line (`find_plugin(name).map(|p| p.version)`) plus an invariant test over all plugins with a `.inst` suffix.

- [x] **S4. The in-session checksum cache is never invalidated, defeating the executor's post-execution recompute when mtime checking is off.** *(Fixed 2026-08-03: new `checksum::forget_in_session` evicts paths from `ctx.checksum_cache`; the executor calls it for a product's outputs on success (before the post-execution recompute), on restore, and on failure (partial writes). The pinning test became `file_checksum_cache_evicts_on_forget`; integration test `no_mtime_cache_chain_converges` (tera → imarkdown2html chain under `--no-mtime-cache`) verified to fail without the success-path eviction. Known residual: files written inside `output_dirs` (tree products) are evicted only if declared in `outputs` — undeclared tree contents consumed as later inputs have no ordering guarantee anyway, see S7.)* *(verified: `ctx.checksum_cache` has three insert sites in `checksum.rs` and no remove/clear anywhere)* With `--no-mtime-cache` (or `cache.mtime_check = false` — now effective in watch after the V2 fix), `combined_input_checksum` → `file_checksum` returns the classify-time value from `ctx.checksum_cache`, so the "recompute NOW (post-execution)" at `handlers.rs:174` and `execution.rs:738` silently returns stale checksums for any input rewritten mid-build by an upstream product. Descriptors get keyed by pre-build content; the next classify computes real content → permanent cache miss, non-convergent rebuilds. The test `file_checksum_caches_per_context` (`checksum.rs:379-395`) pins the current behavior and must be retargeted.

## High

- [ ] **S5. `remove_stale_outputs` looks up the previous tree under the classify-time key; `handle_success` stored it under the post-execution key.** *(verified)* `execution.rs:111` passes the classify-time `input_checksum` to `previous_tree_paths`; `handlers.rs:188` stores under `post_input_checksum`. Whenever a creator/explicit product's inputs include an upstream output (the exact case the comments at `handlers.rs:161-173` describe), the keys differ, the lookup returns empty, stale files survive, and the directory walk in `store_tree_descriptor` re-captures them into the new tree — permanently cached and restored thereafter.

- [ ] **S6. `remove-stale`/`cache stale` still mis-key chained products when inputs are absent (residual of V1).** `valid_cache_keys` (`builder/mod.rs`) hashes inputs from *current* on-disk state, but descriptors are keyed by *post-execution* state. Right after a successful build the two agree; run `cache remove-stale` after `rsconstruct clean` (or mid-failed-build) and inputs that are upstream outputs hash as `MISSING:` → keys differ → chained products' descriptors classified stale and deleted, exactly when restore-from-cache matters most. Real fix likely requires keying valid-set computation off descriptors' own recorded state rather than rehashing inputs.

- [ ] **S7. Two products sharing an `output_dirs` entry corrupt each other's tree descriptors under `-j`.** `is_foreign` (`handlers.rs:199-202`) excludes only paths *declared* as another product's `outputs`; creators declare `output_dirs`, not files, so `path_owner` returns None for everything both write. No shared output path → no dependency edge → same parallel level. Thread A's post-build walk snapshots thread B's half-written files into A's tree; `remove_stale_outputs` symmetrically deletes B's files mid-run. The per-processor `max_jobs` semaphore can't express "these two instances share a directory".

- [ ] **S8. Per-processor `max_jobs` is not honored on the batch path.** `execution.rs:267-271` builds semaphores; only `process_non_batch_chunk` acquires them (`:510-513`); `process_batch_group` never sees the parameter. Batching is on by default, so `max_jobs = 1` on a batch-capable processor is a silent no-op — same shape as V4's `--retry` hole.

- [x] **S9. `FileIndex` indexes `.rsconstruct/` and the output directory as project source.** *(verified walker config; reproduced: `ijsonlint` failed the build on a planted `.rsconstruct/bad.json` and on `out/gen.json`)* `file_index.rs:37-54` uses `.hidden(false)` and excludes nothing of the tool's own; only the user's `.gitignore` accidentally saves most projects, and generated outputs make builds non-idempotent for root-scanning checkers. `watcher.rs:13-22` already encodes "skip `.rsconstruct` and output_dir" — the knowledge exists twice and disagrees (the review's central pattern). *(Fixed 2026-08-03 in two steps. `.rsconstruct/`: both walkers `filter_entry` out the `STATE_DIR` component; regression test `state_dir_is_never_indexed` verified to fail pre-fix. Output roots: `Config::file_index_walk_dirs()` computes the exclusion set from config (global `[build] output_dir` + per-instance `output_dir`/`output`/`output_dirs`, all post-rebase) plus the force-walk set (`src_dirs` entries under an excluded root — the explicit opt-in, generalized from the terms-only `force_dirs` mechanism); `Builder` and `sloc` pass both to `FileIndex::build_with_force_dirs`. Tests `output_roots_are_not_indexed` (renamed global root + per-processor root) and `src_dirs_under_output_root_are_scanned` (opt-in works, sibling dirs stay excluded). Residual gaps, deliberate: `output_dir = ""` (in-place output at the project root) cannot be excluded; the standalone `tags` commands still build an unexcluded index (M6 should make them reuse the Builder's).)*

- [ ] **S10. Symlinked source files are silently invisible to the entire build.** *(verified; reproduced)* `file_index.rs:46` keeps only `is_file()`; the walker never sets `follow_links`, so symlink entries are dropped with no warning — a direct "strict by default" violation (silent skip).

- [ ] **S11. Every documented Lua `stub_path` example is broken: docs pass 3 args, the binding takes 2.** *(verified)* `lua_processor.rs:142-151` is `(source, suffix)`; all four `docs/src/plugins.md` sites pass `(project_root, file, suffix)`. mlua binds positionally: `source=project_root`, `suffix=<filename>`, third arg discarded — a copy-pasted plugin declares garbage output paths that all collide.

- [ ] **S12. 21 hand-rolled processors don't override `config_json()`, so their extra config fields never reach config-change detection.** Trait default (`processors/mod.rs:1020-1022`) serializes `scan_config()` only. 12 of the 21 have non-standard fields (marp timeout/attempts, pdflatex runs/qpdf/shell_escape, aspell words fields, gem, mdl, terms, …). `SimpleGenerator` overrides it and `SimpleChecker` doesn't — the two generic runtimes already disagree (extends M4).

- [x] **S13. `cc.rs::required_tools()` reads config-level `cc`/`cxx` that no execution path uses; `cflags`/`ldflags`/`include_dirs` config fields are fully dead.** *(verified: `cc.rs:295` is the only reader)* Execution reads the per-directory `cc.yaml` manifest. With `cc.yaml: cc: clang`, `tools check` verifies `gcc` and passes on a clang-less machine — and in debug builds the declared-tools assertion panics mid-build on the undeclared `clang`. *(Fixed 2026-08-04 with config-as-defaults semantics: `CcManifest::parse` resolves a raw Option-field shape against `CcConfig`, so unset manifest fields inherit the config values (which the docs already claimed) and the six config fields are all live; discovery records each manifest's resolved compilers into the processor so `required_tools()` names the real compilers by execution time. Tests: `cc_config_flags_are_manifest_defaults`, `cc_manifest_overrides_config_flags`, `cc_manifest_compiler_is_declared` — the last verified to reproduce the exact declared-tools panic without the fix.)*

- [ ] **S14. `simple_generator_is_native_declarations_agree` silently skips `pandoc`.** The test's hardcoded candidate list (`processors/mod.rs:1301-1314`) names 12 SimpleGenerators; there are 13. The list guarding two hand-synced copies is itself a third unenforced copy. Derive it instead of extending it.

- [ ] **S15. Nine commands emit human prose on stdout under `--json`; `status --breakdown --json` appends a table after the JSON document.** *(reproduced)* Sites: `clean.rs:18,20,79-89,98`, `main.rs:535,608`, `smart.rs` (all arms), `fix.rs:40-57,103-108,125-128` (JSON branch exists but the empty-case early-return bypasses it), `cache_cmd.rs` (Clear), `builder/build.rs:492-506`. A `jq` consumer works until the day the list is empty.

- [ ] **S16. `#[cfg]` rule violations.** `processors/mod.rs` carries three `#[cfg(not(debug_assertions))]` stubs (`:88-89,100-103,121-124`) — a build-profile fork with the rule's exact failure mode (release variants never compiled by `cargo test`; the declared-tools check doesn't exist in shipped binaries), plus `#[cfg(unix)]` on two tests (`:1420,1443`) and ~20 more across `tests/` (dead on a unix-only project; `tests/common/mod.rs:85` has a `#[cfg(not(unix))]` no-op `make_executable`). `registries/processor.rs:162` has another `debug_assertions` gate. Decide whether the rule covers profile forks; either way the test-file `cfg(unix)` gates are pure noise.

## Medium

- [ ] **S17. `FileBackend::upload_bytes` overrides the atomic trait default with bare `fs::write` at the final key.** *(verified)* `remote_cache.rs:261-277` vs the temp+rename `upload()` at `:225-247` — and both object-store call sites use `upload_bytes`, so the atomic path is dead code. Consequences: torn descriptors observable on shared mounts (descriptors get no content check on fetch, only a parse), and `fs::write` over the 0o444 file from a previous push fails EACCES — the descriptor push error propagates, so the *second* build against a `file://` remote reports failure.

- [ ] **S18. `HttpBackend` converts every transport error into a cache miss.** `remote_cache.rs:154-163,176-186` — DNS failure, 500, expired auth all return `Ok(None)`/`false`, making the deliberate warn-on-bad-bucket design in `operations.rs:58-64` unreachable for HTTP. A downed team cache silently degrades every machine to full rebuilds with zero diagnostics.

- [ ] **S19. Tree restore has no negative-space reconciliation.** `restore.rs:47-69` restores listed entries and never removes unlisted files in `output_dirs`; `needs_rebuild_descriptor` likewise only checks recorded entries. A shrunk output set leaves the removed file on disk, indistinguishable from a real output, forever verified as "fine" (distinct from H4, which is about Blob's outputs[1..]).

- [ ] **S20. Tree entry paths are unnormalized and unvalidated — a remote-pulled descriptor is an arbitrary-path write primitive.** `descriptors.rs:181` stores `display()` strings of unnormalized walk paths; `restore.rs:49,61-63` restores with bare `Path::new(&entry.path)` + `create_dir_all`. Nothing checks entries stay under the project root, and a fetched descriptor is only parse-validated (`operations.rs:134-148`).

- [ ] **S21. Watch-mode robustness cluster (beyond S1/S2).** Per-processor `output_dir`/`output_dirs` outside the global one aren't in `should_ignore` (`watcher.rs:20`) — build output inside watched trees retriggers builds; rename-based saves of `rsconstruct.toml` (vim, VS Code atomic save) kill the inotify watch on the old inode and config edits silently stop triggering (`:76,149-153`); debounce 200 ms / poll 500 ms are hardcoded (`:87-88`), violating the config-knob rule; `notify::recommended_watcher(tx)?` at `:75` is a bare `?` on an OS resource that fails with EMFILE.

- [ ] **S22. Zero-value config holes.** `[build] max_discovery_passes = 0` → discovery loop `0..0` never runs, zero products, build exits 0 "successfully" (`builder/mod.rs:549-551`); `max_arg_len = 0` degenerates every batch to one file per process spawn; `pdflatex.runs = 0` → with default `qpdf = true` a confusing qpdf error, with `qpdf = false` a green build that produced nothing yet gets cached; `marp.timeout_secs = 0` → instant timeout ×3 retries (its sibling `max_attempts` on the next line has `.max(1)`; `timeout_secs` doesn't). One `validate_build_config` in the shape of the existing `max_jobs != 0` guard covers all four.

- [ ] **S23. `pdflatex` deletes temp files by stem in a shared build dir.** Two same-stem `.tex` files from different `src_dirs` roots map to the same `output_dir` parent; parallel runs share `-output-directory` and `clean_temp_files(stem, dir)` (`pdflatex.rs:28-33,79,85`) deletes each other's `.aux` mid-multi-pass → silently broken cross-references, cached. Also `let _ = fs::remove_file` swallows non-NotFound errors. marp solved this class with a per-invocation namespace (`marp.rs:34-46`).

- [ ] **S24. `tera` re-globs the whole project per render, bypassing FileIndex and `.rsconstructignore`.** `tera.rs:65-78` runs `glob("**/*.tera")` + reads every match for every product — an unparseable `.tera` in `node_modules/` fails every tera product in the project; P products = P full walks (hotter instance of L2).

- [ ] **S25. Tera dep-tracking holes.** `workflow_names()` reads `.github/workflows/*.yml` contents that enter no input list and no config hash — renaming a workflow's `name:` leaves stale rendered output cached indefinitely (`tera.rs:299-339`; `TERA_FUNCTIONS[5].dep_tracking` documents the hole in prose). `copyright_years` swallows all git failures into "current year" (`:248-260`) — a shallow CI clone renders different output than a dev machine, cached, no warning. The 9-entry `TERA_FUNCTIONS` doc table and the 9 `register_function` calls are hand-synced with no test relating them.

- [ ] **S26. Spell-checker input holes.** zspell reads `{dict_dir}/{lang}.aff/.dic` that are inputs of nothing (`zspell.rs:58-75` vs `:182-198`) — a system dictionary update leaves stale PASSes cached. aspell/zspell snapshot the words file in `new()`; with `auto_add_words` and two instances (or watch iterations), the stale snapshot re-appends duplicate words (`aspell.rs:18-29`, `zspell.rs:23-41`).

- [ ] **S27. `explicit` resolves `input_globs` as the union of raw `glob::glob` and FileIndex matches.** `explicit.rs:42-65` — the raw half ignores `.rsconstructignore`/`.gitignore`, so ignored trees (node_modules) silently become product inputs, checksummed every build.

- [ ] **S28. Interrupt/exit-code plumbing.** Exit 130 is only reachable from `build`; `processors/mod.rs:188,240,279,294` raise interruption as `anyhow::bail!("Interrupted")` strings which classify as exit 1 — Ctrl+C during `fix` reports "1 errors". The Ctrl+C handler thread races startup with no readiness synchronization (`main.rs:209-224`), and the double-Ctrl+C `process::exit(130)` skips every `Drop` (redb handles) — a routine path to mid-transaction DBs. `Executor::clean` never checks `is_interrupted`.

- [ ] **S29. `errors::ctx()` stringifies the source error, destroying the chain.** `errors.rs:10-13` formats the error into a new `anyhow!` message, so `classify_error`'s `downcast_ref::<io::Error>` never matches anything routed through `ctx()` — exit code 5 is largely unreachable (this, not `classify_error`, is the mechanism behind L4's misclassification note). `.with_context()` preserves the chain; `ctx()` silently defeats it while CLAUDE.md presents it as the sanctioned pattern.

- [ ] **S30. `--quiet`/output-sink gaps beyond V8's list.** `tables.rs:30,46` are bare `println!` behind 39 call sites — V8's planned scanner extension over `src/builder/` will report clean while these remain; route `tables.rs` through `output::info` *with* the V8 fix. `builder/fix.rs:102-107` prints the failure summary to stdout and details to stderr (inverted). `analyzers/mod.rs:346` has a raw `eprintln!` beside a sibling that correctly uses `output::warn`; `:351` also `|`-joins hash pieces — V9's injection at a second site.

- [ ] **S31. `detect_config_changes` skips its DB write under `--json`/`--quiet`** (`builder/mod.rs:284-289` returns before the loop), so a CI running `--json` leaves the config baseline stale for the next human build. Suppress the printing, not the state update.

- [ ] **S32. Store hygiene cluster.** `store_object`'s `has_object` fast path is format-blind — toggling `compression` on is a silent no-op for existing plain objects and the store double-counts dual-format content (`blobs.rs:57-61,112-114`); descriptor read-only chmod swallowed with `let _ =` while the identical blob op is checked (`descriptors.rs:40` vs `blobs.rs:91`); `walk_files`'s `.tmp-` filter hides crash-orphaned temp files from `trim` forever, contradicting trim's own comment (`object_store/mod.rs:33-39`); descriptors are rewritten+re-pushed on every no-op build though `changed` is already computed (`descriptors.rs:20-52,105-118`); `mtime.redb` is created+exclusively locked by read-only commands (`checksum.rs:32-41` — `webcache.rs:68-70` shows the `db_exists()` guard pattern); `checksum_output`/`checksum_fast` are near-identical copies whose doc claim ("never uses the in-session cache") is false — it *populates* it (`checksum.rs:251-278`); `descriptor_key` and `tool_identity` concatenate user-controlled names/paths unprefixed while `hash_checksums` is length-prefixed with a test (`cache_key.rs:137-150`, `tool_lock.rs:178-188`); `tool_identity` silently drops unreadable tools from the key (`tool_lock.rs:248-264`).

- [ ] **S33. Config/CLI seams.** `--iset`/`--pset` can't reach Lua plugin sections (they live in `extra`, not `instances`; `config/mod.rs:1962-1976`) and the error claims the instance doesn't exist; `validate_override_field` treats unknown types as "all fields invalid" while `validate_single_processor` skips them — two functions, same question, opposite answers (`:2002-2018` vs `:1485-1488`). ~~The bash completion `fixers` list (`cli.rs:987`) duplicates the registry's `can_fix` set with no agreement test~~ *(fixed 2026-08-04 with V3: the alternation is generated from `all_plugins().filter(can_fix)` at emission time).* A third dead `expected_field_type` arm — `("a2x","a2x")` at `config/mod.rs:1436` — that V5's deletion list misses (blind for the same structural reason). `cc_single_file` spawns `sh` without declaring it while suppressing the tool check (`cc_single_file.rs:266-283`; `tera.rs:127-128` does it right).

## Low

- [ ] **S34. `validate_dep_references` cannot ever fire** (`graph.rs:648-663`) — every dep id is a live index by construction; it's a user-visible config field giving false assurance. Make it check invariants that can break, or delete it.
- [ ] **S35. `analyzers build` skips `resolve_dependencies` and `validate`** (`builder/analyzers.rs:207-223`) — it reports success on configs `build` rejects; also the fourth hand-rolled discover-then-X pipeline (reinforces M1/M2).
- [ ] **S36. `build.rs` shells out to `date(1)`** for `BUILD_TIMESTAMP` (non-reproducible binaries, `SOURCE_DATE_EPOCH` ignored) and scrapes `edition` from Cargo.toml with `starts_with` line matching (`build.rs:29-35,49-55`).
- [ ] **S37. Executor odds and ends.** `ExecutorOptions.verbose` is dead for the clean path (shadowed by a method parameter, `builder/clean.rs:56-64`); `--explain` lines interleave unordered across threads under `-j` — the flag users reach for to reconstruct causality.

## Test suite

- [ ] **S38. Both watch tests pass vacuously** — `watch.rs:35` matches the unconditional "Running initial build..." banner; `:73`'s "Change detected" is satisfied by S1's self-triggered loop; both `kill()` (SIGKILL) so S2's interrupt path is never exercised. The suite is structurally blind to both critical watch bugs.
- [ ] **S39. Exit codes 3/4/5/130 have zero tests** despite `exit_code.rs` calling them "a public contract"; a reachability test per variant would have caught S28 and S29.
- [ ] **S40. 15 sleep-based races** (~7.5 s wall-clock), including 100 ms mtime-granularity sleeps that flake on coarse-timestamp filesystems; `filetime::set_file_mtime` is deterministic.
- [ ] **S41. `tests/main.rs` is a 76-entry hand-maintained, unsorted module list** — a new test file not added there silently never runs; assert the list against the directory.

## Scan-level observations

- The dominant new pattern is unchanged from the 2026-08-02 review — hand-synchronized knowledge — but with a twist: **several of the worst items are sibling functions where exactly one copy got the correctness fix** (S3 `processor_version` vs `find_plugin`; S17 `upload_bytes` vs `upload`; S22 `timeout_secs` vs `max_attempts`; S32 chmod pairs and checksum twins; S29 `ctx()` vs `with_context`). When fixing any one, grep for its siblings.
- **Watch mode is the least-owned subsystem**: S1, S2, S21, S38 plus prior M2/carried-forward items all land there, and its two tests assert nothing. It needs a real driver (M2's reporter) and real tests before any of its individual bugs are worth polishing.
- **The remote cache has no backend conformance suite** — only FileBackend is exercised, which is exactly why S17/S18/S20 survived. One shared test harness over all three backends (atomicity under overwrite, error-vs-absent, path validation on fetched descriptors) pins the class.

## Remediation status (2026-08-04 sweep)

**Done** (each verified by the full suite; several with dedicated regression tests):
S3, S4, S8 (semaphore honored on the batch path), S9, S10 (symlink skips now warn), S11 (docs corrected to the 2-arg binding; phantom `processor_type()` hook doc removed), S12 (12 processors gained `config_json`), S13, S14 (pandoc added + construction-site count pin), S16 (all `#[cfg]` forks removed — declared-tools machinery is always-compiled with `debug_assert!`, tests un-gated), S17, S18, S20 (`..` rejected everywhere, absolute paths rejected at the remote-fetch boundary), S22, S23 (per-product scratch dir — this also fixed a worse latent bug: `.aux`/`.toc` were deleted BETWEEN runs, making multi-pass a no-op), S24, S25 (workflow_names content-tracked; copyright_years surfaces git failures), S26, S27, S28 (typed interrupts — exit 130 reachable from every command; Ctrl+C handler installs before run() proceeds), S29 (`ctx()` preserves the error chain — exit 5 reachable again), S31, S34, S35, S36, S37 (interrupt check in clean), S41. Plus V3–V7, V9, V10 above, and S15's worst offenders.

**Partial:** V8/S30 (tables.rs + main prose sites routed; `smart.rs` arms and the scanner extension remain), S32 (descriptor chmod checked, `.tmp-` filter removed so trim reclaims orphans, compression short-circuit format-aware, key hashing length-prefixed; remaining: no-op descriptor rewrite skip, lazy mtime.redb open — both fold into M7), S33 (a2x arm + fixer-list derivation done; the Lua `--iset` seam remains, folds into H2/H3).

**Deliberately deferred:**
- Watch mode (S1, S2, S21, S38) — back-burnered by decision 2026-08-04.
- S5/S6/S7 + S19 — the descriptor-key timing / shared-output-dir / tree-reconciliation seam: wants a one-page design ("which checksum keys a descriptor, when; who owns a shared directory") before code. The biggest remaining correctness cluster.
- S39/S40 — exit-code reachability tests and de-sleeping the suite; S39 becomes easy once a signal-driving test harness exists (same infra the watch tests need).
- H1–H4, M1–M7, L2–L4 — the structural refactors, unchanged from the 2026-08-02 order of attack.