From 2754f80c90d58c0e401b7641407d4b220195125c Mon Sep 17 00:00:00 2001 From: Drew Butler Date: Wed, 8 Jul 2026 14:23:09 -0400 Subject: [PATCH 1/5] feat(cache): env-gated integrity_check trace hook (NRN-83) Emit a stable `norn trace: integrity_check` stderr marker per PRAGMA integrity_check execution when NORN_TRACE_INTEGRITY_CHECK is set. Gated on the env var so normal output and the byte-identical routing proofs are untouched. Gives an out-of-process harness a deterministic, cross-process count of how many times a code path pays the O(db-size) check: a warm daemon opens-once/verifies-once (one marker across N routed reads) versus N markers for N direct reopens. --- src/cache/open.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/cache/open.rs b/src/cache/open.rs index 9f188236..60b3cec0 100644 --- a/src/cache/open.rs +++ b/src/cache/open.rs @@ -243,6 +243,17 @@ fn inspect_existing_cache( } // PRAGMA integrity_check + // + // Acceptance-benchmark trace hook (NRN-83). When `NORN_TRACE_INTEGRITY_CHECK` + // is set, emit one stable stderr marker per `integrity_check` execution. This + // gives an out-of-process harness a deterministic, cross-process count of how + // many times a code path actually pays the O(db-size) check: a live daemon + // opens-once/verifies-once, so N routed reads share ONE marker, whereas N + // direct invocations each reopen and produce N markers. Gated on the env var + // so normal output — and the byte-identical routing proofs — are untouched. + if std::env::var_os("NORN_TRACE_INTEGRITY_CHECK").is_some() { + eprintln!("norn trace: integrity_check"); + } let integrity: Result = conn.query_row("PRAGMA integrity_check", [], |r| r.get(0)); match integrity { Ok(s) if s == "ok" => {} From 07ab956375be1f36fa144578ec8fcc29dc2bd2cc Mon Sep 17 00:00:00 2001 From: Drew Butler Date: Wed, 8 Jul 2026 14:23:09 -0400 Subject: [PATCH 2/5] test(bench): NRN-83 integrity_check acceptance harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Committed, re-runnable acceptance benchmark proving the daemon initiative's founding-bug fix (ADR 0005) end-to-end at 50k-doc scale: routed reads pay zero per-invocation integrity_check while direct no-daemon reads still verify every call. - tests/bench_util/: deterministic synthetic-vault generator (SplitMix64, pure function of (N, seed) — no wall-clock/thread RNG), with determinism self-tests that run in the default pass. - tests/integrity_benchmark.rs: #[ignore]-gated harness. Structural acceptance assertion (not timing): exactly ONE integrity_check marker in the daemon's stderr across all routed reads vs one-per-call direct, cross-checked against served markers so it cannot pass vacuously. Scale/seed/iters env-overridable; prints an evidence table. 50k run: direct read median ~410ms vs routed warm ~147ms; direct=15 integrity_check markers, daemon=1. --- CHANGELOG.md | 2 + tests/bench_util/mod.rs | 141 ++++++++++++++ tests/integrity_benchmark.rs | 349 +++++++++++++++++++++++++++++++++++ 3 files changed, 492 insertions(+) create mode 100644 tests/bench_util/mod.rs create mode 100644 tests/integrity_benchmark.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index df5580b2..b23bf5f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ Entries here have landed on `main` but have not yet been cut into a tagged relea ### Added +- **Re-runnable integrity-check acceptance benchmark for the warm daemon (NRN-83).** A committed, `#[ignore]`-gated integration harness (`tests/integrity_benchmark.rs` + a deterministic synthetic-vault generator in `tests/bench_util/`) proves the founding-bug fix end-to-end at 50k-doc scale: with a live `norn serve` daemon, routed `count`/`find`/`get` reads pay **zero** per-invocation `PRAGMA integrity_check` (open-once / verify-once, ADR 0005), while direct no-daemon reads still verify every call (trust preserved). The acceptance criterion is asserted **structurally, not by timing**: `src/cache/open.rs` now emits a `norn trace: integrity_check` stderr marker per check when `NORN_TRACE_INTEGRITY_CHECK` is set (env-gated, off by default — normal output and the byte-identical routing proofs are untouched), so the harness counts exactly ONE marker in the daemon's stderr across all routed reads versus one-per-call on the direct path, cross-checked against the daemon's `served` markers so the result cannot pass vacuously. The generator is deterministic given `(N, seed)` — no wall-clock or thread RNG — and scale/seed/iterations are env-overridable (`NORN_BENCH_DOCS` default 50000, `NORN_BENCH_SEED` default 83, `NORN_BENCH_ITERS` default 5). Run it with `cargo test --release --test integrity_benchmark -- --ignored --nocapture`; it prints an evidence table (sizes, timings, marker/served counts) for the operator record. Dev-facing only — no runtime behavior change on any default path. + - **`norn service` — a launchd supervisor over the warm `norn serve` daemon (macOS) (NRN-115).** `norn serve` is a plain foreground process; the new `norn service install | uninstall | start | stop | restart | status` verbs adopt it as a launchd user agent so it is always warm (`KeepAlive` + `RunAtLoad`) — the same install-once-stay-warm posture Mimir's service contract uses, with no CLI auto-spawn. `install` renders a plist at `~/Library/LaunchAgents/com.dbtlr.norn.serve.plist` and bootstraps it idempotently (bootout-first, with a retry on launchd's async-teardown race). The plist's `ProgramArguments` use the invoking binary's absolute path **with symlinks preserved** (a Homebrew-style symlink stays the stable launcher path instead of a versioned target that dies on upgrade), stdout/stderr redirect to `/norn/log/serve.log`, and an install-time `XDG_CACHE_HOME` is **baked into the unit's environment** (launchd agents inherit no shell env; without this the daemon and its clients would derive different socket paths and never route). `uninstall` boots out a loaded unit and removes the plist (config and logs kept; tolerant of not-installed — but a failed unload keeps the plist and errors, never orphaning a live KeepAlive daemon); `stop` is an honest bootout (a `KeepAlive` daemon would resurrect a merely-killed pid); `restart` is `kickstart -k`. **Exit-code contract:** `start` is idempotent — exit 0 iff the unit ended running through this call *or was already running* (a loaded-but-not-running unit is kickstarted); `stop`/`restart` exit 0 iff they acted on a loaded unit, and a no-op (not installed, or not running) exits 1 with a structured reason — so a `norn service stop && …` chain never proceeds on a no-op, while `start && …` proceeds whenever the daemon is up. `status` combines `launchctl print` (loaded / running / pid) with a live control-ping over the daemon's Unix socket, rendering the running vs on-disk build version with a **restart-pending** flag (plus uptime and the plist / log / socket paths). The JSON report's `loaded` / `running` are **tri-state** (`true` / `false` / `null`): when the launchd probe itself fails, status still renders the report — launchd state `null` with the probe error in a `launchd_error` field, and whatever the live socket pong said (version / pid / uptime) — but **exits nonzero (1)**, so a `norn service status || alert` health gate fires on unknown supervision state; every known state (running, stopped, not installed) exits 0. Every verb takes `--format text|json`; under `json` every outcome — failures included — is a machine-readable object on stdout. On non-macOS hosts the verbs print a friendly fallback (run `norn serve` under your own supervisor; systemd support is planned) and exit nonzero. ### Changed diff --git a/tests/bench_util/mod.rs b/tests/bench_util/mod.rs new file mode 100644 index 00000000..bf028e7b --- /dev/null +++ b/tests/bench_util/mod.rs @@ -0,0 +1,141 @@ +//! Deterministic synthetic-vault generator for the integrity-check acceptance +//! benchmark (NRN-83) and any future scale regression harness. +//! +//! `generate_vault(dir, n, seed)` writes `n` Markdown documents whose frontmatter, +//! wikilinks, and headings are a pure function of `(index, seed)` — no +//! `Date::now`, no thread RNG, no filesystem entropy — so a run is byte-for-byte +//! reproducible and drives no flakiness into the assertions layered on top of it. +//! The docs are realistic enough that the graph/link/field index is genuinely +//! exercised: a few indexed frontmatter fields (`type`/`status`/`priority`/ +//! `title`), real inter-doc `[[wikilinks]]`, and headings + body. +//! +//! Cargo compiles this into each test binary that declares `mod bench_util;`. +//! Only the benchmark uses it today; silence the per-binary dead-code lint. +#![allow(dead_code)] + +use std::fmt::Write as _; +use std::path::Path; + +/// SplitMix64 — a tiny, dependency-free deterministic PRNG. Seeded from +/// `(seed, index)` so each document draws an independent-looking but fully +/// reproducible stream. We only need cheap, well-distributed integers here, not +/// cryptographic quality. +struct SplitMix64(u64); + +impl SplitMix64 { + fn new(seed: u64) -> Self { + SplitMix64(seed) + } + + fn next_u64(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + + /// Uniform-ish integer in `[0, bound)`. `bound` must be non-zero. + fn below(&mut self, bound: u64) -> u64 { + self.next_u64() % bound + } +} + +const TYPES: &[&str] = &["note", "task", "log", "reference"]; +const STATUSES: &[&str] = &["active", "backlog", "done", "archived"]; + +/// Zero-padded stem for document `i` (`doc-00042`). Width 6 keeps stems sorted +/// and stable up to a million docs — comfortably past the 50k benchmark scale. +pub fn doc_stem(i: usize) -> String { + format!("doc-{i:06}") +} + +/// Write a deterministic vault of `n` documents into `dir`. Returns the number +/// of documents written (always `n`). Panics on any I/O error — this is test +/// infrastructure and a failed write is a hard stop. +pub fn generate_vault(dir: &Path, n: usize, seed: u64) -> usize { + assert!(n > 0, "generate_vault needs at least one document"); + std::fs::create_dir_all(dir).expect("create vault dir"); + + for i in 0..n { + let mut rng = SplitMix64::new(seed ^ (i as u64).wrapping_mul(0x0100_0000_01B3)); + let doc_type = TYPES[(rng.below(TYPES.len() as u64)) as usize]; + let status = STATUSES[(rng.below(STATUSES.len() as u64)) as usize]; + let priority = 1 + rng.below(5); // 1..=5 + + // Two deterministic wikilinks to other docs (never self), so the link + // graph is dense enough to exercise the links index. + let link_a = pick_other(&mut rng, i, n); + let link_b = pick_other(&mut rng, i, n); + + let mut body = String::with_capacity(256); + // Frontmatter — the indexed fields queries filter on. + write!( + body, + "---\ntype: {doc_type}\nstatus: {status}\npriority: {priority}\ntitle: Document {i}\n---\n" + ) + .unwrap(); + // Headings + prose so the headings index and body scans have real input. + write!( + body, + "# Document {i}\n\n## Overview\n\nSynthetic document {i} of type {doc_type}. \ + Links to [[{a}]] and [[{b}]].\n\n## Details\n\nStatus is {status}; priority {priority}. \ + Lorem ipsum body text for index exercise.\n", + a = doc_stem(link_a), + b = doc_stem(link_b), + ) + .unwrap(); + + let path = dir.join(format!("{}.md", doc_stem(i))); + std::fs::write(&path, body).unwrap_or_else(|e| panic!("write {}: {e}", path.display())); + } + n +} + +/// Pick a document index other than `i` in `[0, n)`. With `n == 1` there is no +/// other doc, so it links to itself (harmless for the benchmark's purposes). +fn pick_other(rng: &mut SplitMix64, i: usize, n: usize) -> usize { + if n == 1 { + return 0; + } + let mut j = rng.below(n as u64) as usize; + if j == i { + j = (j + 1) % n; + } + j +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generation_is_deterministic_for_same_seed() { + let a = tempfile::tempdir().unwrap(); + let b = tempfile::tempdir().unwrap(); + generate_vault(a.path(), 50, 7); + generate_vault(b.path(), 50, 7); + for i in 0..50 { + let fa = std::fs::read_to_string(a.path().join(format!("{}.md", doc_stem(i)))).unwrap(); + let fb = std::fs::read_to_string(b.path().join(format!("{}.md", doc_stem(i)))).unwrap(); + assert_eq!(fa, fb, "doc {i} must be identical across same-seed runs"); + } + } + + #[test] + fn different_seeds_diverge() { + let a = tempfile::tempdir().unwrap(); + let b = tempfile::tempdir().unwrap(); + generate_vault(a.path(), 50, 1); + generate_vault(b.path(), 50, 2); + let any_diff = (0..50).any(|i| { + let fa = std::fs::read_to_string(a.path().join(format!("{}.md", doc_stem(i)))).unwrap(); + let fb = std::fs::read_to_string(b.path().join(format!("{}.md", doc_stem(i)))).unwrap(); + fa != fb + }); + assert!( + any_diff, + "different seeds should produce at least one differing doc" + ); + } +} diff --git a/tests/integrity_benchmark.rs b/tests/integrity_benchmark.rs new file mode 100644 index 00000000..2c765f42 --- /dev/null +++ b/tests/integrity_benchmark.rs @@ -0,0 +1,349 @@ +//! NRN-83 acceptance benchmark — the daemon initiative's founding-bug proof. +//! +//! The founding bug: `Cache::open` runs `PRAGMA integrity_check` on every open, +//! an O(db-size) cost that dwarfs the actual query at scale. ADR 0005's +//! resolution relocated the check to the warm `norn serve` daemon +//! (open-once / verify-once); routed reads inherit the already-verified state. +//! This harness proves that end-to-end at 50k-doc scale, re-runnably. +//! +//! ## The structural (non-timing) acceptance observable +//! +//! `src/cache/open.rs` emits one `norn trace: integrity_check` stderr line per +//! `PRAGMA integrity_check` execution WHEN `NORN_TRACE_INTEGRITY_CHECK` is set. +//! Because a `Cache::open` on an EXISTING db always runs the check, this marker +//! is a deterministic, cross-process count of how many times a code path pays it: +//! +//! * A **direct** invocation reopens the cache every time → one marker PER call. +//! * The **warm daemon** opens the cache once and holds it → ONE marker across +//! every routed read, no matter how many `count`/`find`/`get` calls arrive. +//! +//! So the acceptance criterion is asserted structurally, not by timing: after N +//! routed reads the daemon's stderr carries exactly ONE integrity_check marker, +//! while N direct reads carry N. Timings are collected as supporting operator +//! evidence, printed as a table under `--nocapture`. The `count_served` markers +//! (one per tools/call the daemon actually serves) prove the routed reads were +//! genuinely served warm and did not silently fall back to a direct open — so +//! the one-marker result is a real verify-once win, not a vacuous zero-traffic +//! artifact. +//! +//! Run it explicitly (it is `#[ignore]`-gated; the 50k build takes real seconds): +//! +//! ```text +//! cargo test --release --test integrity_benchmark -- --ignored --nocapture +//! ``` +//! +//! Scale/seed are env-overridable: `NORN_BENCH_DOCS` (default 50000), +//! `NORN_BENCH_SEED` (default 83), `NORN_BENCH_ITERS` (default 5). + +#![cfg(unix)] + +#[path = "bench_util/mod.rs"] +mod bench_util; +#[path = "serve_util/mod.rs"] +mod serve_util; + +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use bench_util::generate_vault; +use serve_util::{count_served, norn_bin, socket_path_for, wait_for_ready, ChildGuard}; + +/// The stable marker `src/cache/open.rs` emits per integrity_check under trace. +const INTEGRITY_MARKER: &str = "norn trace: integrity_check"; + +fn env_usize(key: &str, default: usize) -> usize { + std::env::var(key) + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(default) +} + +fn env_u64(key: &str, default: u64) -> u64 { + std::env::var(key) + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(default) +} + +/// Outcome of one `norn` invocation: exit code plus wall-clock and the count of +/// integrity_check markers on its OWN stderr (direct runs carry them here). +struct RunResult { + code: i32, + elapsed: Duration, + integrity_markers: usize, +} + +/// Run `norn --cwd ` with a private cache/state home and the +/// integrity-check trace enabled. Returns the timed result. +fn run_norn(cache_home: &Path, state_home: &Path, vault: &Path, args: &[&str]) -> RunResult { + let start = Instant::now(); + let out = Command::new(norn_bin()) + .env("XDG_CACHE_HOME", cache_home) + .env("XDG_STATE_HOME", state_home) + .env("NORN_SERVICE_HANDSHAKE_TIMEOUT_MS", "5000") + .env("NORN_TRACE_INTEGRITY_CHECK", "1") + .arg("--cwd") + .arg(vault) + .args(args) + .output() + .expect("run norn"); + let elapsed = start.elapsed(); + let markers = String::from_utf8_lossy(&out.stderr) + .matches(INTEGRITY_MARKER) + .count(); + RunResult { + code: out.status.code().unwrap_or(-1), + elapsed, + integrity_markers: markers, + } +} + +/// Spawn a daemon on `cache_home`, with integrity-check tracing on and its +/// stderr captured to `/err`. Returns the guard, the stderr path, and the +/// tempdir root (kept alive so the socket path stays valid). +fn spawn_daemon_traced(cache_home: &Path, state_home: &Path) -> (ChildGuard, PathBuf) { + let stderr_path = cache_home + .parent() + .expect("cache_home has a parent") + .join("daemon-err"); + let stderr_file = std::fs::File::create(&stderr_path).unwrap(); + let child = Command::new(norn_bin()) + .arg("serve") + .env("XDG_CACHE_HOME", cache_home) + .env("XDG_STATE_HOME", state_home) + .env("NORN_TRACE_INTEGRITY_CHECK", "1") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::from(stderr_file)) + .spawn() + .expect("spawn norn serve"); + let guard = ChildGuard(child); + wait_for_ready(&socket_path_for(cache_home), Duration::from_secs(10)); + (guard, stderr_path) +} + +fn daemon_integrity_markers(stderr_path: &Path) -> usize { + std::fs::read_to_string(stderr_path) + .unwrap_or_default() + .matches(INTEGRITY_MARKER) + .count() +} + +fn median(mut v: Vec) -> Duration { + v.sort(); + v[v.len() / 2] +} + +/// The three routed-read shapes exercised. `get` targets the first generated +/// doc (guaranteed to exist); `find` is filtered + limited so its JSON output +/// stays small while still driving a full indexed query. +fn read_shapes() -> Vec<(&'static str, Vec<&'static str>)> { + vec![ + ("count", vec!["count"]), + ( + "find", + vec![ + "find", + "--eq", + "type:note", + "--limit", + "5", + "--format", + "json", + ], + ), + ("get", vec!["get", "doc-000000"]), + ] +} + +#[test] +#[ignore = "50k-doc acceptance benchmark; run explicitly with --ignored --nocapture"] +fn integrity_check_acceptance_50k() { + let n = env_usize("NORN_BENCH_DOCS", 50_000); + let seed = env_u64("NORN_BENCH_SEED", 83); + let iters = env_usize("NORN_BENCH_ITERS", 5); + assert!( + iters >= 2, + "need at least 2 iterations to separate cold/warm" + ); + + // ---- Generate the synthetic vault ----------------------------------- + let vault_tmp = tempfile::Builder::new() + .prefix("nb-vault-") + .tempdir() + .unwrap(); + let gen_start = Instant::now(); + generate_vault(vault_tmp.path(), n, seed); + let gen_elapsed = gen_start.elapsed(); + let vault = vault_tmp.path(); + + // One shared cache home for direct baseline THEN daemon: no daemon socket + // exists during the direct phase (so those calls run direct); the daemon + // binds the same home afterward and routes against the already-built cache. + // Short prefix keeps the socket path under macOS's ~104-byte sun_path. + let home_tmp = tempfile::Builder::new().prefix("nb-").tempdir().unwrap(); + let cache_home = home_tmp.path().join("c"); + let state_home = home_tmp.path().join("s"); + std::fs::create_dir_all(&cache_home).unwrap(); + std::fs::create_dir_all(&state_home).unwrap(); + + // ---- Build the cache once (Fresh open: no integrity_check yet) ------- + let build_start = Instant::now(); + let build = run_norn(&cache_home, &state_home, vault, &["count"]); + let build_elapsed = build_start.elapsed(); + assert_eq!(build.code, 0, "initial cache build (count) must exit 0"); + assert_eq!( + build.integrity_markers, 0, + "the Fresh-open build must NOT run integrity_check (no db existed yet)" + ); + // The cache dir is vault-hash-derived under the home; walk to find it. + let cache_db_bytes = walk_find_cache_db(&cache_home) + .and_then(|p| p.metadata()) + .map(|m| m.len()) + .unwrap_or(0); + + // ---- Direct baseline: each call reopens → pays integrity_check ------- + // This is ALSO the no-daemon regression guard (deliverable 3): a direct read + // at 50k docs must STILL verify — exactly one integrity_check marker per call. + let mut direct_medians: Vec<(&str, Duration)> = Vec::new(); + let mut direct_integrity_total = 0usize; + for (label, args) in read_shapes() { + let mut samples = Vec::with_capacity(iters); + for _ in 0..iters { + let r = run_norn(&cache_home, &state_home, vault, &args); + assert_eq!(r.code, 0, "direct {label} must exit 0"); + assert_eq!( + r.integrity_markers, 1, + "direct {label} at {n} docs must pay EXACTLY one integrity_check \ + (trust preserved on the no-daemon path)" + ); + direct_integrity_total += r.integrity_markers; + samples.push(r.elapsed); + } + direct_medians.push((label, median(samples))); + } + + // ---- Routed: spawn the daemon on the SAME (already-built) cache home -- + let (_guard, daemon_stderr) = spawn_daemon_traced(&cache_home, &state_home); + + let mut routed_medians: Vec<(&str, Duration)> = Vec::new(); + let mut served_counts: Vec<(&str, usize)> = Vec::new(); + let mut total_routed_calls = 0usize; + for (label, args) in read_shapes() { + // Warm-only latency: skip the first call, whose cost may include the + // daemon's single warm open. Calls 2..=iters are steady-state warm. + let mut warm_samples = Vec::with_capacity(iters - 1); + for i in 0..iters { + let r = run_norn(&cache_home, &state_home, vault, &args); + assert_eq!(r.code, 0, "routed {label} must exit 0"); + // A routed call carries NO integrity marker on its OWN stderr — the + // check (if any) happens daemon-side, counted separately below. + assert_eq!( + r.integrity_markers, 0, + "routed {label} must not run integrity_check in the CLI process" + ); + if i > 0 { + warm_samples.push(r.elapsed); + } + total_routed_calls += 1; + } + routed_medians.push((label, median(warm_samples))); + + let tool = match label { + "count" => "vault.count", + "find" => "vault.find", + "get" => "vault.get", + other => panic!("unmapped shape {other}"), + }; + served_counts.push((label, count_served(&daemon_stderr, tool))); + } + + // ---- STRUCTURAL ACCEPTANCE ASSERTION -------------------------------- + // The daemon opened the cache once and held it: across ALL routed reads its + // stderr carries exactly ONE integrity_check marker. If routed reads paid the + // check per invocation (the founding bug, un-fixed), this would be + // total_routed_calls instead. + let daemon_markers = daemon_integrity_markers(&daemon_stderr); + assert_eq!( + daemon_markers, + 1, + "ACCEPTANCE: the warm daemon must pay integrity_check exactly ONCE across \ + all {total_routed_calls} routed reads (verify-once); got {daemon_markers}. \ + daemon stderr:\n{}", + std::fs::read_to_string(&daemon_stderr).unwrap_or_default() + ); + + // Routing was real, not a vacuous fall-back to direct: each shape was served + // `iters` times by the daemon. + for (label, served) in &served_counts { + assert_eq!( + *served, iters, + "routed {label} must have been SERVED {iters} times by the daemon, got {served}; \ + a lower count means it silently ran direct and the one-marker result above is vacuous" + ); + } + + // Regression guard tally: direct reads verified every time. + assert_eq!( + direct_integrity_total, + iters * read_shapes().len(), + "every direct read must have paid integrity_check (no-daemon trust guard)" + ); + + // Supporting timing evidence (not the acceptance gate, but should hold with a + // wide margin because the warm path skips the O(db-size) check entirely). + let direct_count_med = direct_medians[0].1; + let routed_count_med = routed_medians[0].1; + assert!( + routed_count_med < direct_count_med, + "warm routed count ({routed_count_med:?}) should be faster than direct ({direct_count_med:?})" + ); + + // ---- Evidence table -------------------------------------------------- + println!("\n==================== NRN-83 integrity_check acceptance ===================="); + println!("documents generated : {n} (seed {seed})"); + println!("vault generation : {gen_elapsed:?}"); + println!("cold cache build (count) : {build_elapsed:?}"); + println!("cache.db size : {} bytes", cache_db_bytes); + println!("iterations per shape : {iters}"); + println!("--------------------------------------------------------------------------"); + println!( + "{:<8} {:>18} {:>18} {:>10}", + "shape", "direct median", "routed(warm) med", "served" + ); + for i in 0..read_shapes().len() { + let (label, d) = direct_medians[i]; + let (_, r) = routed_medians[i]; + let (_, served) = served_counts[i]; + println!("{label:<8} {:>18?} {:>18?} {:>10}", d, r, served); + } + println!("--------------------------------------------------------------------------"); + println!( + "integrity_check markers : direct = {} (one per read), daemon = {} (verify-once)", + direct_integrity_total, daemon_markers + ); + println!("routed reads served : {total_routed_calls} total across all shapes"); + println!("==========================================================================\n"); +} + +/// Best-effort walk to locate the vault's `cache.db` under a cache home (the +/// path is vault-hash-derived). Used only for the evidence table's size figure. +fn walk_find_cache_db(cache_home: &Path) -> std::io::Result { + fn recurse(dir: &Path) -> std::io::Result> { + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + if let Some(found) = recurse(&path)? { + return Ok(Some(found)); + } + } else if path.file_name().and_then(|s| s.to_str()) == Some("cache.db") { + return Ok(Some(path)); + } + } + Ok(None) + } + recurse(cache_home)? + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "cache.db")) +} From 034cbee5a6dcff9075df93536142d0313685e797 Mon Sep 17 00:00:00 2001 From: Drew Butler Date: Wed, 8 Jul 2026 14:43:29 -0400 Subject: [PATCH 3/5] refactor(bench): apply NRN-83 review dispositions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/cache/open.rs: make the trace hook's contract explicit — opt-in diagnostic off by default; enabling it intentionally diverges routed vs direct stderr; never enable where norn's stderr is asserted on. Documents why it stays release-available (pure observability) in contrast to the debug-gated NORN_CACHE_LOCK_TIMEOUT_MS (which changes timing behavior). CHANGELOG gains the same clarification. - integrity_benchmark: demote the routed-faster-than-direct timing check from a hard assert to a WARN println — the structural marker assertions are the only acceptance gates; timing is evidence. - integrity_benchmark: locate cache.db via the production identity mapping instead of a recursive walk, failing loudly if absent. Adds a #[doc(hidden)] resolve_cache_dir seam (cache.rs, re-exported in lib.rs) that stringifies the error so the crate-private CacheError does not leak into public visibility; same posture as Cache::conn. - bench_util: drop the blanket #![allow(dead_code)] (everything compiles into the #[ignore] test binary; no per-item allows needed). --- CHANGELOG.md | 2 +- src/cache.rs | 14 +++++++++ src/cache/open.rs | 19 +++++++++-- src/lib.rs | 5 +++ tests/bench_util/mod.rs | 2 -- tests/integrity_benchmark.rs | 61 +++++++++++++++++------------------- 6 files changed, 66 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b23bf5f5..31fd4c7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ Entries here have landed on `main` but have not yet been cut into a tagged relea ### Added -- **Re-runnable integrity-check acceptance benchmark for the warm daemon (NRN-83).** A committed, `#[ignore]`-gated integration harness (`tests/integrity_benchmark.rs` + a deterministic synthetic-vault generator in `tests/bench_util/`) proves the founding-bug fix end-to-end at 50k-doc scale: with a live `norn serve` daemon, routed `count`/`find`/`get` reads pay **zero** per-invocation `PRAGMA integrity_check` (open-once / verify-once, ADR 0005), while direct no-daemon reads still verify every call (trust preserved). The acceptance criterion is asserted **structurally, not by timing**: `src/cache/open.rs` now emits a `norn trace: integrity_check` stderr marker per check when `NORN_TRACE_INTEGRITY_CHECK` is set (env-gated, off by default — normal output and the byte-identical routing proofs are untouched), so the harness counts exactly ONE marker in the daemon's stderr across all routed reads versus one-per-call on the direct path, cross-checked against the daemon's `served` markers so the result cannot pass vacuously. The generator is deterministic given `(N, seed)` — no wall-clock or thread RNG — and scale/seed/iterations are env-overridable (`NORN_BENCH_DOCS` default 50000, `NORN_BENCH_SEED` default 83, `NORN_BENCH_ITERS` default 5). Run it with `cargo test --release --test integrity_benchmark -- --ignored --nocapture`; it prints an evidence table (sizes, timings, marker/served counts) for the operator record. Dev-facing only — no runtime behavior change on any default path. +- **Re-runnable integrity-check acceptance benchmark for the warm daemon (NRN-83).** A committed, `#[ignore]`-gated integration harness (`tests/integrity_benchmark.rs` + a deterministic synthetic-vault generator in `tests/bench_util/`) proves the founding-bug fix end-to-end at 50k-doc scale: with a live `norn serve` daemon, routed `count`/`find`/`get` reads pay **zero** per-invocation `PRAGMA integrity_check` (open-once / verify-once, ADR 0005), while direct no-daemon reads still verify every call (trust preserved). The acceptance criterion is asserted **structurally, not by timing**: `src/cache/open.rs` now emits a `norn trace: integrity_check` stderr marker per check when `NORN_TRACE_INTEGRITY_CHECK` is set (env-gated, off by default — normal output and the byte-identical routing proofs are untouched), so the harness counts exactly ONE marker in the daemon's stderr across all routed reads versus one-per-call on the direct path, cross-checked against the daemon's `served` markers so the result cannot pass vacuously. The generator is deterministic given `(N, seed)` — no wall-clock or thread RNG — and scale/seed/iterations are env-overridable (`NORN_BENCH_DOCS` default 50000, `NORN_BENCH_SEED` default 83, `NORN_BENCH_ITERS` default 5). Run it with `cargo test --release --test integrity_benchmark -- --ignored --nocapture`; it prints an evidence table (sizes, timings, marker/served counts) for the operator record. The trace hook is available in release builds (unlike the debug-gated `NORN_CACHE_LOCK_TIMEOUT_MS`, it is pure observability and alters no behavior when unset), but enabling it intentionally makes routed and direct stderr diverge — never set it in an environment that asserts on norn's stderr. Dev-facing only — no runtime behavior change on any default path. - **`norn service` — a launchd supervisor over the warm `norn serve` daemon (macOS) (NRN-115).** `norn serve` is a plain foreground process; the new `norn service install | uninstall | start | stop | restart | status` verbs adopt it as a launchd user agent so it is always warm (`KeepAlive` + `RunAtLoad`) — the same install-once-stay-warm posture Mimir's service contract uses, with no CLI auto-spawn. `install` renders a plist at `~/Library/LaunchAgents/com.dbtlr.norn.serve.plist` and bootstraps it idempotently (bootout-first, with a retry on launchd's async-teardown race). The plist's `ProgramArguments` use the invoking binary's absolute path **with symlinks preserved** (a Homebrew-style symlink stays the stable launcher path instead of a versioned target that dies on upgrade), stdout/stderr redirect to `/norn/log/serve.log`, and an install-time `XDG_CACHE_HOME` is **baked into the unit's environment** (launchd agents inherit no shell env; without this the daemon and its clients would derive different socket paths and never route). `uninstall` boots out a loaded unit and removes the plist (config and logs kept; tolerant of not-installed — but a failed unload keeps the plist and errors, never orphaning a live KeepAlive daemon); `stop` is an honest bootout (a `KeepAlive` daemon would resurrect a merely-killed pid); `restart` is `kickstart -k`. **Exit-code contract:** `start` is idempotent — exit 0 iff the unit ended running through this call *or was already running* (a loaded-but-not-running unit is kickstarted); `stop`/`restart` exit 0 iff they acted on a loaded unit, and a no-op (not installed, or not running) exits 1 with a structured reason — so a `norn service stop && …` chain never proceeds on a no-op, while `start && …` proceeds whenever the daemon is up. `status` combines `launchctl print` (loaded / running / pid) with a live control-ping over the daemon's Unix socket, rendering the running vs on-disk build version with a **restart-pending** flag (plus uptime and the plist / log / socket paths). The JSON report's `loaded` / `running` are **tri-state** (`true` / `false` / `null`): when the launchd probe itself fails, status still renders the report — launchd state `null` with the probe error in a `launchd_error` field, and whatever the live socket pong said (version / pid / uptime) — but **exits nonzero (1)**, so a `norn service status || alert` health gate fires on unknown supervision state; every known state (running, stopped, not installed) exits 0. Every verb takes `--format text|json`; under `json` every outcome — failures included — is a machine-readable object on stdout. On non-macOS hosts the verbs print a friendly fallback (run `norn serve` under your own supervisor; systemd support is planned) and exit nonzero. diff --git a/src/cache.rs b/src/cache.rs index 537c4b94..84be4582 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -43,6 +43,20 @@ pub(crate) use identity::{ cache_dir_for, cache_tree_root, events_dir_for, hex_lower, state_dir_for, state_tree_root, vault_identity, vault_identity_hash, xdg_cache_home_env, }; + +/// Resolve a vault's on-disk cache directory with the SAME identity mapping +/// production opens use (`identity::cache_dir_for`), returning just the cache +/// dir. Cross-crate test-harness seam: the NRN-83 acceptance benchmark locates +/// `cache.db` under a private `XDG_CACHE_HOME` through it instead of walking +/// the cache tree. The error is stringified so the crate-private `CacheError` +/// type does not leak into public visibility. Not part of the stable public +/// API — same posture as `Cache::conn`. +#[doc(hidden)] +pub fn resolve_cache_dir(vault_root: &camino::Utf8Path) -> Result { + cache_dir_for(vault_root) + .map(|(_canonical, dir)| dir) + .map_err(|e| e.to_string()) +} pub(crate) use lock::acquire_flock; pub(crate) use query_show::{DocumentDeep, IncomingLink}; diff --git a/src/cache/open.rs b/src/cache/open.rs index 60b3cec0..6a712bcc 100644 --- a/src/cache/open.rs +++ b/src/cache/open.rs @@ -249,8 +249,23 @@ fn inspect_existing_cache( // gives an out-of-process harness a deterministic, cross-process count of how // many times a code path actually pays the O(db-size) check: a live daemon // opens-once/verifies-once, so N routed reads share ONE marker, whereas N - // direct invocations each reopen and produce N markers. Gated on the env var - // so normal output — and the byte-identical routing proofs — are untouched. + // direct invocations each reopen and produce N markers. + // + // Contract: + // - Opt-in diagnostic, OFF by default: with the env var unset (every normal + // environment) this emits nothing and changes no behavior. + // - When enabled, routed and direct stderr INTENTIONALLY diverge — revealing + // where the check runs is the hook's entire purpose, so the byte-identical + // routing guarantee does not hold under trace. + // - Never enable it in an environment that asserts on norn's stderr (the + // routing byte-identity proofs, log-diffing pipelines). + // + // Deliberately available in RELEASE builds, unlike the debug-gated + // `NORN_CACHE_LOCK_TIMEOUT_MS` (src/cache/lock.rs): that override CHANGES + // timing behavior, so an inherited value in a production environment must + // be impossible; this hook is pure observability — it alters no timing, no + // outcome, no output when unset — and the `--release` acceptance benchmark + // needs it in the optimized binary it measures. if std::env::var_os("NORN_TRACE_INTEGRITY_CHECK").is_some() { eprintln!("norn trace: integrity_check"); } diff --git a/src/lib.rs b/src/lib.rs index 463b01cb..b849f4de 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,11 @@ mod apply_cmd; pub mod apply_report; mod audit; mod cache; +// The vault-root → cache-dir identity mapping, re-exported for test harnesses +// (the NRN-83 acceptance benchmark) that must locate a vault's cache.db under +// a private XDG_CACHE_HOME exactly the way production does. `#[doc(hidden)]` +// seam, not stable public API — see `cache::resolve_cache_dir`. +pub use cache::resolve_cache_dir; mod cache_cmd; mod cli; mod completions; diff --git a/tests/bench_util/mod.rs b/tests/bench_util/mod.rs index bf028e7b..55a68aff 100644 --- a/tests/bench_util/mod.rs +++ b/tests/bench_util/mod.rs @@ -10,8 +10,6 @@ //! `title`), real inter-doc `[[wikilinks]]`, and headings + body. //! //! Cargo compiles this into each test binary that declares `mod bench_util;`. -//! Only the benchmark uses it today; silence the per-binary dead-code lint. -#![allow(dead_code)] use std::fmt::Write as _; use std::path::Path; diff --git a/tests/integrity_benchmark.rs b/tests/integrity_benchmark.rs index 2c765f42..48f65e90 100644 --- a/tests/integrity_benchmark.rs +++ b/tests/integrity_benchmark.rs @@ -188,6 +188,13 @@ fn integrity_check_acceptance_50k() { std::fs::create_dir_all(&cache_home).unwrap(); std::fs::create_dir_all(&state_home).unwrap(); + // Point THIS process's cache home at the private tempdir too, so the + // in-process `cache_dir_for` call below resolves the same cache dir the + // spawned `norn` children use (they receive it via explicit `.env()`). + // Safe: this is the only test running under `--ignored`, and the + // bench_util unit tests never read XDG_CACHE_HOME. + std::env::set_var("XDG_CACHE_HOME", &cache_home); + // ---- Build the cache once (Fresh open: no integrity_check yet) ------- let build_start = Instant::now(); let build = run_norn(&cache_home, &state_home, vault, &["count"]); @@ -197,11 +204,16 @@ fn integrity_check_acceptance_50k() { build.integrity_markers, 0, "the Fresh-open build must NOT run integrity_check (no db existed yet)" ); - // The cache dir is vault-hash-derived under the home; walk to find it. - let cache_db_bytes = walk_find_cache_db(&cache_home) - .and_then(|p| p.metadata()) - .map(|m| m.len()) - .unwrap_or(0); + // Resolve the vault's cache.db with the SAME identity mapping production + // uses (the cache dir is vault-hash-derived under the home). A missing db + // here means the build above did not do what this harness thinks it did — + // fail loudly rather than reporting a 0-byte size. + let vault_utf8 = camino::Utf8Path::from_path(vault).expect("utf8 vault path"); + let cache_dir = norn_run::resolve_cache_dir(vault_utf8).expect("resolve cache dir for vault"); + let cache_db = cache_dir.join("cache.db"); + let cache_db_bytes = std::fs::metadata(cache_db.as_std_path()) + .unwrap_or_else(|e| panic!("cache.db must exist at {cache_db} after the build: {e}")) + .len(); // ---- Direct baseline: each call reopens → pays integrity_check ------- // This is ALSO the no-daemon regression guard (deliverable 3): a direct read @@ -291,14 +303,20 @@ fn integrity_check_acceptance_50k() { "every direct read must have paid integrity_check (no-daemon trust guard)" ); - // Supporting timing evidence (not the acceptance gate, but should hold with a - // wide margin because the warm path skips the O(db-size) check entirely). + // Supporting timing evidence only — NOT an acceptance gate. The structural + // marker assertions above are the gates; wall-clock is machine- and + // load-dependent (small NORN_BENCH_DOCS overrides, loaded CI machines), so + // a slower routed median WARNs into the --nocapture record instead of + // failing the run. let direct_count_med = direct_medians[0].1; let routed_count_med = routed_medians[0].1; - assert!( - routed_count_med < direct_count_med, - "warm routed count ({routed_count_med:?}) should be faster than direct ({direct_count_med:?})" - ); + if routed_count_med >= direct_count_med { + println!( + "WARN: routed warm count median ({routed_count_med:?}) was not faster than direct \ + ({direct_count_med:?}); timing is evidence only — the structural verify-once gates \ + above still held" + ); + } // ---- Evidence table -------------------------------------------------- println!("\n==================== NRN-83 integrity_check acceptance ===================="); @@ -326,24 +344,3 @@ fn integrity_check_acceptance_50k() { println!("routed reads served : {total_routed_calls} total across all shapes"); println!("==========================================================================\n"); } - -/// Best-effort walk to locate the vault's `cache.db` under a cache home (the -/// path is vault-hash-derived). Used only for the evidence table's size figure. -fn walk_find_cache_db(cache_home: &Path) -> std::io::Result { - fn recurse(dir: &Path) -> std::io::Result> { - for entry in std::fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); - if path.is_dir() { - if let Some(found) = recurse(&path)? { - return Ok(Some(found)); - } - } else if path.file_name().and_then(|s| s.to_str()) == Some("cache.db") { - return Ok(Some(path)); - } - } - Ok(None) - } - recurse(cache_home)? - .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "cache.db")) -} From 75768aa74f29b866033a5cbd127e4daedeb8a488 Mon Sep 17 00:00:00 2001 From: Drew Butler Date: Wed, 8 Jul 2026 14:54:43 -0400 Subject: [PATCH 4/5] refactor(bench): resolve cache dir without process-env mutation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The harness set XDG_CACHE_HOME via std::env::set_var so the in-process resolver would match the children's explicit .env() — reintroducing the process-global env-mutation class NRN-215 removed; under --include-ignored the benchmark runs concurrently with the bench_util unit tests, where set_var races libtest's own getenv on any variable. Make the resolution explicit instead: identity::cache_dir_for now delegates to a new cache_dir_in(cache_home, vault_root) — the same mapping as a pure function of its arguments — and the #[doc(hidden)] seam becomes resolve_cache_dir_in(cache_home, vault_root). The harness computes the path from the same private home value it already passes to children; zero env mutation anywhere in the test binary. --- src/cache.rs | 23 ++++++++++++++--------- src/cache/identity.rs | 16 ++++++++++++++-- src/lib.rs | 11 ++++++----- tests/integrity_benchmark.rs | 20 +++++++++----------- 4 files changed, 43 insertions(+), 27 deletions(-) diff --git a/src/cache.rs b/src/cache.rs index 84be4582..dbbcb9c9 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -44,16 +44,21 @@ pub(crate) use identity::{ vault_identity, vault_identity_hash, xdg_cache_home_env, }; -/// Resolve a vault's on-disk cache directory with the SAME identity mapping -/// production opens use (`identity::cache_dir_for`), returning just the cache -/// dir. Cross-crate test-harness seam: the NRN-83 acceptance benchmark locates -/// `cache.db` under a private `XDG_CACHE_HOME` through it instead of walking -/// the cache tree. The error is stringified so the crate-private `CacheError` -/// type does not leak into public visibility. Not part of the stable public -/// API — same posture as `Cache::conn`. +/// Resolve a vault's on-disk cache directory under an EXPLICIT cache home, +/// with the SAME identity mapping production opens use (`identity::cache_dir_in`, +/// which `cache_dir_for` delegates to after reading `XDG_CACHE_HOME`), returning +/// just the cache dir. Cross-crate test-harness seam: the NRN-83 acceptance +/// benchmark locates `cache.db` under a private cache home through it — a pure +/// function of its arguments, no process-env read or mutation anywhere. The +/// error is stringified so the crate-private `CacheError` type does not leak +/// into public visibility. Not part of the stable public API — same posture as +/// `Cache::conn`. #[doc(hidden)] -pub fn resolve_cache_dir(vault_root: &camino::Utf8Path) -> Result { - cache_dir_for(vault_root) +pub fn resolve_cache_dir_in( + cache_home: &camino::Utf8Path, + vault_root: &camino::Utf8Path, +) -> Result { + identity::cache_dir_in(cache_home, vault_root) .map(|(_canonical, dir)| dir) .map_err(|e| e.to_string()) } diff --git a/src/cache/identity.rs b/src/cache/identity.rs index a7ec03fb..31d05502 100644 --- a/src/cache/identity.rs +++ b/src/cache/identity.rs @@ -51,9 +51,21 @@ pub(crate) fn hex_lower(bytes: &[u8]) -> String { /// Format: `/norn//`, defaulting /// to `~/.cache/norn//` when `XDG_CACHE_HOME` is unset. pub fn cache_dir_for(vault_root: &Utf8Path) -> Result<(Utf8PathBuf, Utf8PathBuf), CacheError> { - let (canonical, hash) = vault_identity(vault_root)?; let base = xdg_cache_home()?; - let dir = base.join("norn").join(hash); + cache_dir_in(&base, vault_root) +} + +/// The same identity mapping as [`cache_dir_for`] with the cache-home base +/// passed explicitly instead of read from the environment — a pure function of +/// its arguments (plus the vault-root canonicalization). Test harnesses that +/// manage a private cache home resolve through this without any process-env +/// mutation. +pub(crate) fn cache_dir_in( + cache_home: &Utf8Path, + vault_root: &Utf8Path, +) -> Result<(Utf8PathBuf, Utf8PathBuf), CacheError> { + let (canonical, hash) = vault_identity(vault_root)?; + let dir = cache_home.join("norn").join(hash); Ok((canonical, dir)) } diff --git a/src/lib.rs b/src/lib.rs index b849f4de..b304fe15 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,11 +3,12 @@ mod apply_cmd; pub mod apply_report; mod audit; mod cache; -// The vault-root → cache-dir identity mapping, re-exported for test harnesses -// (the NRN-83 acceptance benchmark) that must locate a vault's cache.db under -// a private XDG_CACHE_HOME exactly the way production does. `#[doc(hidden)]` -// seam, not stable public API — see `cache::resolve_cache_dir`. -pub use cache::resolve_cache_dir; +// The (cache-home, vault-root) → cache-dir identity mapping, re-exported for +// test harnesses (the NRN-83 acceptance benchmark) that must locate a vault's +// cache.db under a private cache home exactly the way production does — pure +// function, no env read. `#[doc(hidden)]` seam, not stable public API — see +// `cache::resolve_cache_dir_in`. +pub use cache::resolve_cache_dir_in; mod cache_cmd; mod cli; mod completions; diff --git a/tests/integrity_benchmark.rs b/tests/integrity_benchmark.rs index 48f65e90..8fe074a1 100644 --- a/tests/integrity_benchmark.rs +++ b/tests/integrity_benchmark.rs @@ -188,13 +188,6 @@ fn integrity_check_acceptance_50k() { std::fs::create_dir_all(&cache_home).unwrap(); std::fs::create_dir_all(&state_home).unwrap(); - // Point THIS process's cache home at the private tempdir too, so the - // in-process `cache_dir_for` call below resolves the same cache dir the - // spawned `norn` children use (they receive it via explicit `.env()`). - // Safe: this is the only test running under `--ignored`, and the - // bench_util unit tests never read XDG_CACHE_HOME. - std::env::set_var("XDG_CACHE_HOME", &cache_home); - // ---- Build the cache once (Fresh open: no integrity_check yet) ------- let build_start = Instant::now(); let build = run_norn(&cache_home, &state_home, vault, &["count"]); @@ -205,11 +198,16 @@ fn integrity_check_acceptance_50k() { "the Fresh-open build must NOT run integrity_check (no db existed yet)" ); // Resolve the vault's cache.db with the SAME identity mapping production - // uses (the cache dir is vault-hash-derived under the home). A missing db - // here means the build above did not do what this harness thinks it did — - // fail loudly rather than reporting a 0-byte size. + // uses (the cache dir is vault-hash-derived under the home), passing the + // private cache home EXPLICITLY — a pure function of the same value the + // spawned children receive via `.env()`, with zero process-env mutation in + // this test binary. A missing db here means the build above did not do + // what this harness thinks it did — fail loudly rather than reporting a + // 0-byte size. let vault_utf8 = camino::Utf8Path::from_path(vault).expect("utf8 vault path"); - let cache_dir = norn_run::resolve_cache_dir(vault_utf8).expect("resolve cache dir for vault"); + let cache_home_utf8 = camino::Utf8Path::from_path(&cache_home).expect("utf8 cache home"); + let cache_dir = norn_run::resolve_cache_dir_in(cache_home_utf8, vault_utf8) + .expect("resolve cache dir for vault"); let cache_db = cache_dir.join("cache.db"); let cache_db_bytes = std::fs::metadata(cache_db.as_std_path()) .unwrap_or_else(|e| panic!("cache.db must exist at {cache_db} after the build: {e}")) From 9e66976d2fd3d832b6b92180738bd4c125fc44d5 Mon Sep 17 00:00:00 2001 From: Drew Butler Date: Wed, 8 Jul 2026 14:55:43 -0400 Subject: [PATCH 5/5] chore: adversarial review record for NRN-83 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review rounds (engine=code-review, agents pinned to Opus): 7 findings — 5 fixed (trace-hook contract documented with the release-availability position, timing assert demoted to evidence, cache-dir resolution via explicit-home pure function replacing both the recursive walk and a set_var UB reintroduction, blanket dead_code allow removed), 2 dismissed (suppression-scan prose false positive; the benchmark's #[ignore] is the harness design — explicitly-run acceptance gate, not suite erosion). Adversarial-Review: run engine=code-review tier=high findings=7 fixed=5 dismissed=2 deferred=0