Skip to content

feat(route): route set and edit mutations through the warm daemon, byte-identically (NRN-229 PR A)#113

Merged
dbtlr merged 7 commits into
mainfrom
nrn-229-route-cascade-five
Jul 10, 2026
Merged

feat(route): route set and edit mutations through the warm daemon, byte-identically (NRN-229 PR A)#113
dbtlr merged 7 commits into
mainfrom
nrn-229-route-cascade-five

Conversation

@dbtlr

@dbtlr dbtlr commented Jul 10, 2026

Copy link
Copy Markdown
Owner

Summary

PR A of NRN-229 (Phase 1.5 write-routing under NRN-223): the first two routed mutations. norn set and norn edit now execute on the warm norn serve daemon 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

  • CallSpec params-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 private RoutedCall wrapper keeps send-commit policy selection out of caller hands. All too_many_arguments allows removed; reads delegate through the same skeleton unchanged.
  • Route-before-lock: mutation arms attempt routing before the CLI-side mutation lock — the daemon acquires the same per-vault lock file in-process, so a held client lock would deadlock the daemon's acquire into timeout. The Direct fallback runs today's sweep+lock+execute sequence unchanged.
  • Mode mapping: --dry-run / --format json without --yes / non-TTY implicit preview route as confirm:false (Fallback policy); --yes routes as confirm:true (Commit policy: pre-send failures fall back, post-send failures surface post-send-uncertain exit 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).
  • Force-Direct guards: --config / --no-cache-refresh force 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.
  • Zero new rendering or MCP surface: reconstruct deserializes the wire report into the native SetReport/EditReport (Deserialize added; serialization untouched) and emits through the same renderers Direct uses.

Test harness

serve_util gains 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. Suites serve_set_routing (4 tests) and serve_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 (served counts), 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 --yes combos probed routed vs direct), reconstruct roundtrip fidelity (caught that warnings needed serde(default) — without it a clean apply would misreport post-send-uncertain), gating completeness, seam-refactor drift, suite strength, test honesty. One confirmed blocker found and fixed: routed mutations initially bypassed the routing_forced_direct guard, letting a live daemon apply different bytes than an explicit --config dictated; fixed in 99077d3 with 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.lock unmodified
  • cargo test --workspace: 2381 passed, 0 failed (69 binaries) — independently re-run and summed; read byte-identity suites pass unmodified

dbtlr added 7 commits July 9, 2026 21:17
…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.
@dbtlr dbtlr merged commit ab289e2 into main Jul 10, 2026
7 checks passed
@dbtlr dbtlr deleted the nrn-229-route-cascade-five branch July 10, 2026 02:09
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant