Skip to content

fix(mcp): acquire the mutation lock before the confirm-path preflight read (NRN-106)#111

Merged
dbtlr merged 5 commits into
mainfrom
nrn-106-lock-before-preflight
Jul 9, 2026
Merged

fix(mcp): acquire the mutation lock before the confirm-path preflight read (NRN-106)#111
dbtlr merged 5 commits into
mainfrom
nrn-106-lock-before-preflight

Conversation

@dbtlr

@dbtlr dbtlr commented Jul 9, 2026

Copy link
Copy Markdown
Owner

Summary

MCP mutation tools now acquire the per-vault mutation lock BEFORE the confirm-path preflight read, closing the same read→lock TOCTOU window NRN-99 fixed for vault.edit (NRN-106, Phase 1.5 pre-routing hardening under NRN-223). This matters now because write-routing (NRN-229+) moves CLI mutations off the correct-order Direct arms onto these exact daemon paths.

What changed

  • The sweep: vault.set, vault.delete, vault.move, vault.new, vault.rewrite_wikilink, vault.apply lock first on confirm: true, spanning the index load through apply; dry-run stays completely lock-free. vault.apply deliberately parses/validates the plan (no vault reads) before locking; set's pure argument validation (expand_list_ops) moved pre-lock so malformed input fails fast without contending. The plan-time doc-hash CAS stays as defense-in-depth. Guard spelling unified across all seven handlers.
  • The invariant is now canonical: acquire_mutation_lock's doc states it once — on confirm, the lock precedes ANY read that feeds the write; dry-run never locks; read-free arg validation stays pre-lock — and the per-tool comments point there (the old doc described, and effectively endorsed, the pre-fix ordering).
  • Order-pinning tests for all seven tools (edit included): held lock + bogus target → the contention refusal must beat the not-found error, proving order rather than mere lock presence. Enabled by a new NORN_MUTATION_LOCK_TIMEOUT_MS debug-gated override (mirrors NORN_CACHE_LOCK_TIMEOUT_MS; compiled out of release builds) — the seven contended acquires cost ~1.2s total, and the suite drops a previous 5s stall.
  • Behavior under contention (CHANGELOG'd): uncontended outputs, exit codes, and envelopes are byte-identical; on a contended vault the lock-timeout refusal now fires before plan-validation errors, and the lock is held across planning/index load — an external writer on a large cold vault is more likely to hit the 5s timeout (the deliberate correctness tradeoff; a later phase narrows the window per NRN-157/NRN-151).

Review

Adversarial review verified the reorder clean across all six tools (lock scoping/RAII, guard direction, no in-process double-acquire, daemon call-lock serialization unaffected) and drove the hardening: the timeout knob, hermetic tests (the original ordering test leaked a lock file into the real XDG state home), seven-tool order pinning, the canonical doc, and the fail-fast validation move. Deferred as tracked tasks: structural with_confirm_lock extraction (NRN-233), move_doc --parents mkdir into the applier (NRN-234).

Verification

  • Quartet clean after rebase onto main (incl. NRN-221): check --locked / clippy -D warnings / fmt --check; Cargo.lock unmodified
  • cargo test --workspace: 2349 passed, 0 failed

dbtlr added 5 commits July 9, 2026 18:21
…-106)

vault.set, vault.delete, vault.move, vault.new, vault.rewrite_wikilink,
and vault.apply built their plan (graph-index load, preflight, target
resolution) before acquiring the per-vault mutation lock on the confirm
path, leaving a read-lock window where a concurrent norn writer could
drift a file past both the plan-time hash checks and the applier's
index-snapshot check. Reorders each tool to acquire the lock first,
spanning index load through apply, mirroring vault.edit's NRN-99 fix and
the CLI arms that already lock before their cache load. Dry-run stays
lock-free. Human-facing output, exit codes, and envelope shapes are
unchanged in the uncontended case.
Representative coverage for the confirm-path lock reorder: holds the
vault's .mutation.lock via fs2 (same mechanism tests/mutation_lock.rs
uses for the CLI arms), then calls handle() with a target that does not
exist. A preflight-first ordering would surface "doc not found" before
ever touching the lock; with the lock acquired first, the call instead
times out on the lock-contention refusal. Proves order, not just
presence.
Adds NORN_MUTATION_LOCK_TIMEOUT_MS, mirroring the cache write lock's
NORN_CACHE_LOCK_TIMEOUT_MS knob exactly: the env read is
#[cfg(debug_assertions)] and compiled out of release builds, so no
environment can shrink the production 5s budget. Lets contention tests
hit MutationLockTimeout in ~150ms instead of stalling the suite 5s per
contended acquire.
…alidate set args pre-lock

- acquire_mutation_lock's doc is now the single canonical statement of
  the NRN-99/NRN-106 ordering invariant (on confirm, lock before ANY
  read that feeds the write; dry-run never locks; cheap read-free arg
  validation stays pre-lock); the previous text still described the old
  lock-after-plan order. The seven per-tool comments slim to one line
  pointing at it.
- apply.rs and delete.rs guard spelling unified to the edit.rs shape
  (`if p.confirm { Some(..) } else { None }`).
- set.rs runs expand_list_ops (pure push/pop argument validation) before
  the lock, mirroring apply.rs's parse-before-lock pattern, so malformed
  input never contends. No test pinned the old precedence (the refusal
  tests assert Err + disk untouched, both preserved).
- CHANGELOG: note the deliberate tradeoff — the lock now spans planning
  and the index load, so an external concurrent writer on a large cold
  vault is more likely to hit the 5s timeout.
…on tools

Replaces the single set-only ordering test with one parameterized test
in mcp::mutate covering set, edit, delete, move, new, rewrite_wikilink,
and apply: hold the vault's .mutation.lock via fs2, call each handler
with confirm:true and a bogus target, and assert the lock-contention
refusal fires without the error ever naming the target — proving the
lock beats the preflight read wherever a bogus target is preflight-
observable (set/edit/delete/move/new); rewrite_wikilink and apply pin
contention-refusal-under-held-lock. Uses the debug-only
NORN_MUTATION_LOCK_TIMEOUT_MS override (150ms), so all seven contended
acquires cost ~1s total instead of 35s. A Drop guard removes the
per-vault state dir so the lock file never leaks into the real XDG
state home (env stays untouched: set_var is process-global and races
sibling tests, per the server.rs isolation note).
@dbtlr dbtlr merged commit 3d0cdd9 into main Jul 9, 2026
7 checks passed
@dbtlr dbtlr deleted the nrn-106-lock-before-preflight branch July 9, 2026 22:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant