Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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 `<XDG_CACHE_HOME>/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
Expand Down
19 changes: 19 additions & 0 deletions src/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,25 @@ 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 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_in(
cache_home: &camino::Utf8Path,
vault_root: &camino::Utf8Path,
) -> Result<camino::Utf8PathBuf, String> {
identity::cache_dir_in(cache_home, 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};

Expand Down
16 changes: 14 additions & 2 deletions src/cache/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,21 @@ pub(crate) fn hex_lower(bytes: &[u8]) -> String {
/// Format: `<XDG_CACHE_HOME>/norn/<sha256-of-canonical-root>/`, defaulting
/// to `~/.cache/norn/<hash>/` 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))
}

Expand Down
26 changes: 26 additions & 0 deletions src/cache/open.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,32 @@ 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.
//
// 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");
}
let integrity: Result<String, _> = conn.query_row("PRAGMA integrity_check", [], |r| r.get(0));
match integrity {
Ok(s) if s == "ok" => {}
Expand Down
6 changes: 6 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ mod apply_cmd;
pub mod apply_report;
mod audit;
mod cache;
// 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;
Expand Down
139 changes: 139 additions & 0 deletions tests/bench_util/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
//! 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;`.

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"
);
}
}
Loading
Loading