Skip to content

Releases: dbtlr/norn

v0.48.3 - 2026-08-25

Choose a tag to compare

@github-actions github-actions released this 25 Aug 19:20
49c94e0

Release Notes

Release theme: Norn's public agent skill now installs through the skills CLI and teaches the current command, configuration, repair, and apply contracts. This patch also refreshes stable-line Rust dependencies.

Changed

  • The norn skill now installs through the skills CLI. Its package lives at skills/norn, so npx skills add dbtlr/norn --skill norn selects and installs the public skill without installing repository-maintenance skills. The trigger now covers explicit norn requests and configured Markdown vaults in the current directory or at a known -C path. The skill also teaches the current Standards pack shape, MigrationPlan schema v2, and every agent-facing command.

  • Refreshed all semver-compatible Rust dependencies. This updates the test-only h2 crate from 0.4.14 to 0.4.19 and resolves RUSTSEC-2026-0258.

Fixed

  • norn repair --help and norn apply --help now describe MigrationPlan schema v2. The help no longer teaches the retired PlannedChange envelope or the removed --verify flag. It now covers owner-set preconditions, create-path resolution, operation preconditions, and separate post-apply validation.

Install norn-run 0.48.3

Install prebuilt binaries via shell script

curl --proto '=https' --tlsv1.2 -LsSf https://github.com/dbtlr/norn/releases/download/v0.48.3/norn-run-installer.sh | sh

Download norn-run 0.48.3

File Platform Checksum
norn-run-aarch64-apple-darwin.tar.xz Apple Silicon macOS checksum
norn-run-x86_64-apple-darwin.tar.xz Intel macOS checksum
norn-run-aarch64-unknown-linux-musl.tar.xz ARM64 MUSL Linux checksum
norn-run-x86_64-unknown-linux-musl.tar.xz x64 MUSL Linux checksum

v0.48.2 - 2026-07-24

Choose a tag to compare

@github-actions github-actions released this 25 Jul 04:01
15d24d0

Release Notes

Release theme: routed writes survive big cache rebuilds — the stall watchdog now sees real progress during long refreshes instead of abandoning healthy writes.

Fixed

  • Routed writes against a large vault no longer false-stall while the daemon rebuilds or refreshes its cache (NRN-465). A routed set / new / edit against a norn serve daemon could fail with service stopped responding or making writer progress — run \norn service restart`; the daemon may have applied the change(exit 1,post-send-uncertain) even though the daemon was healthy — leaving the write *applied on disk but never acknowledged to the client*. The cause: the client stall watchdog declares a busy writer wedged when its opaque progress sequencestays unchanged for the five-second stall budget, but that sequence only advanced at op boundaries — and a freshness refresh reparses the WHOLE vault on any single content change as one indivisible op. On a large vault (tens of thousands of docs) that reparse froze the sequence for several seconds, tripping the budget while the daemon was actively working. The refresh, rebuild, and generation-open liveness ops now advance the sequence from inside their real work loops (per batch of files parsed / hashed / staged, and per bounded cache-write-lock retry), so the client waits through a legitimately long rebuild instead of abandoning it. Every tick is evidence of actual work — never timer-driven — so a genuinely wedged daemon is still caught. A new undocumentedNORN_SERVICE_STALL_BUDGET_MSclient env override (mirroringNORN_SERVICE_HANDSHAKE_TIMEOUT_MS`) shrinks or grows the stall budget for deterministic tests and as an operator escape hatch.

Install norn-run 0.48.2

Install prebuilt binaries via shell script

curl --proto '=https' --tlsv1.2 -LsSf https://github.com/dbtlr/norn/releases/download/v0.48.2/norn-run-installer.sh | sh

Download norn-run 0.48.2

File Platform Checksum
norn-run-aarch64-apple-darwin.tar.xz Apple Silicon macOS checksum
norn-run-x86_64-apple-darwin.tar.xz Intel macOS checksum
norn-run-aarch64-unknown-linux-musl.tar.xz ARM64 MUSL Linux checksum
norn-run-x86_64-unknown-linux-musl.tar.xz x64 MUSL Linux checksum

v0.48.1 - 2026-07-18

Choose a tag to compare

@github-actions github-actions released this 18 Jul 20:01
4a3772f

Release Notes

Release theme: the daemon heals itself — the shared host daemon no longer wedges at the fd limit under many-vault workloads, and a poisoned daemon exits to respawn fresh instead of requiring a manual restart. Also ships the detached GC sweep that had landed on main unreleased.

Changed

  • The cross-vault cache GC sweep now runs detached, off the request path, and the norn serve daemon triggers it too (NRN-287). Routine commands (norn new, find, set, …) no longer pay the cache/state garbage-collection crawl inline: when the 24h throttle marker is stale or absent the command touches the marker and spawns a fire-and-forget detached child (a hidden norn cache sweep) that runs the sweep in the background, then returns immediately. At real-world scale (tens of thousands of leaked cache entries) the crawl could bill a multi-minute stall to an otherwise instant command; that stall is gone. The marker is touched at spawn time as a stampede guard, so a crashed or killed sweep can't make every subsequent command retry the crawl. Both MCP surfaces now trigger the GC server-side once per request behind the same throttle — the warm norn serve daemon, which previously never swept, and the stdio norn mcp server — with steady-state per-request cost of a single stat. The sweep child takes an advisory lock so at most one sweep runs at a time (a second spawner no-ops), loads the invoking vault's cache config itself (a prune: manual vault still spawns at most one throttled probe that exits without sweeping), and is silent. Caveat: an explicit non-default --config passed to the norn serve daemon or norn mcp server does not propagate to the detached sweep — the warm daemon retains only the parsed config, not its source path — so the child re-discovers config from the vault's default .norn/config.yaml plus environment and cwd; env- and cwd-discovered config still reach it, but a custom config file path does not. The previous inline warn: cache prune skipped N locked entries stderr line is dropped: the detached child has no user terminal to write it to. Explicit norn cache prune is unchanged and still runs synchronously in the foreground. Fresh cache creation now holds the per-vault entry lock across the create (previously lock-free), closing a multi-vault race where a sweep triggered by one vault could delete a sibling vault's cache while the daemon was still building it — which surfaced as a spurious "unable to open database file" / "No such file or directory" on the concurrent request; the sweep already skips a locked entry, so the create now presents that same liveness signal. norn cache clear — which holds that same entry lock across its removal — now retries its remove_dir_all on a transient Directory not empty (ENOTEMPTY): clear's own removal briefly unlinks the entry .lock partway through the walk, so a concurrently-triggered sweep child could recreate a file inside a subdir clear had already emptied and fail the follow-up rmdir; a short bounded retry closes that window (the observed CI flake in cache clear).

Fixed

  • The single norn serve daemon no longer leaks file descriptors until it wedges every vault; it now bounds retained per-vault contexts and exits to self-heal on a poisoned state (NRN-337). The v0.48 one-daemon-per-host model held every served vault's warm context — and its ~7 file descriptors (the entry .lock, the cache db + WAL + SHM per pooled connection, the write connection, and the sentinel) — open forever. A workload that touches many distinct vaults (fixture generation, oracle-parity runs) would climb past the process fd limit (256 on macOS by default) after ~30 distinct vaults, after which every routed operation against any vault failed with io error at ~/.cache/norn/<hash>/.lock: Too many open files (os error 24) until a human ran norn service restart — a shared-daemon blast radius across unrelated projects. Three changes fix it:
    • Bounded entry retention. The daemon now caps the number of retained idle warm contexts (24) and evicts the least-recently-used at each new vault open, closing all of an evicted context's descriptors (read pool, write connection, sentinel; the writer thread is joined off the async workers). Actively-used contexts — any with an open connection or in-flight request — are never evicted and don't count against thrash; the cap bounds idle retention only, so fd usage is bounded regardless of how many distinct vaults are served. Eviction is strictly safe: a context is evicted only when nothing holds it — no in-flight request, no in-flight open, and a drained writer queue — checked atomically under the context-map lock, so a request never observes a torn context. A later request for an evicted vault transparently reopens it (the daemon already re-proves freshness per request, so a reopened context is safe by the existing path).
    • Fail-closed self-heal. When the daemon hits a state it cannot recover from in place — file-descriptor exhaustion (EMFILE/ENFILE) anywhere in the serve path, including at accept, or a SQLite "cannot open database file" for a vault that was already serving (the NRN-325 shape) — it answers the in-flight request with the error, logs one line, and exits cleanly. The next client invocation's existing spawn-on-absent-daemon path brings up a fresh daemon with an empty fd table; exit is the heal, with no in-daemon retry loop. The poisoned-state predicate is conservative and explicit (a matcher on the fd-exhaustion errnos plus the specific SQLite cannot-open code), so an ordinary per-vault error — a bad query, or a corrupt cache, which is still self-healed in place by the generational reopen — never takes the shared daemon down.
    • Observability. norn service status gains an open-entries count (and the daemon's total open-fd count when cheaply obtainable) in both the text report and --format json — additive fields, existing output unchanged.
      As a supporting fix, a config-file read error now preserves its underlying std::io::Error in the error chain (instead of stringifying it), so an EMFILE surfacing at the daemon's first fd-consuming step on a hello stays classifiable by the self-heal.

Install norn-run 0.48.1

Install prebuilt binaries via shell script

curl --proto '=https' --tlsv1.2 -LsSf https://github.com/dbtlr/norn/releases/download/v0.48.1/norn-run-installer.sh | sh

Download norn-run 0.48.1

File Platform Checksum
norn-run-aarch64-apple-darwin.tar.xz Apple Silicon macOS checksum
norn-run-x86_64-apple-darwin.tar.xz Intel macOS checksum
norn-run-aarch64-unknown-linux-musl.tar.xz ARM64 MUSL Linux checksum
norn-run-x86_64-unknown-linux-musl.tar.xz x64 MUSL Linux checksum

v0.48.0 - 2026-07-16

Choose a tag to compare

@github-actions github-actions released this 16 Jul 17:00
159e6cd

Release Notes

Release theme: the cache you can trust — the serve daemon becomes truly concurrent (a read pool over WAL snapshots, a two-class writer queue, atomic cache publication), direct reads get ~3× faster by moving the integrity scan off the hot path, and the cache's on-disk identity becomes channel- and schema-qualified so no binary — dev build, newer version, or downgrade — can ever corrupt, migrate, or lock another binary out of its cache. The cache clear escape hatch now works against any broken cache state.

Breaking changes

  • The document .raw structural facet is removed from find and get. Requesting --col .raw now follows the existing unknown-facet warning path and never emits a raw records field or JSON/JSONL key; MCP vault.find / vault.get callers must likewise remove .raw from col requests. To read one document byte-for-byte, migrate to norn get TARGET --format markdown or vault.get { targets: ["TARGET"], format: "markdown" }. Markdown remains single-document only; there is no multi-document replacement.
  • MigrationPlan is now schema v2 and ApplyReport is now schema v3 (NRN-264, ADR 0015). Plans gain first-class preconditions; reports gain first-class precondition results. Persisted v1 plans must be regenerated, and report consumers must accept the v3 preconditions array. There is no compatibility shim in the pre-1.0 break window.

Added

  • The cache database is now channel-isolated so a dev build can't corrupt an installed binary's cache (NRN-269). A binary run from a cargo build tree (cargo run, cargo test, a freshly-built target/…/norn) resolves to the dev channel and keeps its database at ~/.cache/norn/<hash>/dev/v5/cache.db; the installed binary stays on the live channel at ~/.cache/norn/<hash>/v5/cache.db (the v5 schema segment is added by NRN-286, below). A dev binary can therefore no longer read, migrate, invalidate, or overwrite the live cache — closing the incident where a dev build's older-schema silent rebuild migrated the live per-vault cache to a new schema and locked out the installed client (that upgrade-required lockout is itself retired by NRN-286, below). The channel is detected automatically (dev iff any ancestor of the executable carries a cargo CACHEDIR.TAG, covering custom CARGO_TARGET_DIR locations; detection failures fail toward the isolated dev channel) and can be forced with the new NORN_CACHE_CHANNEL environment variable (live or dev; any other non-empty value is a hard error, empty counts as unset). Only the database moves: the per-vault write lock (<hash>/.lock) and vault-level state stay shared, so dev and live binaries mutating the same vault still serialize against each other. norn cache status gains a channel: line (and a channel field in --format json).
  • Stale dev-channel caches now reclaim themselves on their own TTL (NRN-272). The cross-vault prune sweep evicts a stale dev-channel database (NRN-269) whose newest mtime has aged past a 48h window, removing only that database — the live database, the shared <hash>/.lock, and the entry itself are untouched. Because the sweep's eviction unit is otherwise the whole entry and entry freshness spans both channels, an abandoned dev build (~29MB per vault) inside a still-active live entry previously never aged out; it now does, with zero manual intervention. Eviction takes the same shared entry lock a whole-entry eviction takes, so an in-flight dev writer blocks it, and a now dev-only entry falls through to the existing empty-entry reclamation. NRN-286 (below) generalizes this pass to every non-current-schema and legacy database — including inside the invoking vault's own entry, where it protects only the database that invocation is actively using — and the prune reason is stale-db.
  • Exact Markdown is now available through the routed CLI and MCP vault.get path (NRN-256). norn get TARGET --format markdown no longer forces Direct when a live norn serve daemon is available: the daemon resolves the single document, reads its current file from disk, and returns the exact UTF-8 content for byte-faithful stdout (including no final newline). MCP clients can request the same representation with vault.get { targets: ["TARGET"], format: "markdown" }; the response uses a dedicated markdown: { path, content } envelope rather than adding a .raw/.source facet or mixing source into structured document records. Markdown requires exactly one selected document: zero or multiple selections are clear errors, and a partial resolution that emits one good document plus a missing-target note exits nonzero on both direct and routed paths. Inert --section no longer blocks Markdown routing. Markdown is not cached.
  • Atomic logical-owner preconditions close planning-to-apply identity races (NRN-264, ADR 0015). A plan can assert the exact current owner-path set for a static stem, an exact frontmatter equality selector, or the concrete stem resolved from a named create_document operation (including {{seq}}). Norn resolves every create path, evaluates all owner sets once under the existing per-vault mutation lock, and refuses before any operation runs when an owner appears, disappears, moves, or conflicts with another create in the same plan. Refusals use stable owner-set-mismatch / owner-claim-conflict codes, sorted expected/actual paths, outcome: "refused", and not-run for every operation. Every pre-write barrier — vault-root containment, create-path resolution, and owner-precondition validation — crosses the routed daemon/MCP surface as a coded, report-shaped refusal (never an opaque transport error), and the routed CLI renders the full refused report including preconditions[] and honors --out, byte-for-byte with the direct path. The eq selector matches find --eq's scalar equality — wikilink brackets are stripped from strings and numbers compare numerically, so 2 matches a stored 2.0.
  • norn service status --vault <PATH> now reports per-vault control-plane state (NRN-254, ADR 0013). The vault-scoped control pong carries serving (cold, opening, or ready) and writer_progress (busy plus an opaque monotonic sequence), so operators and the forthcoming client wait policy can distinguish a responsive idle writer from busy work that is or is not advancing. Writer progress advances across generation/liveness transitions, bulk chunk boundaries, and terminal completion/drop/panic; observing a cold vault does not open it. Plain norn service status remains host-level. Scoped text adds vault/serving/writer lines, while JSON adds a vault object containing the canonical root and the two signals.

Changed

  • Cache databases are now stored per schema version (NRN-286). The database path gains a v<schema> segment — ~/.cache/norn/<hash>/v5/cache.db on the live channel, ~/.cache/norn/<hash>/dev/v5/cache.db on dev — so the schema version is part of the database's on-disk identity and a binary only ever opens the db in its own schema directory. The shared per-vault write lock (<hash>/.lock) stays where it was, unchanged. The prune sweep's 48h dev-TTL pass generalizes to every database location under an entry except the live current-schema one: stale-schema databases (v<N>/ for a non-current N, left behind by a newer or older binary) and legacy bare cache.db files at a channel root (the pre-segment layout) are now garbage-collected on the same 48h clock, reported with a stale-db reason (renamed from dev-stale). An existing bare cache.db is left byte-identical and ages out this way; the new binary builds v5/cache.db beside it.
  • Direct CLI reads no longer pay a full cache integrity scan on every invocation (NRN-275). Opening an existing cache used to run PRAGMA integrity_check — an O(database-size) scan of the whole file — on every norn find/count/get/set/validate invocation, ~34ms of a direct call against a 29MB cache and often more than the query it guarded. Against ~/vaults/atlas this cut a warm find from ~48ms to ~16ms and count from ~55ms to ~23ms. The direct read path now opens trusting and skips that scan; the stdio norn mcp server, which opens a fresh cache per call like the direct CLI, moves to the same trusting open. Trust is not dropped, only relocated: the cache is a rebuildable derived artifact, so byte corruption (surfacing as SQLITE_CORRUPT/SQLITE_NOTADB) triggers an evict-and-rebuild-from-the-vault. Corruption a refresh/index read hits is healed in the same invocation (retried once; a second corruption fails closed rather than looping); corruption that only a later query touches fails that command closed and is evicted at the CLI error seam so the next invocation opens fresh and rebuilds. Gross corruption that leaves the database header unreadable is still caught at open and rebuilt. The full integrity_check is unchanged where verification is the point: the norn serve daemon still verifies once per generation on open (routed reads inherit that verified state), norn cache status still runs it as an operator deep check, and it still runs when the cache subcommands open a database. Every corruption rebuild is operator-visible on stderr with a vault: cache is corrupted; … notice, as are the existing schema/identity/links.alias_field config-drift rebuilds.
  • Daemon post-mutation cache updates now publish atomically instead of exposing chunk-by-chunk intermediate state (NRN-256, ADR 0014). The preemptible bulk phase writes job-scoped, connection-local TEMP staging rows for every affected document-owned table and the complete globally resolved links set; readers continue to see the full old cache through every staging boundary. One terminal BEGIN IMMEDIATE transaction switches all affected rows and links to the new snapshot together. Publication authority is reserved before the whole-vault parse...
Read more

v0.47.0 - 2026-07-11

Choose a tag to compare

@github-actions github-actions released this 12 Jul 00:13
1033ec5

Release Notes

This release closes the CLI→daemon routing arc: every read and every mutation now routes through a live norn serve daemon byte-identically to direct execution, the routing gate is future-proofed with an exact build-fingerprint match, refusal codes converge on one vocabulary across validate/set/new and the cascade tools, and self-update no longer orphans a loaded daemon. The norn service supervisor and the operator routing contract are documented in docs/service.md.

Breaking changes

  • vault.set's set/push/pop params are now ordered KEY=JSON/KEY=VALUE token lists, matching vault.new — and the routed CLI shapes that forced Direct because of it now route (NRN-238, breaking). set: BTreeMap<String, Value> is retired in favor of field_json: Vec<String> (the same name, semantic, and doc voice as vault.new's field_json); push: BTreeMap<String, Value> and pop: BTreeMap<String, Value> become push: Vec<String> / pop: Vec<String> of raw KEY=VALUE tokens. Each list is applied in order and fed straight into the shared set::synth/validate seam the CLI's --field-json/--push/--pop flags use, so a key repeated across tokens accumulates into an array (or pushes/pops each value in turn) exactly as repeating the CLI flag does — the one semantic a sorted, unique-key map could never express (a duplicate key silently collapsed to one entry, and a map's key order is never the argv order). Because the wire shape is now order-preserving, --field-json / --push / --pop are un-gated from try_route_set (src/lib.rs) and now route through the warm norn serve daemon byte-identically, joining --field / positionals / --remove / --force; only --body-from-stdin still runs Direct (no wire-faithful stdin analogue — an MCP server has no stdin). One behavior inside the break: the old map façade refused an object/nested-array push/pop value; raw tokens carry no JSON at all, so a JSON-shaped string now lands as a literal list element — exactly what the CLI has always done with --push 'key={"a":1}' (ADR 0009 parity; the CLI is the semantic of record). No compatibility shim — pre-1.0, and stale released daemons are already excluded from routing by the exact-version handshake gate, so this is a clean wire break: an MCP client written against the old set/push/pop map params must migrate to the token-list shape. Documented in docs/mcp-server.md.
  • Two norn validate finding codes are renamed to converge on norn set's refusal vocabulary — one semantic, one code across both surfaces (NRN-235, breaking). frontmatter-disallowed-value is now value-not-allowed, and frontmatter-invalid-type is now field-type-invalid, matching the codes set/new already emit (NRN-221/230) for the identical schema condition (an existing on-disk value vs. an incoming one failing the same allowed_values/field_types check). An agent that validates then remediates now keys one branch table instead of two vocabularies for the same fault. No compatibility shim — pre-1.0, this is a clean rename everywhere the code appears: norn validate output (--format json/jsonl/records, --code filtering), the fix-hint lookup, norn repair --plan's skipped[].finding_code, and norn new's dry-run/apply warnings (same finding constructors, so the rename is automatic there too). Config-facing consequence: any repair.rules[].match.code written against the old names must be updated to value-not-allowed / field-type-invalid, as must any --code filter scripted against them — including glob filters: both codes leave the frontmatter-* family, so a gate scripted as --code 'frontmatter-*' no longer matches these two conditions and must add the new names (or the value-*/field-* families) explicitly; the shipped default .norn/config.yaml example is updated. Documented in docs/validation.md, docs/configuration.md, docs/rule-shape.md, docs/errors.md, and integrations/agent-skill/SKILL.md.

Added

  • The integrity-check acceptance benchmark now covers routed WRITES, not just reads (NRN-232). tests/integrity_benchmark.rs (NRN-83) extends its single daemon lifetime with a mutation phase: after the existing direct-vs-routed count/find/get proof, a direct set --field bench_status=<value> --yes baseline runs before the daemon spawns (still paying integrity_check — trust preserved on the no-daemon write path, though structurally TWICE per call, not once, because the direct set dispatch opens the cache separately for its GraphIndex and its query Cache — the harness hard-pins the value TWO per call, so drift in either direction, a third open or the two opens collapsing into one, fails loudly), then the same daemon that served the routed reads also serves iters routed set calls against a distinct target doc, followed by one routed get proving the final iteration's value actually landed on disk through the daemon. The acceptance criterion is unchanged in kind, widened in scope: across ALL routed traffic — reads, writes, and the post-apply verification read, in one daemon lifetime — the daemon's stderr still carries exactly ONE integrity_check marker, cross-checked against served vault.set/served vault.get counts so the result cannot pass vacuously. Confirmed at 50k-doc scale: the routed daemon pays the check once across 21 combined routed calls while direct reads and writes each keep paying their own per-call constant. The evidence table gains a set row (direct vs routed-warm median, same skip-first-call convention). Run it with cargo test --release --test integrity_benchmark -- --ignored --nocapture. Dev-facing only — no runtime behavior change on any default path.
  • docs/service.md now documents the norn service launchd supervisor and the CLI's daemon-routing contract (NRN-225). Two arcs had landed without doc coverage: the install/uninstall/start/stop/restart/status verbs (NRN-115) and the completed CLI→daemon routing seam — every read and every mutation now routes through a live, version- and build-matched norn serve daemon when one is reachable, byte-identically to running direct. The page's two stale claims ("supervisor verbs don't exist yet" and "the CLI does not yet route its own reads") are gone.

Changed

  • CLI→daemon routing now requires an exact build-fingerprint match, not just an exact version match — closing the same-version dev-build skew hole (NRN-247). The handshake already gated on CARGO_PKG_VERSION, so a released stale daemon never served; but two builds of the same 0.x version can carry different wire schemas (this happened live — a stale installed daemon rendered additive ApplyReport fields as zeros), and the version gate was blind to it. The daemon's control-pong now carries a build fingerprint — a blake3 hash of the crate's src/ tree plus Cargo.lock, emitted at build time as NORN_BUILD_ID — and the routing gate requires it to match the client's exactly. Any mismatch — including a pong with no build field at all (a daemon predating the fingerprint) — falls back to Direct silently, with one operator stderr line (service is a different build of v<version> — restart the norn serve daemon), the same treatment version skew already gets; the gate order is version, then build, then protocol, so a version upgrade still reports as version skew. norn service status likewise compares build fingerprints, so a same-version rebuild now shows restart pending (rebuilt) where the two version strings alone would look identical. The fingerprint is source-content-derived, not a timestamp: an unchanged rebuild reproduces the same id and keeps routing, while any source or lockfile change mints a new one. The upshot is that wire-schema growth no longer needs any per-field or protocol-bump compatibility policy — a changed schema is a changed build, and a changed build doesn't route. The cost, plainly: after you rebuild norn, the still-running daemon stops serving requests (silent Direct fallback) until you norn service restart.
  • norn set now routes through the warm norn serve daemon when one is live, byte-identically — the first routed mutation, extending the NRN-94/222 read-routing seam to writes (NRN-229). A dry-run/preview (--dry-run, or --format json without --yes, or the non-TTY implicit-preview path) and a non-interactive apply (--yes) are served from the daemon's already-verified warm cache when one is reachable, producing output byte-for-byte identical to direct execution (stdout, stderr, and exit code — the telemetry trace_id aside, which is non-deterministic on the direct path too) and the same on-disk result. With no daemon, set runs the direct path exactly as before. A routed apply runs under the send-commit policy (NRN-228): a failure before the request reaches the daemon falls back to a direct re-run, while a failure after send surfaces post-send-uncertain (exit 1) rather than risk a double-apply. The routable surface is target + --field / trailing KEY=VALUE positionals + --remove + --force; --field-json, --push, --pop, and --body-from-stdin stay Direct for now (their wire mapping is not yet order-preserving), as does the interactive TTY confirm prompt — and, exactly like routed reads, an explicit --config or --no-cache-refresh still runs direct (the wire speaks canonical vault roots only; the daemon serves each vault's own config from a refreshed cache). No new public MCP surface — routing reuses the existing vault.set tool.
  • norn edit now routes through the warm norn serve daemon too, on the same terms as set (NRN-229). The mode mapping (--dry-run / --yes / --format json without --yes / non-TTY implicit preview / interactive TTY stays Direct), the --config / --no-cache-refresh force-Direct rule, and the send-commit policy are identical to set's. Unlike set, eve...
Read more

v0.46.0 - 2026-07-08

Choose a tag to compare

@github-actions github-actions released this 08 Jul 19:14
1a8b566

Release Notes

The daemon-reads-complete release. Phase 1 of the norn-service initiative exits: every read command (count/find/get, canonical and dynamic-predicate spellings alike) routes through the warm norn serve daemon when one is live, the nine graph-index tools build from the daemon's verified-once connection, daemon-side operator notes reach the routed caller, and the new norn service verbs adopt the daemon under launchd so it is always warm. The initiative's founding bug — PRAGMA integrity_check paid per invocation, O(db-size) — is closed on committed structural evidence at 50k-doc scale (one check per daemon lifetime vs one per call direct; trust never weakened, per ADR 0005).

Breaking changes

  • norn mcp --read-only is removed (NRN-116). The flag, its McpArgs.read_only field, and the server-side gating (dropping the 7 mutation tools from tools/list and refusing them at runtime) are gone; norn mcp now always serves the full 14-tool catalog, exactly like norn serve always has. No migration shim — a client invoking norn mcp --read-only gets clap's unknown-argument error. Read-only was deliverable-phasing scaffolding from the original MCP build-out (NRN-33); no external consumer depends on it, and keeping it risked the stdio and socket MCP surfaces drifting in capability. Per-connection capability scoping, if ever wanted, belongs to the future phase-4 authn/authz surface, not this flag.

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

  • Dynamic-field predicates (find --type note, count --status active) now route through the warm norn serve daemon, closing the ergonomic-spelling slowdown (NRN-218). Previously an ADR 0010 forgiving-input predicate forced Direct execution — the field-universe gate (NRN-207) that validates the desugared field name needed a cache the warm daemon did not expose, so the more ergonomic spelling was systematically slower than the canonical --eq type:note (which already routed since NRN-222). The gate now runs daemon-side against the warm cache: a dynamic-predicate find/count routes as-is, the daemon validates the desugared field name and — for an unknown field — returns the byte-identical error the direct gate would emit (same stderr, same exit 1), served in one round trip with no fall-back to a second direct execution. A KNOWN dynamic field routes exactly like its canonical --eq form, matching that spelling's warm latency; an unknown field is refused daemon-side and re-emitted verbatim by the CLI. Direct (non-daemon) invocations are unchanged — the gate still runs on the local cache open — and a dynamic-predicate routed read under lock contention still forwards the daemon's operator note (NRN-215) ahead of the refusal. Wire (additive, pre-1.0, private): the desugared field names cross to the daemon as a dynamic_keys argument on vault.find/vault.count, and a gate refusal crosses back as a dynamic_field_error sibling in structuredContent; both are a private norn-CLI↔norn-daemon channel (#[schemars(skip)]) — they are absent from the published input/output schemas and tools/list, so no new public MCP surface and no new tool (an off-filesystem client filters with canonical eq/in predicates and never arms the gate). No existing field changes meaning. This supersedes the v0.45.0 note that a dynamic-predicate invocation runs Direct.

  • The warm norn serve verify-once win now covers the graph-index tools, not just reads (NRN-130). The nine MCP tools that reconstruct a GraphIndexvault.validate, vault.repair, vault.set, vault.edit, vault.delete, vault.move, vault.rewrite_wikilink, vault.apply, vault.new — previously cold-opened a fresh cache (re-paying PRAGMA integrity_check) on every call even when the request was served by the warm daemon; the verify-once latency win reached only the query_cache-based reads (count since NRN-94, find/get since NRN-222). These tools now build their graph index through the daemon's held-open, already-verified connection via a new VaultContext::load_graph_index seam, running the same per-request self-heal pipeline the warm reads get (root-liveness, config-freshness reparse/hot-swap, out-of-band ground-shift stat check, and the incremental refresh) — so a warm graph-index build is verify-once yet produces a GraphIndex structurally identical to a cold fresh open on the same vault state. (vault.new also stops re-reading config from disk mid-request; it now uses the same request-stable config every other tool gets.) Trust posture: this extends the daemon's ADR-0005 verify-once trade-off — previously carried only by the reads — to graph-index construction, which feeds mutation planning: integrity_check runs at open (and on every self-heal reopen), not per call, so in-place corruption of cache.db is detected by open-time verification plus error-time eviction (any SQLite corruption-class error drops the warm state; the next request fully reopens and re-verifies), not by a per-call recheck. The source of truth remains the Markdown files. Every cold open still pays the full integrity check, the first touch of a vault still verifies once, and an index-relevant config change (files.ignore / alias_field / index-set) still forces a full reopen. Mutation semantics are untouched (write-lock acquisition, plan/apply flow, and per-context ...

Read more

v0.45.1 - 2026-07-07

Choose a tag to compare

@github-actions github-actions released this 07 Jul 15:41
80b22e5

Release Notes

A point release hardening the MCP write signal: a mutation tool result now tells the truth about whether the write landed, so a consumer trusting the protocol-native isError bit can no longer read a no-write or a half-write as success.

Fixed

  • MCP mutations that don't fully apply now report isError: true instead of masquerading as success (NRN-219). Over MCP, vault.apply / vault.move / vault.delete / vault.rewrite_wikilink returned isError: false for every outcome — a completed apply, a byte-identical pre-flight refusal (a CAS expected_old_value / stale-hash mismatch, nothing written), and a partial-apply failure alike — so the only signal distinguishing them was the in-band structuredContent.report.outcome. A consumer trusting the protocol-native isError bit (the MCP client raises on it) treated a no-write or a half-write as a successful mutation; an optimistic-concurrency retry loop keyed on a thrown error silently swallowed a lost update. A confirm call on these four tools now sets isError: true whenever the report's outcome is not applied (i.e. refused or failed), while preserving the full structuredContent.report so a consumer still branches on operations[].error.code (retryable CAS drift vs terminal refusal) — the machine-readable code is not laundered back into prose. A dry-run preview (confirm: false) stays isError: false even when it forecasts a refusal: it attempts no write, so it cannot misreport one, and an SDK that raises on isError must not throw on a preview — the forecast is carried as outcome: refused / dry_run: true in the (still-preserved) structured report. vault.set / vault.edit / vault.new were unaffected (they already surface an apply failure as an MCP error).

Install norn-run 0.45.1

Install prebuilt binaries via shell script

curl --proto '=https' --tlsv1.2 -LsSf https://github.com/dbtlr/norn/releases/download/v0.45.1/norn-run-installer.sh | sh

Download norn-run 0.45.1

File Platform Checksum
norn-run-aarch64-apple-darwin.tar.xz Apple Silicon macOS checksum
norn-run-x86_64-apple-darwin.tar.xz Intel macOS checksum
norn-run-aarch64-unknown-linux-musl.tar.xz ARM64 MUSL Linux checksum
norn-run-x86_64-unknown-linux-musl.tar.xz x64 MUSL Linux checksum

v0.45.0 - 2026-07-07

Choose a tag to compare

@github-actions github-actions released this 07 Jul 13:51
51f1e70

Release Notes

MCP becomes a true peer of the CLI. This release closes the CLI↔MCP parity gap (enforced now by a CI ratchet), routes norn count through the warm daemon byte-identically, hardens the mutation stack (vault-root containment, crash-atomic cascades, a machine-branchable error/outcome taxonomy), teaches the query and mutate grammars to forgive the spellings agents actually type (ADR 0010), and converges the CLI verb / MCP tool vocabulary onto one set of names with canonical kebab wire values. Pre-1.0, the breaking changes below land without shims — coordinate the MCP-wire breaks with any live consumer.

Breaking changes

  • CLI verbs and MCP tools now converge on one vocabulary, and code/enum VALUES on the wire are canonically kebab-case — one coordinated wire break so a consumer updates once (NRN-185, NRN-190). No back-compat aliases (pre-1.0). Full wire-delta, old → new:

    MCP tool renames (tools/list):

    • vault.apply_planvault.apply
    • vault.repair_planvault.repair

    The tool count is unchanged (14: seven read, seven mutation). The renamed tools' params, output schemas, and mutation-safety contract are otherwise identical. The generative rule the vocabulary now encodes: every mutation either produces a plan (vault.repair, --plan modes) or applies one (vault.apply); a bare vault.repair drops its _plan suffix because the invariant nothing writes except apply holds — read-vs-write falls out of the verb.

    CLI verb / argument renames:

    • norn migrate <plan>norn apply <plan> (executes any MigrationPlan — the plan-then-apply doctrine's execute verb; repair --plan stays). The subcommand, its help, the retry/apply-suggestion hints, and the audit-event command label ("migrate""apply") all move. config migrate (config-schema migration) is a distinct command and is unchanged.
    • norn move positional metavars <SRC> / <DST><FROM> / <TO>, matching MCP vault.move's existing from/to params (which are unchanged). Positional order is unchanged; only the displayed metavars.

    Code/enum VALUE canonicalizations (snake_case → kebab-case), emitted in the ApplyReport over --format json and vault.apply structuredContent, and in the audit event stream:

    • op status: not_runnot-run (applied / skipped / failed unchanged; now matches the sibling outcome enum, which was already kebab)
    • backlink-cascade skip reason (operations[].cascade.skips[].reason): source_missingsource-missing, would_corrupt_frontmatterwould-corrupt-frontmatter (drifted unchanged)
    • backlink-cascade failure reason (operations[].cascade.failures[].reason, and the audit norn.reason.code attribute): read_failedread-failed, write_failedwrite-failed

    Collapsed / corrected reason fields (skipped findings):

    • No change to the wire skipped-finding object — MigrationPlan.skipped[] (over norn repair --plan --format json and vault.repair) was already the single-field shape { finding_code, path, reason }, where reason is the kebab-case skip-reason code (e.g. "ambiguous-target"). A consumer branches on reason.
    • Removed a redundant precomputed reason_code field and canonicalized the skip_reason enum value to kebab in norn's internal repair-plan representation (never on the wire), so the one identifier is canonical everywhere. Help/docs that illustrated a fictional skipped-finding shape { code, skip_reason: "ambiguous_target", reason_code } are corrected to the real { finding_code, path, reason: "ambiguous-target" }.
  • A --format json mutation failure now carries its structured { code, message, path? } payload on STDOUT, and no longer prints the failure prose to stderr for that case (NRN-150). What broke: an apply/move/delete/rewrite-wikilink invocation run with --format json that fails (a stale-plan refusal, a destination-exists move, a partial apply, …) previously emitted only a bare nonzero exit plus human prose on stderr; it now emits a machine-readable envelope on stdout (a byte-identical refusal) or the full ApplyReport reflecting the partial state (a partial apply), with stderr no longer carrying the prose for the JSON case. Blast radius: any wrapper that scraped stderr for the failure text of a --format json mutation, or that assumed stdout was empty on failure, must read the code/message/path (or the report's failing-op error) from stdout instead. The records/TTY path is unchanged — prose still goes to stderr there. No migration shim; pre-1.0 makes this acceptable — this is the window to land the machine-branchable contract before external consumers exist.

  • norn apply and norn rewrite-wikilink no longer apply on --format json alone (NRN-212). Both commands previously forced is_apply/dry_run = false whenever --format json was present, even with no --yes on the command line and no TTY to confirm against — so an agent asking only for structured output got an unconfirmed write. --format json is now output-shape-only, matching every other mutation command (set/delete/new/edit/move): a non-TTY invocation without --yes is an implicit dry-run regardless of --format. Pass --yes to write. No migration shim; pre-1.0 makes this acceptable.

Added

  • Mutation failures now carry a stable, machine-branchable error taxonomy and a single outcome signal, so an agent can decide retry-vs-reread-vs-giveup by comparing a CODE instead of string-matching prose (NRN-150, NRN-183). Four additive parts, all kebab-case code values from the start (no snake_case debt for NRN-190 to canonicalize). (1) Error-code taxonomy. Every apply-time refusal now maps to a stable kebab code (stale-document-hash, expected-old-value-mismatch, post-image-verification-failed, content-op-after-vacate, move-destination-exists, the containment-* family, mutation-lock-timeout, …; full list in the new docs/errors.md), recovered by downcasting the previously-opaque error. A consumer distinguishes RETRYABLE CAS drift (stale-document-hash / expected-old-value-mismatch — re-read and re-plan) from a TERMINAL refusal by comparing code, never the message text. (2) MCP structured error data + return-report. MCP tool errors now carry the same { code, message, path? } envelope in the JSON-RPC error's data field. And an apply that refuses or partially fails is returned to an MCP caller as the ApplyReport itself — the offending op status: "failed" with an error.code, the untouched ops not-run — rather than an opaque transport error, so the client branches on error.code. (3) outcome field + tri-state exit contract. Every ApplyReport (over --format json and vault.apply's structuredContent) gains an outcome field — applied (exit 0) / failed (exit 1, a partial-apply failure: a write landed, then an op failed) / refused (exit 2, a byte-identical preflight refusal), plus a reserved rebased for a future auto-rebase-on-drift (NRN-152) — collapsing the process-exit tri-state into one field exposed identically by both surfaces. The failed-vs-refused split is decided by whether any filesystem write actually landed, not by the error variant, so a refused report always promises a byte-identical vault and a failed report always means partial mutation (re-read before retrying). A consumer that ported "nonzero exit = failure" should key on outcome, since over MCP a refused/failed apply is a normal structuredContent report, not a transport error. New docs/errors.md documents the exit codes, the outcome field, and the full code taxonomy. (NRN-150, NRN-183)
  • The query family (find/count/describe) now forgives predictable input spellings, while the canonical grammar is unchanged — you never have to type the forgiven forms and docs/help/errors still show only the canonical ones (ADR 0010). Three additive, non-breaking conveniences: (1) Separator forgiveness (NRN-206). Predicate tokens stay canonically field:value and assignment tokens (set/new --field, --field-json, --push, --pop) stay canonically key=value, but both families now accept either separator — the split point is the first : or =, whichever comes first, so a value-embedded : (a datetime, a URL) or = still parses correctly. --eq modified=2026-07-01 and --field status:active now work as well as their canonical spellings. (2) Dynamic field predicates (NRN-207). On the query family, an unknown --field value filters as --eq field:value for any field this vault actually carries (find --type note == find --eq type:note); a repeated key (--status active --status backlog) desugars to an any-of --in. Two guardrails keep this safe: real flags always win (a vault field literally named format stays reachable only via --eq format:x, never via --format), and an unknown key that is not a known field is a hard error with a did-you-mean across both flags and fields — so a typo'd flag like --formt json errors ("did you mean --format?") instead of silently returning an empty result. Equality only, a value is required (no bare-flag booleans), and never on the mutate family. (3) Alias pack (NRN-209). Hidden, accepted-not-taught: --where/--filter alias --eq, --group-by aliases count --by, and count --all is accepted as a harmless no-op for find symmetry. Cross-family misses get a teaching error (set --eq … points at --field key=value). The pre-clap argv pass shares clap's own notion of flags — the reserved value/bool flag sets are derived from clap's Command rather than hand-maintained, so a query flag added to the CLI can never silently degrade into a dynamic predicate — and honors clap's lexing conventions: a flag-shaped token (--all) is never swallowed as another flag's value (find --path --all errors like clap, ne...
Read more

v0.44.0 - 2026-07-06

Choose a tag to compare

@github-actions github-actions released this 06 Jul 12:54
5fc2ea9

Release Notes

The write-safety release: mutation can no longer silently corrupt a document's frontmatter. The headline is the post-image verification gate — no frontmatter mutation is written unless the result re-parses to exactly the intended mapping, with the same parse-degradation guarantee extended to on-document link rewrites and move/delete backlink cascades. Underneath it, the plan applier became per-document crash-atomic (single composed write, temp-file + rename), the span locator's remaining corruption vectors were retired or converted to clean refusals (flow-item quoting, whole-document ambiguity refusal, serde-oracle scalar spans), and norn get --section closes the read/write asymmetry with edit's section ops.

Added

  • norn get --section "Heading" reads named sections of a document's body, closing the read/write asymmetry with edit's section ops: it resolves each requested heading with the exact same boundary semantics edit --append-to-section/--replace-section use (heading line through the next same-or-higher heading, or EOF), so a section read mirrors a section write. The flag is repeatable — pass it once per heading (--section "Task Description" --section "Annotations"), and each occurrence is one whole heading string, so a heading that itself contains a comma (--section "Risks, Open Questions") is addressable verbatim. Orthogonal to --col/--all-cols — combine freely. --format json/jsonl add a sections object keyed by the requested heading text (a keyed lookup, alphabetically ordered); records prints each requested section as its own block in request order; the section content is byte-identical across formats, so a value read from either round-trips to edit --replace-section. Repeating the same heading collapses to one entry. A heading missing or ambiguous in a given document warns on stderr and is omitted from that document's sections without affecting sibling headings or other targets; if none of the requested headings resolve for a document, that document counts toward get's existing nonzero-exit contract (mirroring an unresolved target), though its record still returns. --format paths/markdown ignore --section entirely (no section resolution runs, so the lookup still exits 0), with a warning, like --col. (NRN-102)

Fixed

  • The plan applier now mutates each document crash-atomically across all content classes. The applier was pass-major: frontmatter, link-rewrite, body-replace, and section-edit ops each ran as an independent pass that read → transformed → wrote the file, so a document touched by two classes (canonically a status transition: set a frontmatter field plus append_to_section on the same doc) was written twice, and a failure between the two writes left the file half-mutated. Content ops are now file-major: every content-mutating class for a document composes into a single read and a single write, and the whole content phase computes (and validates, under whole-doc CAS) every file before writing any of them — so a hash drift or a failing transform (e.g. a missing edit heading) aborts before the first byte is written, leaving every file byte-identical to its original. The write itself is now crash-atomic — content is staged to a sibling temp file and renamed into place (as create_document already did) — so an interrupted process (SIGKILL, power loss, disk-full) can no longer truncate the live document. This is per-document atomicity; a plan touching several documents is still applied document-by-document (cross-document atomicity remains out of scope). Lifecycle ops (create/delete/move and their backlink cascades) are unchanged. (NRN-139)
  • The applier rejects a plan that edits a document after deleting or moving it earlier in the same plan. Because content ops now always apply before delete/move, a plan authored as [delete_document X, <edit> X] (or [move X→Y, <edit> X]) would otherwise be silently reordered into edit-then-vacate, masking an incoherent request. Such a plan is now refused up front, before any write, naming the path and the earlier delete/move. The coherent orders are unaffected: editing a document and then deleting/moving it still works, as does deleting a document and recreating it at the same path with create_document. (NRN-139)
  • norn new closes a files.ignore gap on {{seq}}-templated targets. NRN-131's build-time guard only ever saw the literal template path (e.g. logs/{{seq}}.md), so a files.ignore pattern that only matches the resolved filename (e.g. logs/1.md) slipped through — {{seq}} is resolved later, inside the applier, after the guard already ran. The applier now re-checks files.ignore against the resolved path before writing, refusing the create with the resolved path named in the error. A resolved path outside files.ignore is unaffected. (NRN-138)
  • Frontmatter minimal-edit no longer silently corrupts documents with anchors, aliases, tags, multi-line values, or nested flow — and quoted/numeric/merge keys are now visible to set/remove. The top-level span locator that set/remove use to find a field's exact value bytes was a hand-rolled scanner with a family of confirmed bugs: an anchor/alias/tag value (base: &a v, ref: *a, count: !!str 1) was misclassified as a plain scalar, so an edit deleted or rewrote the YAML markup; a multi-line plain-scalar fold (title: hello / world) orphaned its continuation line on remove; nested flow ({a: {b: 1}}, [a, [b]]) truncated on the naive first-brace scan; and quoted ("key with spaces":), numeric-leading (123:), or merge (<<:) keys failed the identifier-only key filter, so set/remove reported the field as "not present." The locator now treats the serde_yaml parse of the frontmatter as the authority: a byte-offset scanner only proposes a span, and the parsed mapping vetoes it. A proposed value span survives only if the bytes it covers re-parse (through the same pipeline the frontmatter parse uses) to exactly the field's parsed value — so a construct with real YAML complexity (block scalars, any multi-line value, anchors/aliases/tags, flow mappings, nested flow) can never reach the splice with a wrong span; it is refused, declining the in-place edit with a clear error rather than writing a corrupt document. The null case is gated explicitly: a bare key: # comment (which the parser reads as null) is refused rather than having its comment clobbered, while a literal key: null stays editable. A plain span is trimmed to the value's exact bytes (a trailing comment or trailing whitespace never bleeds in), and an indented trailing comment after a scalar no longer masquerades as a multi-line fold. The line_range that a remove (and a collection set) deletes is anchored to serde too: its boundaries come only from confirmed key lines — a candidate line whose name resolves to exactly one parsed key. A phantom line that looks like a key but is not one — a column-0 - name: item in a no-indent list of mappings, or a key:-shaped line inside a multi-line quoted/flow value — is absorbed into the preceding field rather than truncating it, so a remove deletes the whole property (a list-of-mappings block, an anchored block, a fold, and their blank/comment lines) without orphaning a tail. Ambiguity refuses: if a key matches zero candidate lines, or two candidate lines decode to the same name (a key-escape collision like "\x61" + x61), those keys get no editable span and are not used as boundaries, so set/remove cannot splice the wrong field. Flow sequences are no longer precision-spanned at all — a quote, nest, or trailing token inside [...] used to over-refuse an editable list like tags: ["a", "b"]; a flow (like a block) sequence now edits by replacing its whole serde-aligned line_range with a freshly serialized field, so quoted flow lists are editable again and the collection style is preserved. Key names come from the parse, so quoted ("key with spaces":) and numeric-leading (123:) keys are visible to set/remove; a merge key (<<:) is treated exactly as serde reads it (a literal mapping-valued field — serde_yaml does not auto-merge — refused for set, removable as its own line), keeping the locator consistent with what validate/find see. This retires the silent-corruption class for scalar edits: a wrong scalar span damages a file, a declined edit does not. (NRN-133)
  • Flow-sequence set no longer mis-splits or invalidates items containing flow indicators. Serializing a flow list (set tags=[…]) verified each item's plain form round-trips in a block template (k: <val>), where , [ ] { }, : , and edge whitespace are legal plain bytes — but the item lands in a flow context (k: [<val>]) where those are structural. So set tags=["a,b"] wrote tags: [a,b] and read back as two items, and set tags=["a]b"] wrote tags: [a]b] — invalid YAML that collapsed the whole frontmatter block to null, silently dropping every field from queries and edits. The item round-trip check now runs in a flow template, so an item that would mis-split or break the sequence is quoted until it reparses byte-identically as the sole element; items that were already safe are unchanged. (NRN-141)
  • An ambiguous-key document is refused whole, never partially edited. The span locator confirmed each serde key it could uniquely place and dropped the rest, but still emitted spans for the placed keys. A key the scanner mis-decodes (a double-quoted escape such as "\x61", which serde reads as a but the scanner reads as x61) matched no candidate line, so its bytes were absorbed into a neighboring field's line span — a remove/set of that unrelated field silently deleted the mis-decoded real key. When any serde key cannot be located to exactly one candidate line (zero, or more than one), the locator now refuses spans for the entire document...
Read more

v0.43.0 - 2026-07-05

Choose a tag to compare

@github-actions github-actions released this 05 Jul 15:16
795711d

Release Notes

Correctness hardening plus the warm-daemon foundation. The headline is norn serve — a single host daemon that serves the full MCP toolset for any vault over one well-known socket (CLI read-routing lands next). Alongside it, eight fixes retire a cluster of frontmatter- and ignore-model defects surfaced by dogfooding: incremental-refresh link determinism, files.ignore actually excluding documents, a four-in-one set-hardening batch, dotted-stem wikilinks, an append_to_section boundary weld, set initializing frontmatter on a document that has none, and new honoring the hard ignore boundary.

Added

  • norn serve — a warm host daemon. One foreground process (Unix only) serves the full MCP toolset for any vault on the host over a single well-known Unix socket (~/.cache/norn/run/norn.sock); at most one instance runs per user, guarded by a lifetime advisory lock. Each vault's integrity is verified once on first touch and held warm from then on, with per-request freshness, self-healing config changes, cache clears, and a vanished root. There is no norn service supervisor yet — run norn serve under your own process supervisor — and the CLI does not route reads through it yet (that lands with NRN-94); an MCP client can connect to the daemon directly. See docs/service.md.

Changed

  • The CLI→service probe now targets the well-known host socket instead of a per-vault path derived from the vault's identity hash — the derived form could exceed macOS's sun_path limit on some vault paths. Routing additionally requires the daemon to echo an exact version match, with a one-line stderr note on skew (restart the norn serve daemon), and the probe's connect is now timeout-bounded rather than a blocking connect.
  • Bumped camino 1.2.3 → 1.2.4 (patch) and rmcp 1.7.0 → 1.8.0 (minor). Lock-only updates; no source changes required.

Fixed

  • An incremental cache refresh now resolves links identically to a full rebuild. Link findings depend on the whole document set — adding an alias to one document can resolve a previously-missing [[alias]] in another, and adding or removing a document can change whether a link is ambiguous. The incremental refresh only re-resolved links whose target matched a changed path or stem, so alias- and ambiguity-driven re-resolution in unchanged documents was missed: back-to-back validate runs after a set batch kept stale link findings that a full cache rebuild dropped, making validate output depend on cache history rather than vault content — a violation of norn's same-input-same-output guarantee. When a Markdown change is detected, the refresh now rewrites the whole links table from the authoritative freshly-parsed index (which it already builds), so an incremental refresh and a full rebuild produce identical link state. Rewriting the full table costs O(total links) per refresh-with-changes (measured well within budget: ~0.34s after a one-document change on a ~1,200-document vault, still cheaper than a full rebuild; refreshes with no detected change remain a no-op). (NRN-126)
  • files.ignore now actually excludes documents from the graph. Visible-path globs in files.ignore (e.g. Archive/**) were inert: the cache was always built unfiltered, so ignored documents stayed indexed, queryable (count/find/get), and link-resolvable — even after a full cache rebuild. It went unnoticed because every stock entry targets hidden directories the scanner already skips. files.ignore globs are now wired into the cache build (both full rebuild and incremental refresh): matching files are never parsed, indexed, or resolved as link targets, and a path newly added to files.ignore is purged on the next refresh. Per ADR 0007's two-tier model, a link into an ignored path legitimately becomes link-target-missing — the target is outside norn by definition. The redundant read-time filter (which only dropped in-memory docs, never the SQLite rows, and never retracted resolved links) is removed, unifying the CLI and MCP paths on the built cache. (NRN-117)
  • norn set no longer corrupts frontmatter through four mutation edge cases. A batch of set-hardening fixes, each of which could leave a document frontmatter-parse-failed or silently wrong:
    • Scalar values are quoted by round-trip verification, not a denylist. The plain-scalar safety check was a hand-maintained list of YAML hazards and was incomplete: a value ending in a bare colon (Three threads in one session:) emitted unquoted and reparsed as a mapping; a value containing a newline fell through to single-quoting, which folds the newline to a space (silent corruption); and numeric-looking strings (123, 1.5) emitted plain reparsed as numbers. norn now renders at the preferred style, reparses the scalar through a real YAML parser, and escalates plain → single → double until it round-trips byte-identically — catching these and any not-yet-seen hazard. Explicit quote styles are never downgraded. (NRN-118)
    • Coercion uses the post-state schema on a type change. A single call that changed type and set another field coerced that field under the outgoing type's rules — e.g. --field type=session-log --field workspace=Agents on a type: note doc wrote workspace: "[[Agents]]" (the note rule's wikilink type) instead of the bare value the incoming type expects. Coercion and the wikilink-resolution warning sweep now resolve field types against the document's post-state. (NRN-119)
    • --push/--pop operate per element on list_of_strings fields. A list value was always wrapped into a one-element array, which is correct for a whole-field --field set but wrong for element ops: --pop aliases=x compared ["x"] against the string members and silently removed nothing, and --push nested the array. Push and pop now coerce their value as a single element. (NRN-127)
    • --remove on a no-indent block list consumes its item lines. Removing a list-typed key written in the column-0 layout (aliases: then - x with no indent — the common hand-authored / Obsidian form) deleted only the key line and orphaned the - x items into invalid YAML. The frontmatter span scanner now absorbs no-indent block-sequence items as part of the key's span. (NRN-128)
  • Dotted-stem wikilinks no longer report as broken. validate's link resolver derived its stem-match key with Path::file_stem, which truncates at the last dot — so a wikilink like [[v0.40.0]] or [[periodic-0.4-review]] was looked up as v0.40 / periodic-0 and reported link-target-missing even though the file existed and get resolved it. It now strips only a trailing .md from the target's final path component, matching how document stems are keyed. Path-qualified ([[dir/name]]) stem fallback is preserved, and only .md is stripped — a [[note.png]] target no longer cross-resolves to note.md. Takes effect on the next cache rebuild. (NRN-123)
  • edit --append-to-section no longer welds the appended line onto the next heading when the target section is empty. Appending into an empty section that was immediately followed by another heading (back-to-back ## History / ## Annotations, with no blank line between) emitted the appended line without a trailing newline, merging its last line into the following heading (todo → in_progress## Annotations) and destroying that heading — after which a section read of the clobbered sibling returned empty and a later append_to_section to it failed heading not found. The corruption was silent on write and only observable on read-back. An append into an empty section now always terminates the line with a single newline, so the following heading (or end-of-document) is preserved; the same applies to an empty section at end-of-file, which previously left the document without a trailing newline. Applies to the edit CLI and the apply_plan / edit MCP ops. (NRN-137)
  • norn set can now initialize frontmatter on a document that has none. set (and any additive add_frontmatter apply op) previously refused a document with no --- block outright (cannot minimal-edit frontmatter … document has no frontmatter), so schema backfill on legacy or hand-created files could not go through norn at all. It now synthesizes an empty frontmatter block and inserts the fields through the normal add path — set legacy.md --field title="…" turns body into ---\ntitle: "…"\n---\nbody. An existing but empty (---\n---\n) block is likewise initializable. Only a clean absence is synthesized: a malformed or unclosed block now surfaces as a frontmatter-parse failure (previously misreported as "document has no frontmatter") rather than getting a second block prepended. (NRN-120)
  • norn new refuses to create a document at a files.ignore'd path. Under the two-tier ignore model (ADR 0007), a files.ignore'd path is out of the graph entirely — get/set/delete already refuse it. new was the outlier: it would happily create a document that norn then could not read, edit, or delete, since it was excluded from the index. new now refuses such a path up front (cannot create <path>: excluded by files.ignore …, exit 2), using the exact matcher the graph build uses, so the hard boundary holds for every verb. (NRN-131)

Install norn-run 0.43.0

Install prebuilt binaries via shell script

curl --proto '=https' --tlsv1.2 -LsSf https://github.com/dbtlr/norn/releases/download/v0.43.0/norn-run-installer.sh | sh

Download norn-run 0.43.0

File Platform Checksum
norn-run-aarch64-apple-darwin.tar.xz Apple Silicon macOS checksum
[norn-run-x86_64-apple-darwin.t...
Read more