feat(route): route set and edit mutations through the warm daemon, byte-identically (NRN-229 PR A)#113
Merged
Merged
Conversation
…N-229)
Pure params-struct refactor of the route_call/route_read/route_tool_call/
execute_routed_call cascade — zero behavior change. Each layer now takes
(context args, spec, reconstruct, emit):
- CallSpec<'a> { tool, arguments, on_tool_error, verbose } is the public
call description mutation commands construct (route_call stays the only
pub mutation entry and still takes dry_run, so the after_send_for
mapping and the Commit-policy debug_assert are unchanged).
- RoutedCall { spec, after_send } is the private inner shape carrying the
send-commit policy; only route_read/route_call build it.
- reconstruct/emit remain generic closure params (not boxed into the
struct); all #[allow(clippy::too_many_arguments)] on the seam removed.
Callers (route_count/route_find/route_get) and the in-crate stub-daemon
seam tests updated mechanically; the serve_*_routing byte-identity suites
pass unmodified.
Wire norn set through the CLI→service routing seam so a live norn serve daemon serves the mutation byte-identically, falling back to direct execution otherwise. This is the first routed mutation, extending the NRN-94/222 read-routing seam to writes. - New src/set/route.rs (count/show precedent): to_mcp_arguments folds --field + desugared positionals into the tool's field list, plus --remove/--force/target/confirm; reconstruct rebuilds the native SetReport (incl. coded refusals) from structuredContent; emit renders through the SAME render_records/render_json + refusal rendering the direct arm uses (zero new rendering code). - try_route_set in lib.rs: routes BEFORE the local mutation lock (the daemon takes the same per-vault lock in-process), maps --dry-run / json-without-yes / non-TTY-preview to confirm:false and --yes to confirm:true, keeps the interactive TTY prompt Direct, and gates --field-json/--push/--pop/--body-from-stdin to Direct (no order-preserving wire mapping yet). Unix-only with a non-unix always-Direct stub. - SetReport/FrontmatterChange/SetWarning gain Deserialize (serialization unchanged) so the seam can rebuild the report from the wire.
The direct-vs-routed byte-identity harness the four cascade commands
(PR B) copy. Extends tests/serve_util with a stderr-capturing daemon
spawn (DaemonWithLog + spawn_ready_daemon_with_log), a write_vault seed
helper, and a recursive dir_snapshot for whole-vault post-state
comparison.
tests/serve_set_routing.rs proves, per shape, that routed norn set is
byte-identical to direct on the (stdout, stderr, exit_code) triple
(telemetry trace_id redacted on both sides), the whole post-state vault
matches file-for-file, and the daemon served the call (count_served).
Shape matrix: {records, json} x {--yes apply, --dry-run, json-preview,
value-not-allowed refusal, unknown-doc refusal} plus a --remove apply
and a non-TTY implicit-preview case. One contention case pins a routed
apply under a held lock as SAFE (non-zero exit, nothing written) without
asserting identity to Direct, since the two legitimately diverge today.
Copy the just-landed set routing template for the edit mutation: src/edit/route.rs provides to_mcp_arguments/reconstruct/emit, and try_route_edit is attempted before the local mutation lock, falling back to Direct unchanged (cfg-dual stub on non-Unix). EditOp gains Serialize (the wire needs to re-encode the locally-resolved ops array) and EditReport/EditChange gain Deserialize, mirroring SetReport, without touching their serialization output. Every edit shape routes today: the ops array is already resolved locally (stdin/--edits-json/--ops-file/sugar) before routing is attempted, so unlike set there is no gated flag. The routed emit faithfully reproduces a pre-existing asymmetry in edit's direct refusal path: it does not branch on --format for a preflight refusal (prose + exit 2 regardless of json/records), unlike set/move/delete's NRN-221 JSON envelope.
Copy serve_set_routing.rs's shape for the newly-routed edit
mutation: {records, json} x {--yes apply, --dry-run, json-preview,
anchor-not-found refusal, unknown-doc refusal}, a multi-op
--edits-json batch apply, no_daemon_runs_direct, and the routed
apply under a held lock (safety only, not byte-identity — same
documented routed/direct divergence as set's contention case).
CHANGELOG entry for the edit routing extends the set entry with
edit's specifics, including the format-refusal asymmetry the
routed path reproduces from Direct rather than papering over.
…mutations (NRN-229) try_route_set and try_route_edit bypassed the routing_forced_direct guard the read seam applies, so a live daemon would serve a mutation whose invocation carried --config or --no-cache-refresh — flags the wire cannot express (the daemon loads each vault's own default config and always serves a refreshed warm cache). Empirically, `norn --config <alt> set task status=frozen --yes` was REFUSED routed (vault config enforced, exit 2) where Direct applied it under the alternate config (exit 0, different vault bytes) — divergence on all three identity axes. Thread explicit_config + no_cache_refresh into both seams and early-return None via the same routing_forced_direct guard, before any probe work, structurally identical to try_route_read. Regression coverage in both suites (forced_direct_flags_never_route): a semantically load-bearing --config shape (set: alt config widens allowed_values to admit `frozen`; edit: vault config files.ignores the target, alt config does not — so a regression diverges in bytes and exit, not just served counts) plus a --no-cache-refresh shape, each asserting routed-home == direct on the full triple + whole-vault snapshot AND zero `served vault.set`/`served vault.edit` markers. Both tests were verified red against the unguarded seams.
…rule on routed mutations (NRN-229)
dbtlr
added a commit
that referenced
this pull request
Jul 10, 2026
…m daemon (NRN-229 PR B) (#114) ## Summary PR B of NRN-229, completing the cascade five: `move`, `delete`, and `rewrite-wikilink` now route through the warm `norn serve` daemon byte-identically, preceded by the refusal-envelope parity work that makes routing them safe. With #113 (set/edit), every single-invocation mutation command routes. Closes NRN-229. ## Checkpoint 1 — refusal-envelope parity Routed mutations run under the send-commit policy: a daemon failure arriving as a bare JSON-RPC protocol error is indistinguishable from an unknown-state transport failure and must surface as `post-send-uncertain` (exit 1). Clean refusals therefore have to cross as coded, report-shaped tool results: - **Machine codes for the preflight error families** (`MovePreflightError`, `DeletePreflightError`, new typed `RewriteWikilinkError`): five new codes (`source-destination-same`, `backlinks-present`, `rewrite-to-not-found`, `rewrite-to-self`, `rewrite-to-ambiguous`); reused `target-not-found`/`target-ambiguous`/`destination-exists`/`parent-missing` where the semantic is identical (one semantic, one code — the NRN-235 direction). Documented in docs/errors.md. - **`internal-error` laundering closed:** move/delete/rewrite-wikilink `handle_output` now catch their typed preflight errors into structured `isError` refusals (NRN-220 contract); `ApplyError::from_anyhow` recognizes the new types. - **Lock-timeout is a structured `mutation-lock-timeout` refusal across all seven mutation tools** — a routed apply hitting daemon-side lock contention reconstructs as a clean exit-2 refusal, not uncertainty. - **CLI parity:** move/delete preflight refusals honor `--format json` with the NRN-150 envelope (previously prose-only, format-blind). Records/TTY prose byte-identical everywhere. ## Checkpoint 2 — routing + suites Same seam, ladder, and force-Direct guards as set/edit (`routing_forced_direct` wired from the start — the #113 review lesson). Shared `reconstruct_wire_report`/`emit_refusal` helpers; all three re-render through the exact print seams direct uses. Notable calls, all evidence-backed: - **move/delete route only exact on-disk doc paths** (directory for folder moves, `.md` file otherwise): the MCP tools apply the raw argument while the CLI applies the preflight-resolved path, so bare stems — and especially stems shadowed by extensionless files (`README` beside `README.md`) — gate Direct. Regression tests pin the shadowing scenario. Follow-up to remove the gate by aligning tool-side resolution is seeded. - **delete with `--rewrite-to`/`--allow-broken-links` in records format gates Direct** — the wire report omits the index-derived cascade counts the records renderer needs (seeded as a wire-shape gap). - **`remove_empty_dirs` runs on both surfaces** (verified) — folder moves leave identical trees. - **`--out` (rewrite-wikilink) reproduces client-side** — file-byte identity asserted. Suites: three new byte-identity suites (records/json × apply/dry-run/preview/coded-refusal/unknown-target + folder-move, `--parents`, `--rewrite-to` cascade, `--out`, shadowed-stem, forced-direct, no-daemon, contention), whole-vault snapshot identity including refusal shapes, `served` counts per shape. A deterministic cascade-failure case pins routed==direct for the exit-0-with-warning outcome; a genuine exit-1 partial is not deterministically inducible for single-op plans (the applier is atomic — adversarially confirmed), so partial semantics stay pinned at the applier level. **Documented divergences (deliberate, not asserted away):** lock-contention stderr prose differs (exit/stdout/vault identical; unification seeded); an apply-time *internal* error on a routed committing apply surfaces `post-send-uncertain` exit 1 where direct exits 2 — vault bytes identical, per the send-commit policy. ## Adversarial review Independent empirical review (probe authority, live daemons) over all nine commits, eight attack surfaces. One confirmed should-fix, escalated to blocker by policy: the original `exists()` routing gate let a shadowing filesystem entry make routed and direct silently act on *different files* — fixed in `79eed88`, re-probed by the reviewer (divergence closed, no over-gating: exact `.md` paths and folder moves still route). Taxonomy honesty, laundering closure, send-commit pairing, remove_empty_dirs parity, PR A regression surface, and test honesty all verified clean. Final verdict: **APPROVE**. The send-commit policy this arc pins is now memorialized as ADR 0011 (routed mutations commit at send; post-send never falls back). ## Verification - `cargo check --workspace --locked` / `cargo clippy --workspace --all-targets -- -D warnings` / `cargo fmt --check`: clean; `Cargo.lock` unmodified - `cargo test --workspace`: 2429 passed, 0 failed — independently re-run and summed; set/edit routing suites and read suites pass unmodified
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
PR A of NRN-229 (Phase 1.5 write-routing under NRN-223): the first two routed mutations.
norn setandnorn editnow execute on the warmnorn servedaemon when one is live — byte-identical output (stdout, stderr, exit code) and identical on-disk vault bytes — falling back to direct execution otherwise. Includes the deferred params-struct refactor of the routing seam and the mutation-routing test harness the remaining cascade commands (PR B: move/delete/rewrite-wikilink) will reuse.Design
CallSpecparams-struct (deferred from feat(route): extract route_call send-commit seam for mutation routing (NRN-228) #109 for its real callers): the seam layers now take(context, spec, reconstruct, emit); the privateRoutedCallwrapper keeps send-commit policy selection out of caller hands. Alltoo_many_argumentsallows removed; reads delegate through the same skeleton unchanged.--dry-run/--format jsonwithout--yes/ non-TTY implicit preview route asconfirm:false(Fallback policy);--yesroutes asconfirm:true(Commit policy: pre-send failures fall back, post-send failures surfacepost-send-uncertainexit 1 — never a double-apply). The interactive TTY preview→prompt→apply flow deliberately stays Direct (lock continuity across the prompt has no routed equivalent, and no CAS gate exists by design — NRN-224).--config/--no-cache-refreshforce Direct exactly like routed reads (the wire speaks canonical vault roots only). set additionally gates--field-json/--push/--pop(BTreeMap wire params can't preserve argv order — tracked) and--body-from-stdin; edit gates nothing — its ops resolve locally and ship as an order-preserved array.SetReport/EditReport(Deserializeadded; serialization untouched) and emits through the same renderers Direct uses.Test harness
serve_utilgains mutation-grade helpers: fresh seeded vault per case, whole-vault recursive byte-snapshot comparison (routed vault vs direct vault — refusal shapes included, so a leaked partial write fails the suite), env-threaded daemon spawn. Suitesserve_set_routing(4 tests) andserve_edit_routing(5 tests) cover records/json × apply/dry-run/preview/coded-refusal/unknown-doc (+ multi-op batch for edit), assert routing actually happened per shape (servedcounts), pin the no-daemon fallback, pin routed safety under lock contention (the exit 1-vs-2 divergence there is real and documented, not asserted away), and pin the force-Direct flags with semantically load-bearing alt configs.Adversarial review
Independent empirical review (probe authority, live daemons) across eight surfaces: double-apply/policy pairing, route-before-lock ordering, decision-ladder precedence (
--dry-run --yescombos probed routed vs direct), reconstruct roundtrip fidelity (caught thatwarningsneededserde(default)— without it a clean apply would misreportpost-send-uncertain), gating completeness, seam-refactor drift, suite strength, test honesty. One confirmed blocker found and fixed: routed mutations initially bypassed therouting_forced_directguard, letting a live daemon apply different bytes than an explicit--configdictated; fixed in99077d3with red-proofed regression shapes in both suites, re-probed by the reviewer and CLEARED (final verdict: approve). Known divergences deliberately not papered over: lock-contention exit code (routed 1 vs direct 2 — candidate for PR B's refusal-envelope parity), edit's format-blind preflight refusals (pre-existing, tracked).Verification
cargo check --workspace --locked/cargo clippy --workspace --all-targets -- -D warnings/cargo fmt --check: clean;Cargo.lockunmodifiedcargo test --workspace: 2381 passed, 0 failed (69 binaries) — independently re-run and summed; read byte-identity suites pass unmodified