Releases: dbtlr/norn
Release list
v0.48.3 - 2026-08-25
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
nornskill now installs through the skills CLI. Its package lives atskills/norn, sonpx skills add dbtlr/norn --skill nornselects and installs the public skill without installing repository-maintenance skills. The trigger now covers explicitnornrequests and configured Markdown vaults in the current directory or at a known-Cpath. 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
h2crate from 0.4.14 to 0.4.19 and resolves RUSTSEC-2026-0258.
Fixed
norn repair --helpandnorn apply --helpnow describe MigrationPlan schema v2. The help no longer teaches the retiredPlannedChangeenvelope or the removed--verifyflag. 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 | shDownload 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
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/editagainst anorn servedaemon could fail withservice 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 progresssequencestays 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 | shDownload 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
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 servedaemon 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 hiddennorn 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 warmnorn servedaemon, which previously never swept, and the stdionorn mcpserver — 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 (aprune: manualvault still spawns at most one throttled probe that exits without sweeping), and is silent. Caveat: an explicit non-default--configpassed to thenorn servedaemon ornorn mcpserver 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.yamlplus environment and cwd; env- and cwd-discovered config still reach it, but a custom config file path does not. The previous inlinewarn: cache prune skipped N locked entriesstderr line is dropped: the detached child has no user terminal to write it to. Explicitnorn cache pruneis 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 itsremove_dir_allon a transientDirectory not empty(ENOTEMPTY): clear's own removal briefly unlinks the entry.lockpartway through the walk, so a concurrently-triggered sweep child could recreate a file inside a subdir clear had already emptied and fail the follow-uprmdir; a short bounded retry closes that window (the observed CI flake incache clear).
Fixed
- The single
norn servedaemon 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 withio error at ~/.cache/norn/<hash>/.lock: Too many open files (os error 24)until a human rannorn 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 statusgains 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 underlyingstd::io::Errorin the error chain (instead of stringifying it), so an EMFILE surfacing at the daemon's first fd-consuming step on ahellostays 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 | shDownload 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
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
.rawstructural facet is removed fromfindandget. Requesting--col .rawnow follows the existing unknown-facet warning path and never emits arawrecords field or JSON/JSONL key; MCPvault.find/vault.getcallers must likewise remove.rawfromcolrequests. To read one document byte-for-byte, migrate tonorn get TARGET --format markdownorvault.get { targets: ["TARGET"], format: "markdown" }. Markdown remains single-document only; there is no multi-document replacement. MigrationPlanis now schema v2 andApplyReportis now schema v3 (NRN-264, ADR 0015). Plans gain first-classpreconditions; reports gain first-class precondition results. Persisted v1 plans must be regenerated, and report consumers must accept the v3preconditionsarray. 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-builttarget/…/norn) resolves to thedevchannel and keeps its database at~/.cache/norn/<hash>/dev/v5/cache.db; the installed binary stays on thelivechannel at~/.cache/norn/<hash>/v5/cache.db(thev5schema 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 cargoCACHEDIR.TAG, covering customCARGO_TARGET_DIRlocations; detection failures fail toward the isolated dev channel) and can be forced with the newNORN_CACHE_CHANNELenvironment variable (liveordev; 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 statusgains achannel:line (and achannelfield 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-activeliveentry 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 isstale-db. - Exact Markdown is now available through the routed CLI and MCP
vault.getpath (NRN-256).norn get TARGET --format markdownno longer forces Direct when a livenorn servedaemon 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 withvault.get { targets: ["TARGET"], format: "markdown" }; the response uses a dedicatedmarkdown: { path, content }envelope rather than adding a.raw/.sourcefacet 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--sectionno 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_documentoperation (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 stableowner-set-mismatch/owner-claim-conflictcodes, sorted expected/actual paths,outcome: "refused", andnot-runfor 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 includingpreconditions[]and honors--out, byte-for-byte with the direct path. Theeqselector matchesfind --eq's scalar equality — wikilink brackets are stripped from strings and numbers compare numerically, so2matches a stored2.0. norn service status --vault <PATH>now reports per-vault control-plane state (NRN-254, ADR 0013). The vault-scoped control pong carriesserving(cold,opening, orready) andwriter_progress(busyplus an opaque monotonicsequence), 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. Plainnorn service statusremains host-level. Scoped text adds vault/serving/writer lines, while JSON adds avaultobject 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.dbon the live channel,~/.cache/norn/<hash>/dev/v5/cache.dbon 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 barecache.dbfiles at a channel root (the pre-segment layout) are now garbage-collected on the same 48h clock, reported with astale-dbreason (renamed fromdev-stale). An existing barecache.dbis left byte-identical and ages out this way; the new binary buildsv5/cache.dbbeside 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 everynorn find/count/get/set/validateinvocation, ~34ms of a direct call against a 29MB cache and often more than the query it guarded. Against~/vaults/atlasthis cut a warmfindfrom ~48ms to ~16ms andcountfrom ~55ms to ~23ms. The direct read path now opens trusting and skips that scan; the stdionorn mcpserver, 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 asSQLITE_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 fullintegrity_checkis unchanged where verification is the point: thenorn servedaemon still verifies once per generation on open (routed reads inherit that verified state),norn cache statusstill 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 avault: cache is corrupted; …notice, as are the existing schema/identity/links.alias_fieldconfig-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 IMMEDIATEtransaction switches all affected rows and links to the new snapshot together. Publication authority is reserved before the whole-vault parse...
v0.47.0 - 2026-07-11
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'sset/push/popparams are now orderedKEY=JSON/KEY=VALUEtoken lists, matchingvault.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 offield_json: Vec<String>(the same name, semantic, and doc voice asvault.new'sfield_json);push: BTreeMap<String, Value>andpop: BTreeMap<String, Value>becomepush: Vec<String>/pop: Vec<String>of rawKEY=VALUEtokens. Each list is applied in order and fed straight into the sharedset::synth/validateseam the CLI's--field-json/--push/--popflags 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/--popare un-gated fromtry_route_set(src/lib.rs) and now route through the warmnorn servedaemon byte-identically, joining--field/ positionals /--remove/--force; only--body-from-stdinstill 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-arraypush/popvalue; 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 oldset/push/popmap params must migrate to the token-list shape. Documented indocs/mcp-server.md.- Two
norn validatefinding codes are renamed to converge onnorn set's refusal vocabulary — one semantic, one code across both surfaces (NRN-235, breaking).frontmatter-disallowed-valueis nowvalue-not-allowed, andfrontmatter-invalid-typeis nowfield-type-invalid, matching the codesset/newalready emit (NRN-221/230) for the identical schema condition (an existing on-disk value vs. an incoming one failing the sameallowed_values/field_typescheck). 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 validateoutput (--format json/jsonl/records,--codefiltering), the fix-hint lookup,norn repair --plan'sskipped[].finding_code, andnorn new's dry-run/apply warnings (same finding constructors, so the rename is automatic there too). Config-facing consequence: anyrepair.rules[].match.codewritten against the old names must be updated tovalue-not-allowed/field-type-invalid, as must any--codefilter scripted against them — including glob filters: both codes leave thefrontmatter-*family, so a gate scripted as--code 'frontmatter-*'no longer matches these two conditions and must add the new names (or thevalue-*/field-*families) explicitly; the shipped default.norn/config.yamlexample is updated. Documented indocs/validation.md,docs/configuration.md,docs/rule-shape.md,docs/errors.md, andintegrations/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-routedcount/find/getproof, a directset --field bench_status=<value> --yesbaseline 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 directsetdispatch opens the cache separately for itsGraphIndexand its queryCache— 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 servesitersroutedsetcalls against a distinct target doc, followed by one routedgetproving 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 ONEintegrity_checkmarker, cross-checked againstserved vault.set/served vault.getcounts 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 asetrow (direct vs routed-warm median, same skip-first-call convention). Run it withcargo test --release --test integrity_benchmark -- --ignored --nocapture. Dev-facing only — no runtime behavior change on any default path. docs/service.mdnow documents thenorn servicelaunchd 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-matchednorn servedaemon 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 same0.xversion can carry different wire schemas (this happened live — a stale installed daemon rendered additiveApplyReportfields as zeros), and the version gate was blind to it. The daemon's control-pong now carries abuildfingerprint — a blake3 hash of the crate'ssrc/tree plusCargo.lock, emitted at build time asNORN_BUILD_ID— and the routing gate requires it to match the client's exactly. Any mismatch — including a pong with nobuildfield 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 statuslikewise compares build fingerprints, so a same-version rebuild now showsrestart 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 rebuildnorn, the still-running daemon stops serving requests (silent Direct fallback) until younorn service restart. norn setnow routes through the warmnorn servedaemon 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 jsonwithout--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 telemetrytrace_idaside, which is non-deterministic on the direct path too) and the same on-disk result. With no daemon,setruns 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 surfacespost-send-uncertain(exit 1) rather than risk a double-apply. The routable surface istarget+--field/ trailingKEY=VALUEpositionals +--remove+--force;--field-json,--push,--pop, and--body-from-stdinstay 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--configor--no-cache-refreshstill 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 existingvault.settool.norn editnow routes through the warmnorn servedaemon too, on the same terms asset(NRN-229). The mode mapping (--dry-run/--yes/--format jsonwithout--yes/ non-TTY implicit preview / interactive TTY stays Direct), the--config/--no-cache-refreshforce-Direct rule, and the send-commit policy are identical toset's. Unlikeset, eve...
v0.46.0 - 2026-07-08
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-onlyis removed (NRN-116). The flag, itsMcpArgs.read_onlyfield, and the server-side gating (dropping the 7 mutation tools fromtools/listand refusing them at runtime) are gone;norn mcpnow always serves the full 14-tool catalog, exactly likenorn servealways has. No migration shim — a client invokingnorn mcp --read-onlygets 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 intests/bench_util/) proves the founding-bug fix end-to-end at 50k-doc scale: with a livenorn servedaemon, routedcount/find/getreads pay zero per-invocationPRAGMA 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.rsnow emits anorn trace: integrity_checkstderr marker per check whenNORN_TRACE_INTEGRITY_CHECKis 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'sservedmarkers 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_DOCSdefault 50000,NORN_BENCH_SEEDdefault 83,NORN_BENCH_ITERSdefault 5). Run it withcargo 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-gatedNORN_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 warmnorn servedaemon (macOS) (NRN-115).norn serveis a plain foreground process; the newnorn service install | uninstall | start | stop | restart | statusverbs 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.installrenders a plist at~/Library/LaunchAgents/com.dbtlr.norn.serve.plistand bootstraps it idempotently (bootout-first, with a retry on launchd's async-teardown race). The plist'sProgramArgumentsuse 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-timeXDG_CACHE_HOMEis 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).uninstallboots 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);stopis an honest bootout (aKeepAlivedaemon would resurrect a merely-killed pid);restartiskickstart -k. Exit-code contract:startis idempotent — exit 0 iff the unit ended running through this call or was already running (a loaded-but-not-running unit is kickstarted);stop/restartexit 0 iff they acted on a loaded unit, and a no-op (not installed, or not running) exits 1 with a structured reason — so anorn service stop && …chain never proceeds on a no-op, whilestart && …proceeds whenever the daemon is up.statuscombineslaunchctl 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'sloaded/runningare tri-state (true/false/null): when the launchd probe itself fails, status still renders the report — launchd statenullwith the probe error in alaunchd_errorfield, and whatever the live socket pong said (version / pid / uptime) — but exits nonzero (1), so anorn service status || alerthealth gate fires on unknown supervision state; every known state (running, stopped, not installed) exits 0. Every verb takes--format text|json; underjsonevery outcome — failures included — is a machine-readable object on stdout. On non-macOS hosts the verbs print a friendly fallback (runnorn serveunder your own supervisor; systemd support is planned) and exit nonzero.
Changed
-
Dynamic-field predicates (
find --type note,count --status active) now route through the warmnorn servedaemon, 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-predicatefind/countroutes 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--eqform, 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 adynamic_keysargument onvault.find/vault.count, and a gate refusal crosses back as adynamic_field_errorsibling instructuredContent; both are a private norn-CLI↔norn-daemon channel (#[schemars(skip)]) — they are absent from the published input/output schemas andtools/list, so no new public MCP surface and no new tool (an off-filesystem client filters with canonicaleq/inpredicates 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 serveverify-once win now covers the graph-index tools, not just reads (NRN-130). The nine MCP tools that reconstruct aGraphIndex—vault.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-payingPRAGMA integrity_check) on every call even when the request was served by the warm daemon; the verify-once latency win reached only thequery_cache-based reads (countsince NRN-94,find/getsince NRN-222). These tools now build their graph index through the daemon's held-open, already-verified connection via a newVaultContext::load_graph_indexseam, 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 aGraphIndexstructurally identical to a cold fresh open on the same vault state. (vault.newalso 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_checkruns at open (and on every self-heal reopen), not per call, so in-place corruption ofcache.dbis 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 ...
v0.45.1 - 2026-07-07
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: trueinstead of masquerading as success (NRN-219). Over MCP,vault.apply/vault.move/vault.delete/vault.rewrite_wikilinkreturnedisError: falsefor every outcome — a completed apply, a byte-identical pre-flight refusal (a CASexpected_old_value/ stale-hash mismatch, nothing written), and a partial-apply failure alike — so the only signal distinguishing them was the in-bandstructuredContent.report.outcome. A consumer trusting the protocol-nativeisErrorbit (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 setsisError: truewhenever the report'soutcomeis notapplied(i.e.refusedorfailed), while preserving the fullstructuredContent.reportso a consumer still branches onoperations[].error.code(retryable CAS drift vs terminal refusal) — the machine-readable code is not laundered back into prose. A dry-run preview (confirm: false) staysisError: falseeven when it forecasts a refusal: it attempts no write, so it cannot misreport one, and an SDK that raises onisErrormust not throw on a preview — the forecast is carried asoutcome: refused/dry_run: truein the (still-preserved) structured report.vault.set/vault.edit/vault.newwere 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 | shDownload 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
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_plan→vault.applyvault.repair_plan→vault.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,--planmodes) or applies one (vault.apply); a barevault.repairdrops its_plansuffix 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 anyMigrationPlan— the plan-then-apply doctrine's execute verb;repair --planstays). 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 movepositional metavars<SRC>/<DST>→<FROM>/<TO>, matching MCPvault.move's existingfrom/toparams (which are unchanged). Positional order is unchanged; only the displayed metavars.
Code/enum VALUE canonicalizations (snake_case → kebab-case), emitted in the
ApplyReportover--format jsonandvault.applystructuredContent, and in the audit event stream:- op
status:not_run→not-run(applied/skipped/failedunchanged; now matches the siblingoutcomeenum, which was already kebab) - backlink-cascade skip reason (
operations[].cascade.skips[].reason):source_missing→source-missing,would_corrupt_frontmatter→would-corrupt-frontmatter(driftedunchanged) - backlink-cascade failure reason (
operations[].cascade.failures[].reason, and the auditnorn.reason.codeattribute):read_failed→read-failed,write_failed→write-failed
Collapsed / corrected reason fields (skipped findings):
- No change to the wire skipped-finding object —
MigrationPlan.skipped[](overnorn repair --plan --format jsonandvault.repair) was already the single-field shape{ finding_code, path, reason }, wherereasonis the kebab-case skip-reason code (e.g."ambiguous-target"). A consumer branches onreason. - Removed a redundant precomputed
reason_codefield and canonicalized theskip_reasonenum 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 jsonmutation 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: anapply/move/delete/rewrite-wikilinkinvocation run with--format jsonthat 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 fullApplyReportreflecting the partial state (a partial apply), withstderrno longer carrying the prose for the JSON case. Blast radius: any wrapper that scraped stderr for the failure text of a--format jsonmutation, or that assumed stdout was empty on failure, must read thecode/message/path(or the report's failing-operror) from stdout instead. Therecords/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 applyandnorn rewrite-wikilinkno longer apply on--format jsonalone (NRN-212). Both commands previously forcedis_apply/dry_run = falsewhenever--format jsonwas present, even with no--yeson the command line and no TTY to confirm against — so an agent asking only for structured output got an unconfirmed write.--format jsonis now output-shape-only, matching every other mutation command (set/delete/new/edit/move): a non-TTY invocation without--yesis an implicit dry-run regardless of--format. Pass--yesto write. No migration shim; pre-1.0 makes this acceptable.
Added
- Mutation failures now carry a stable, machine-branchable error taxonomy and a single
outcomesignal, 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, thecontainment-*family,mutation-lock-timeout, …; full list in the newdocs/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 comparingcode, 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'sdatafield. And an apply that refuses or partially fails is returned to an MCP caller as theApplyReportitself — the offending opstatus: "failed"with anerror.code, the untouched opsnot-run— rather than an opaque transport error, so the client branches onerror.code. (3)outcomefield + tri-state exit contract. EveryApplyReport(over--format jsonandvault.apply'sstructuredContent) gains anoutcomefield —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 reservedrebasedfor a future auto-rebase-on-drift (NRN-152) — collapsing the process-exit tri-state into one field exposed identically by both surfaces. Thefailed-vs-refusedsplit is decided by whether any filesystem write actually landed, not by the error variant, so arefusedreport always promises a byte-identical vault and afailedreport always means partial mutation (re-read before retrying). A consumer that ported "nonzero exit = failure" should key onoutcome, since over MCP a refused/failed apply is a normalstructuredContentreport, not a transport error. Newdocs/errors.mddocuments the exit codes, theoutcomefield, 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 canonicallyfield:valueand assignment tokens (set/new--field,--field-json,--push,--pop) stay canonicallykey=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-01and--field status:activenow work as well as their canonical spellings. (2) Dynamic field predicates (NRN-207). On the query family, an unknown--field valuefilters as--eq field:valuefor 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 namedformatstays 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 jsonerrors ("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/--filteralias--eq,--group-byaliasescount --by, andcount --allis accepted as a harmless no-op forfindsymmetry. 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'sCommandrather 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 --allerrors like clap, ne...
v0.44.0 - 2026-07-06
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 withedit's section ops: it resolves each requested heading with the exact same boundary semanticsedit --append-to-section/--replace-sectionuse (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/jsonladd asectionsobject keyed by the requested heading text (a keyed lookup, alphabetically ordered);recordsprints 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 toedit --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'ssectionswithout affecting sibling headings or other targets; if none of the requested headings resolve for a document, that document counts towardget's existing nonzero-exit contract (mirroring an unresolved target), though its record still returns.--format paths/markdownignore--sectionentirely (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:
seta frontmatter field plusappend_to_sectionon 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 (ascreate_documentalready 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 withcreate_document. (NRN-139) norn newcloses afiles.ignoregap on{{seq}}-templated targets. NRN-131's build-time guard only ever saw the literal template path (e.g.logs/{{seq}}.md), so afiles.ignorepattern 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-checksfiles.ignoreagainst the resolved path before writing, refusing the create with the resolved path named in the error. A resolved path outsidefiles.ignoreis 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 thatset/removeuse 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, soset/removereported 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 barekey: # comment(which the parser reads as null) is refused rather than having its comment clobbered, while a literalkey: nullstays 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. Theline_rangethat aremove(and a collectionset) 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 akey:-shaped line inside a multi-line quoted/flow value — is absorbed into the preceding field rather than truncating it, so aremovedeletes 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, soset/removecannot 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 liketags: ["a", "b"]; a flow (like a block) sequence now edits by replacing its whole serde-alignedline_rangewith 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 toset/remove; a merge key (<<:) is treated exactly as serde reads it (a literal mapping-valued field — serde_yaml does not auto-merge — refused forset, removable as its own line), keeping the locator consistent with whatvalidate/findsee. 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
setno 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. Soset tags=["a,b"]wrotetags: [a,b]and read back as two items, andset tags=["a]b"]wrotetags: [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 asabut the scanner reads asx61) matched no candidate line, so its bytes were absorbed into a neighboring field's line span — aremove/setof 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...
v0.43.0 - 2026-07-05
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 nonorn servicesupervisor yet — runnorn serveunder 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_pathlimit 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
camino1.2.3 → 1.2.4 (patch) andrmcp1.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-backvalidateruns after asetbatch kept stale link findings that a fullcache rebuilddropped, makingvalidateoutput 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.ignorenow actually excludes documents from the graph. Visible-path globs infiles.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 fullcache rebuild. It went unnoticed because every stock entry targets hidden directories the scanner already skips.files.ignoreglobs 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 tofiles.ignoreis purged on the next refresh. Per ADR 0007's two-tier model, a link into an ignored path legitimately becomeslink-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 setno longer corrupts frontmatter through four mutation edge cases. A batch ofset-hardening fixes, each of which could leave a documentfrontmatter-parse-failedor 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 escalatesplain → single → doubleuntil 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
typeand set another field coerced that field under the outgoing type's rules — e.g.--field type=session-log --field workspace=Agentson atype: notedoc wroteworkspace: "[[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/--popoperate per element onlist_of_stringsfields. A list value was always wrapped into a one-element array, which is correct for a whole-field--fieldset but wrong for element ops:--pop aliases=xcompared["x"]against the string members and silently removed nothing, and--pushnested the array. Push and pop now coerce their value as a single element. (NRN-127)--removeon a no-indent block list consumes its item lines. Removing a list-typed key written in the column-0 layout (aliases:then- xwith no indent — the common hand-authored / Obsidian form) deleted only the key line and orphaned the- xitems into invalid YAML. The frontmatter span scanner now absorbs no-indent block-sequence items as part of the key's span. (NRN-128)
- 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 (
- Dotted-stem wikilinks no longer report as broken.
validate's link resolver derived its stem-match key withPath::file_stem, which truncates at the last dot — so a wikilink like[[v0.40.0]]or[[periodic-0.4-review]]was looked up asv0.40/periodic-0and reportedlink-target-missingeven though the file existed andgetresolved it. It now strips only a trailing.mdfrom the target's final path component, matching how document stems are keyed. Path-qualified ([[dir/name]]) stem fallback is preserved, and only.mdis stripped — a[[note.png]]target no longer cross-resolves tonote.md. Takes effect on the next cache rebuild. (NRN-123) edit --append-to-sectionno 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 laterappend_to_sectionto it failedheading 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 theeditCLI and theapply_plan/editMCP ops. (NRN-137)norn setcan now initialize frontmatter on a document that has none.set(and any additiveadd_frontmatterapply 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="…"turnsbodyinto---\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 newrefuses to create a document at afiles.ignore'd path. Under the two-tier ignore model (ADR 0007), afiles.ignore'd path is out of the graph entirely —get/set/deletealready refuse it.newwas the outlier: it would happily create a document that norn then could not read, edit, or delete, since it was excluded from the index.newnow 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 | shDownload 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... |