Skip to content
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ Entries here have landed on `main` but have not yet been cut into a tagged relea

### Changed

- **`norn set` now routes through the warm `norn serve` daemon when one is live, byte-identically — the first routed *mutation*, extending the NRN-94/222 read-routing seam to writes (NRN-229).** A dry-run/preview (`--dry-run`, or `--format json` without `--yes`, or the non-TTY implicit-preview path) and a non-interactive apply (`--yes`) are served from the daemon's already-verified warm cache when one is reachable, producing output byte-for-byte identical to direct execution (stdout, stderr, and exit code — the telemetry `trace_id` aside, which is non-deterministic on the direct path too) and the same on-disk result. With no daemon, `set` runs the direct path exactly as before. A routed apply runs under the send-commit policy (NRN-228): a failure *before* the request reaches the daemon falls back to a direct re-run, while a failure *after* send surfaces `post-send-uncertain` (exit 1) rather than risk a double-apply. The routable surface is `target` + `--field` / trailing `KEY=VALUE` positionals + `--remove` + `--force`; `--field-json`, `--push`, `--pop`, and `--body-from-stdin` stay Direct for now (their wire mapping is not yet order-preserving), as does the interactive TTY confirm prompt — and, exactly like routed reads, an explicit `--config` or `--no-cache-refresh` still runs direct (the wire speaks canonical vault roots only; the daemon serves each vault's own config from a refreshed cache). No new public MCP surface — routing reuses the existing `vault.set` tool.
- **`norn edit` now routes through the warm `norn serve` daemon too, on the same terms as `set` (NRN-229).** The mode mapping (`--dry-run` / `--yes` / `--format json` without `--yes` / non-TTY implicit preview / interactive TTY stays Direct), the `--config` / `--no-cache-refresh` force-Direct rule, and the send-commit policy are identical to `set`'s. Unlike `set`, every `edit` shape routes today — the ops array (however it was sourced: `--edits-json`, `--ops-file`, stdin, or single-op sugar) is resolved locally first and shipped as the already-resolved, order-preserved array, so there is no gated flag. One pre-existing asymmetry rides through unchanged: `norn edit`'s direct preflight refusal (an unresolved anchor, an unknown target, a stale `--expected-hash`) has never branched on `--format` the way `set`/`move`/`delete` do (NRN-221) — it prints `error: <message>` to stderr and exits 2 regardless of `--format json` — and the routed path reproduces that exactly rather than introducing a JSON envelope Direct doesn't emit. No new public MCP surface — routing reuses the existing `vault.edit` tool.
- **`vault.set`'s schema-validation refusals now carry a stable machine-branchable `code`, closing the gap NRN-220 deliberately deferred (NRN-221).** A `norn set` / `vault.set` call that hits a schema or argument refusal — an out-of-schema field value, a value outside `allowed_values`, a `--remove` of a `required_frontmatter` field, a malformed `KEY=VALUE`, a `--push` on a scalar, an unresolved or ambiguous `DOC` target, a cross-class key conflict, or a non-mapping/unparseable frontmatter — previously surfaced as prose only: MCP `vault.set` returned an opaque JSON-RPC error with no `report.error.code`, and the CLI printed prose to stderr and exited 2 with **nothing on stdout**, even under `--format json`. Both surfaces now carry one of twelve new kebab codes — `field-type-invalid`, `field-type-unsupported` (the vault's schema declares a `field_type` norn doesn't support — a config defect, split from `field-type-invalid` so an agent's fix-the-value retry branch never loops against a broken schema), `value-too-long`, `value-not-allowed`, `field-json-invalid`, `required-field-removed`, `target-not-found`, `target-ambiguous`, `assignment-malformed`, `field-conflict`, `push-on-scalar`, `frontmatter-not-mapping` (plus the existing `frontmatter-parse-failed` for a target document with unparseable on-disk frontmatter) — documented in `docs/errors.md`. On MCP, a **confirm** call returns the SAME structured `isError: true` + `report.error.code` shape NRN-220 gave `edit`/`new`, and a **dry-run** (`confirm: false`) forecast of such a refusal converges on the edit/new dry-run contract too: structured `outcome: "refused"` + `error.code` with `isError: false` (previously a bare JSON-RPC `Err`), so an SDK that raises on `isError` does not throw on a preview. On the CLI, `norn set --format json` now emits the NRN-150 `{ code, message, path? }` error envelope on stdout (matching `move`/`delete`), where it previously emitted no stdout at all. Records/TTY behavior is byte-identical (prose on stderr, exit 2), and every error's `Display` prose is unchanged; only the machine-readable surfaces are new. No other command's refusal codes change.

### Fixed
Expand Down
1 change: 1 addition & 0 deletions src/edit/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

pub mod ops;
pub mod report;
pub mod route;
pub mod sugar;
pub mod synth;
pub mod transform;
9 changes: 7 additions & 2 deletions src/edit/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@
//! (MCP). Internally tagged on `op`; `deny_unknown_fields` is intentionally
//! omitted (serde forbids it on internally-tagged enums).

use serde::Deserialize;
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
/// `Serialize` is derived so the CLI→service routing seam (NRN-229) can
/// re-encode the locally-resolved ops (sugar-desugared or parsed from
/// `--edits-json`/`--ops-file`/stdin) as the `edits` array `vault.edit` expects
/// on the wire — the exact inverse of the `Deserialize` impl, so a round trip
/// through JSON is lossless.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum EditOp {
/// Replace literal `old` with `new`. Unique-match-or-refuse unless
Expand Down
12 changes: 9 additions & 3 deletions src/edit/report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,16 @@

use crate::edit::transform::EditDescriptor;
use camino::Utf8PathBuf;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use std::io::Write;

#[derive(Debug, Clone, Serialize)]
/// `Deserialize` is derived so the CLI→service routing seam (NRN-229) can
/// rebuild an `EditReport` from a routed `vault.edit`'s `structuredContent` and
/// render it through the SAME `render_json`/`render_records` the direct path
/// uses — the load-bearing routed↔direct isomorphism (ADR 0005), mirroring
/// `SetReport`. Serialization is unchanged, so the MCP tool output stays
/// byte-identical.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EditReport {
pub schema_version: u32,
/// Shared by every telemetry event for this invocation. Empty on dry-run.
Expand Down Expand Up @@ -51,7 +57,7 @@ impl EditReport {
}
}

#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EditChange {
pub op: String,
pub anchor: String,
Expand Down
Loading
Loading