Noma Agent Protocol v1.0

The contract agents follow when editing Noma documents. Covers block identity, patch operations, validation, transcript records, and source-span guarantees. As of the v1.0 freeze the full patch-op catalog (core + extended), the baseHash block precondition, and Annexes A and B are all normative.

1. Conformance and versioning

1.1 What v1.0 stabilizes

v1.0 locks the following invariants. Downstream consumers (agent SDKs, third-party tools) MAY depend on these without expecting breaking changes within the v1.x line.

  • Block identity on the wire. Patch operations address blocks by canonical id only.

Aliases are resolution aids; they are not patch identity.

  • Patch operation semantics. The full op catalog — the five core ops (replace_block,

add_block, delete_block, update_attribute, rename_id, detailed in §3) plus the extended ops (enumerated in §1.5) — have stable names, required fields, and observable effects. Removing any op or changing its semantics is a major-version change. New ops remain additive (minor).

  • Validation contract (pre/post). Every patch attempt yields a pre_validation and

post_validation summary (ok | warn | error). The conditions under which each value is assigned are normative (§4).

  • Transcript record shape. The required and optional fields of a transcript entry, the

type of each field, and the rules for when each MUST or MAY be emitted are normative (§5).

  • Source span guarantees. For valid (Tier-1) documents, spans are exact: 1-based

inclusive [startLine, endLine] on each node type as listed in §6. For invalid or parser-recovered documents, spans are diagnostic hints only.

1.2 Compatibility promises

This spec follows Semantic Versioning for the normative core (§§1–6) and for Annexes A and B, which graduated to normative at the v1.0 freeze.

Major version bump — a breaking change that implementations MUST handle explicitly:

  • Removing a required field from the transcript record.
  • Changing the type of a required field.
  • Removing a patch op (core or extended) from the normative op set.
  • Changing an op's semantics in a way that alters previously-correct behavior.
  • A backward-incompatible change to an Annex A capability descriptor or Annex B binding shape.

Minor version bump — backward-compatible additions:

  • Adding optional transcript fields.
  • Adding new op codes (expected to be rare; any new op ships with full §3 coverage).
  • Expanding the error-code taxonomy in §4.

Patch version bump — clarifications and documentation only. No normative changes.

1.3 Extension model

Implementations MUST ignore unknown fields in transcript records. A record carrying fields not listed in §5 is still a valid v1.0 transcript. This rule enables forward-compatible tooling: newer emitters can enrich records without breaking older readers.

Implementations MUST ignore unknown attributes on Noma directive blocks. An attribute not recognized by a renderer or validator is passed through silently. This is the mechanism by which new attribute-based features (e.g., future ::claim{reviewed-by="…"}) stay backward-compatible with implementations that pre-date them.

Adding optional fields — to transcript records or to directive syntax — is always a backward-compatible change under this model.

1.4 Annexes A and B (normative)

Annex A (capability descriptor) and Annex B (MCP-over-stdio binding) graduated from provisional to normative at the v1.0 freeze. They carry the same SemVer promises as the core (§1.2): the descriptor field set and the binding's tool/error shapes are stable, and a backward-incompatible change to either requires a major version.

The reference MCP server (@ferax564/noma-mcp-server) implements Annex B; the capability descriptor schema (Annex A) ships at schemas/capability and is exercised by the agent SDK capability-check surface. Enforcement tightening (e.g., additional descriptor-driven rejections) remains additive: new optional fields and new advisory checks are minor changes; existing fields and their meanings do not change within v1.x.

1.5 Extended operations (normative)

Beyond the five core ops of §3, the v1.0 op catalog includes the extended operations below. At the v1.0 freeze these graduated from implementation extensions to normative: their names, required fields, and observable effects carry the same SemVer promises as the core (§1.2). They are fully covered by the conformance corpus (Annex C). A v1.0-conformant implementation MUST support them.

The extended operations are:

  • replace_body — replace body text on a body-only directive or text node while

preserving the wrapper, attributes, and sibling blocks.

  • update_heading — change a section title while preserving the section's canonical

ID by adding an explicit heading {id="..."} when needed.

  • add_comment — add a targeted ::comment block after an existing block or a

threaded reply_to= comment after an existing comment so agent review notes can become native Word comments in DOCX output. Source patching preserves nested directive fence depth when the target lives inside another directive.

  • resolve_comment — mark an existing ::comment block as resolved by setting

status="resolved" and optional resolver metadata.

  • remove_attribute — remove one non-id directive attribute without replacing

the block body.

  • add_footnote / add_endnote — add targeted ::footnote or ::endnote

blocks after existing blocks for Word-compatible note handoffs, preserving nested directive fence depth in source patches.

  • add_change_request — add a targeted ::change_request block with explicit

insert/delete/replace revision text for DOCX tracked-review handoffs, preserving nested directive fence depth in source patches.

  • update_table_cell — update one body cell in an ID-bearing ::table directive by

zero-based row and numeric column or header label, escaping separator pipes outside inline code spans while preserving pipes inside code spans.

  • update_table_header_cell — update one header cell in an ID-bearing

::table directive by numeric column or header label.

  • insert_table_row / delete_table_row — add or remove one body row in an

ID-bearing ::table directive by zero-based row.

  • insert_table_column / delete_table_column — add or remove one column in an

ID-bearing ::table directive by zero-based column or, for deletion, header label.

  • update_dataset_cell — update one body cell in an ID-bearing ::dataset

directive by zero-based row and numeric column or dataset column label. The source-preserving implementation supports inline YAML row arrays plus single-line CSV/TSV datasets with quoted cells and simple pretty-printed JSON row or record arrays; other dataset shapes can still use replace_body.

  • insert_dataset_row / delete_dataset_row — add or remove one data row in an

ID-bearing ::dataset directive by zero-based row. Source-preserving row edits support inline YAML row arrays, single-line CSV/TSV bodies with quoted cells, and simple pretty-printed JSON row or record arrays.

  • insert_dataset_column / delete_dataset_column — add or remove one data

column in an ID-bearing ::dataset directive by zero-based column or, for deletion, dataset column label. Source-preserving column edits support inline YAML row arrays, single-line CSV/TSV bodies with quoted cells, and simple pretty-printed JSON row or record arrays.

  • move_block — move an existing directive block under a new ID-bearing parent

while preserving the block body/attributes and normalizing fence depth when needed.

Implementations may discover the full op wire shape with noma schema patch-op. Tools that only need the smallest interoperable core MAY restrict themselves to the five core ops, but the extended ops are now part of the frozen v1.0 surface and will not change incompatibly within the v1.x line.

2. Document model

2.1 Addressable blocks — canonical IDs only on wire

Every addressable node in a Noma document carries a canonical ID. Canonical IDs are the sole block identity on the wire: patch operations MUST address blocks by canonical ID.

A canonical ID originates from one of two sources:

  • Explicit: ::block{id="my-id"} — the value of the id= attribute as written.
  • Implicit (heading slugs): # My Heading auto-generates a section with id="my-heading".

Slug derivation is deterministic and stable across re-parses of unchanged content.

Collision suffixing. When two or more headings slugify to the same id, the second occurrence receives the suffix -2, the third -3, and so on. The suffix is applied deterministically in document order. The canonical id after suffixing (e.g., my-heading-2) is the value that MUST appear on the wire.

Explicit {id="…"} overrides are NOT suffixed. If two explicit-id directives carry the same id, the validator emits a duplicate-id diagnostic (error severity). Agents MUST NOT emit patches addressing a document with known duplicate-id errors, as block identity is ambiguous.

Implementations MUST use the post-suffix canonical id in all patch operations. Using a pre-suffix slug or a guess at the suffix value without resolving against the parsed document is a protocol violation.

2.2 Aliases as resolution aids

A block may carry one or more aliases — alternative identifiers that resolve to the same node. Aliases arise from four sources:

  • aliases= attribute on a directive block: ::section{id="overview" aliases="intro,summary"}.
  • aliases: list in document frontmatter (attached to the root section).
  • Filename slug in book mode: the chapter filename without extension is auto-attached as an

alias of the root section.

  • Legacy unscoped slug in book mode: when scoping elevates an ID to chapter/original,

original is retained as an alias so pre-scoping references continue to resolve.

Aliases are valid for: discovery (exploring a document's structure), wikilink resolution ([[alias]] links), validator cross-reference checks, and UI lookups. Aliases are NOT patch identity.

If an agent begins discovery from an alias — for example, navigating a [[risks]] wikilink — it MUST resolve the alias to the canonical id before emitting a patch. Patches carrying an alias value in an id field SHOULD be rejected by conforming implementations, since aliases may be non-unique across future edits and do not carry the suffix-safety guarantees of canonical ids.

2.3 Book-mode scoping is a parser concept, not a wire concept

In book mode (noma render book.noma.yml), the parser applies ID scoping: every level-≥2 section id in a chapter becomes <chapter-slug>/<original-id> at parse time. This avoids cross-chapter id collisions inside the assembled document tree.

Scoping is a parsing operation only. It does not extend to the patch wire. v1.0 patches operate on a single .noma source file. A book manifest is NOT a valid patch target.

To edit a chapter, an agent MUST:

  1. Address the chapter's .noma source file directly.
  2. Use that file's unscoped canonical IDs (e.g., risks, not chapter-2/risks).

The unscoped form is the canonical id inside the individual chapter file. The scoped form (chapter-2/risks) is a parse-time artifact of book assembly and MUST NOT appear in patch operations.

Multi-chapter patches and book-scoped wire identity are deferred to v1.1.

3. Patch operation semantics

This section is normative. It defines the five patch operations that constitute the v1.0 op set, their atomicity guarantees, the reference-retargeting contract for rename_id, the validation failure policy, and the complete error-code taxonomy.

3.1 The five core operations

This section details the five core operations. They are the smallest interoperable set; the v1.0 op catalog also includes the extended operations of §1.5, which are equally normative. Implementations MUST support all five core ops; a v1.0-conformant implementation also supports the extended ops. An op value outside the full catalog MUST be rejected with code: "unsupported_op" (§3.5).

All operations address blocks by canonical id only (see §2.1). Addressing a block by alias is a protocol violation; conforming implementations SHOULD reject such requests.

3.1.1 replace_block

Replaces the entire content and attributes of an existing directive block with new source.

Required fields:

  • op: "replace_block"
  • id: canonical id of the block to replace.
  • content: a .noma source fragment that MUST parse to exactly ONE top-level directive

block. Paragraphs, sections, headings, and any other non-directive content are rejected.

Semantics: the target block — its opening fence, attributes, body, and closing fence — is removed from the document and replaced with the parsed directive from content. The block's position among its siblings is preserved. All other nodes in the document are unchanged.

The replacement block's id= (if declared in content) becomes the new canonical id of that position. If no id= is present in content, the block has no id after replacement (the old id is gone).

Error conditions:

  • target_missing (§3.5) — id does not exist in the document, or resolves to a

non-directive node.

  • invalid_content (§3.5) — content does not parse to exactly one top-level directive

block.

  • id_conflict (§3.5) — the id= declared inside content is already taken by another

block in the document (other than the block being replaced).

Example:

{
  "op": "replace_block",
  "id": "main-claim",
  "content": "::claim{id=\"main-claim\" confidence=0.9}\nRevised claim text.\n::"
}

3.1.2 add_block

Inserts a new directive block as a child of an existing parent block.

Required fields:

  • op: "add_block"
  • parent: canonical id of the block that will receive the new child.
  • content: a .noma source fragment that MUST parse to exactly ONE top-level directive

block. Same constraint as replace_block.content.

Optional fields:

  • position: 0-based integer index in parent.children at which to insert the new block.

If omitted, the block is appended at the end of parent.children.

Semantics: the parsed directive from content is inserted into parent.children at index position (or appended if position is absent). Existing children at index ≥ position are shifted right by one. All other nodes in the document are unchanged.

Range check. If position is present and is out of range (negative, or greater than parent.children.length), the operation is rejected with code: "parent_missing". This reuse of parent_missing for an out-of-range index matches the shipped reference implementation behavior and is intentional; it signals that the addressed insertion point does not exist.

Error conditions:

  • parent_missing (§3.5) — parent id does not exist in the document, does not accept

children, or position is out of the valid range [0, parent.children.length].

  • invalid_content (§3.5) — content does not parse to exactly one top-level directive

block.

  • id_conflict (§3.5) — the id= declared inside content is already taken by another

block in the document.

Example:

{
  "op": "add_block",
  "parent": "evidence-list",
  "position": 0,
  "content": "::evidence{id=\"ev-new\" for=\"main-claim\"}\nNew supporting evidence.\n::"
}

3.1.3 delete_block

Removes a directive block from the document.

Required fields:

  • op: "delete_block"
  • id: canonical id of the block to remove.

Semantics: the target block — including its entire subtree of child blocks — is removed from the document. Its position among siblings is vacated; remaining siblings close the gap. Deletion is non-cascading: references to the deleted block's id in other blocks (e.g., a for= attribute pointing to the deleted block) are NOT updated. The caller is responsible for cleaning up dangling references, or they will surface as validator diagnostics after the patch is applied.

Error conditions:

  • target_missing (§3.5) — id does not exist in the document, or resolves to a

non-directive node.

Example:

{
  "op": "delete_block",
  "id": "stale-evidence"
}

3.1.4 update_attribute

Sets or removes a single named attribute on an existing directive block.

Required fields:

  • op: "update_attribute"
  • id: canonical id of the block to modify.
  • key: attribute name. MUST NOT be "id" — use rename_id to change a block's id.
  • value: the new attribute value (string, number, or boolean), or null to remove the

attribute entirely.

Semantics: the attribute named key on the target block is set to value. If value is null and the attribute is not present, the operation is a noop (see §3.2 for noop handling). All other attributes and the block's body content are unchanged.

Protected key. Setting key: "id" is always an error regardless of the proposed value. The block id is the on-wire identity and MUST be changed only through rename_id, which applies reference retargeting atomically.

Error conditions:

  • target_missing (§3.5) — id does not exist in the document, or resolves to a

non-directive node.

  • id_attribute_protected (§3.5) — key is "id".

Example — updating a numeric attribute:

{
  "op": "update_attribute",
  "id": "main-claim",
  "key": "confidence",
  "value": 0.95
}

Example — removing an attribute:

{
  "op": "update_attribute",
  "id": "main-claim",
  "key": "draft",
  "value": null
}

3.1.5 rename_id

Changes the canonical id of a directive block and retargets all in-document references to that id atomically.

Required fields:

  • op: "rename_id"
  • from: the current canonical id of the block to rename.
  • to: the new canonical id. MUST NOT already exist in the document.

Semantics: the following changes are applied atomically:

  1. The id= attribute on the target block is changed from from to to.
  2. Every occurrence of from in the following reference positions is rewritten to to:

- for="<from>" attributes on any directive block. - parent="<from>" attributes on any directive block. - dataset="<from>" attributes on any directive block. - [[from]] wikilinks in inline content throughout the document.

No other content is modified. See §3.3 for what rename_id explicitly does NOT touch.

Error conditions:

  • target_missing (§3.5) — from does not exist in the document, or resolves to a

non-directive node.

  • id_conflict (§3.5) — to already exists in the document.

Example — rename and see reference retargeting:

The following operation renames old-claim to claim-v2. Before the patch, another block carries for="old-claim". After the patch, both the target block's id= and the for= reference are rewritten:

{
  "op": "rename_id",
  "from": "old-claim",
  "to": "claim-v2"
}

Before:

::claim{id="old-claim" confidence=0.8}
Original claim text.
::

::evidence{id="ev-1" for="old-claim"}
Supporting data.
::

After:

::claim{id="claim-v2" confidence=0.8}
Original claim text.
::

::evidence{id="ev-1" for="claim-v2"}
Supporting data.
::

3.2 Atomicity per op-list

A patch request MAY carry a list of operations. The list is atomic: either every operation in the list is applied and the new bytes are written, or no bytes are written at all.

Success path. If all ops in the list resolve and all validation policy checks pass (§3.4), the changes are written as a single update. One transcript record is emitted per op.

Failure path. If any op in the list fails:

  1. No bytes are written. The document on disk is unchanged.
  2. The failing op is recorded with patch_result: "rejected" and the relevant error code

in diagnostics.

  1. Every op that was virtually applied before the failing op is also recorded with

patch_result: "rejected" and code: "op_list_aborted" in its diagnostics. These are rollback notifications, not new failures.

  1. The result is one transcript record per op that was attempted. There is no partial

result value; §5 specifies that partial is not a valid patch_result.

Implementations MUST NOT commit partial writes. If the underlying I/O layer allows partial writes, the implementation MUST buffer the full result before committing to disk.

3.3 rename_id — what is and is not retargeted

rename_id retargets the following reference positions (and only these):

Reference typeRetargeted
id= on the target block itselfYes
for="<from>" on any blockYes
parent="<from>" on any blockYes
dataset="<from>" on any blockYes
[[from]] wikilinks in inline contentYes

The following are explicitly NOT retargeted by rename_id:

ContentNot retargeted — reason
aliases= attribute on the target blockAliases are human-curated metadata, not a reference graph. Renaming claim-x to claim-y does not make claim-x an alias of claim-y.
aliases= on any other blockSame reason.
String literals in prose that happen to match fromPlain text is not a reference; the parser has no way to distinguish an intentional reference from coincidental text.
id= attributes on blocks other than the targetThose are their own canonical ids; renaming one does not affect others.

Retaining the old id as an alias. If the caller wants the old id to remain resolvable after the rename, they MUST emit an explicit update_attribute on the renamed block setting its aliases= attribute to include the old id. rename_id never does this automatically.

3.4 Validation failure policy

Two validation runs are associated with every patch attempt: pre-validation and post-validation. Both run the full document validator. The policy determines whether each run can block the patch.

Pre-validation. Runs against the document state before applying any op. The result is recorded in the transcript as pre_validation: "ok" | "warn" | "error". By default, pre-validation errors do NOT block the patch. The rationale: refusing to apply a patch to a document that already has validator errors would prevent agents from fixing those errors.

A strict mode MAY be offered by implementations. When strict mode is active:

  • If pre_validation resolves to "error", the entire op-list is rejected without applying

any op.

  • The transcript records patch_result: "rejected" with code: "pre_validation_blocked" in

diagnostics.

Post-validation. Runs against the document state after applying all ops. The result is recorded as post_validation: "ok" | "warn" | "error". Post-validation is always informational: the patch IS written regardless of result. The caller decides whether to act on post_validation: "error" (e.g., by emitting a follow-up corrective patch).

expected_sha mismatch. If the caller supplies an expected_sha field (8-char display hash, Phase 0 compatibility), and it does not match pre_sha of the document when the patch is received, the entire op-list is rejected without applying any op. The transcript records patch_result: "rejected" with code: "sha_mismatch" in diagnostics.

base_sha256 divergence. If the caller supplies base_sha256 (the full hash of the document state the caller prepared the patch against) and it differs from pre_sha256 (the engine's observed pre-state), the patch is still applied. The transcript carries a diagnostics entry with severity: "warning" and code: "base_sha_drift". This is a concurrency signal — it tells reviewers that the document was modified between when the agent read it and when the patch arrived. It does not block.

baseHash block precondition (normative). Any op MAY carry a baseHash field: the sha256 hex (full, or a prefix of at least 8 chars) of the target block's current source slice, as returned by blockSourceHash() or the MCP read_doc tool's per-block hash. When present and the block's current hash does not start with the supplied value, the op is rejected with sha_mismatch before any bytes are written. Unlike expected_sha (whole-file), baseHash scopes the precondition to one block, so concurrent edits to other blocks of the same document do not conflict. For ops without an id target the hash applies to rename_id.from, add_block.parent, or the target of add_comment / add_footnote / add_endnote / add_change_request.

Summary:

ConditionDefault behaviorStrict mode
Pre-existing validator errorsApply, warn in transcriptReject (pre_validation_blocked)
Post-apply validator errorsAlways informational; patch writtenSame
expected_sha mismatch (Phase 0)Reject (sha_mismatch)Same
base_sha256pre_sha256Apply, warning diagnostic (base_sha_drift)Same
baseHash ≠ target block hashReject (sha_mismatch)Same

3.5 Error taxonomy

The following codes are the complete v1.0 PatchError set. Implementations MUST surface errors as machine-readable code values in the diagnostics array of the transcript record (see §5 for the Diagnostic shape). Human-readable descriptions are in the message field.

CodeMeaning
target_missingThe operation references an id (or from) that does not exist in the document. Also applies when the referenced id resolves to a non-directive node — patches operate on directive blocks only.
parent_missingadd_block.parent references an id that does not exist, does not accept children, or add_block.position is out of range for the parent's children list.
id_conflictrename_id.to already exists in the document, or add_block.content declares an id that is already taken.
invalid_contentreplace_block.content or add_block.content is not a single parseable top-level directive block. Paragraphs, sections, headings, and other non-directive content are rejected with this code.
id_attribute_protectedupdate_attribute was called with key: "id". The block id MUST be changed only through rename_id.
sha_mismatchThe caller-supplied expected_sha (8-char, Phase 0 field) did not match the document's pre-state hash. Also emitted when strict-mode base_sha256 is checked and does not match, and when an op's baseHash block precondition does not match the target block's current source hash.
pre_validation_blockedStrict mode is active and pre-validation returned "error". The op-list was not applied.
op_list_abortedThis op was virtually applied in memory before a later op in the same list failed. The list was rolled back; no bytes were written. Emitted in the transcript record for each preceding op in the failed list.
unsupported_opThe op value is not in the v1.0 op catalog (the §3.1 core ops or the §1.5 extended ops), or the operation targets a book manifest (.noma.yml). Book manifests are not valid patch targets in v1.0.

New codes MAY be added in a minor version without breaking conforming implementations (the extension model in §1.3 applies: unknown codes in transcript records MUST be tolerated by readers). Removing a code from this table requires a major version bump.

4. Validation contract

This section is normative. It specifies when validation runs relative to a patch attempt, how the summary strings in the transcript relate to the structured diagnostic array, the canonical shape of a Diagnostic record, and the complete set of diagnostic codes that conforming implementations MUST recognise.

Relationship to §3. Section 3.5 defined the patch error codes — machine-readable reasons a patch op was rejected (target_missing, id_conflict, etc.). Those codes appear in transcript diagnostics records and describe patch-engine failures. This section defines the validator codes — diagnostics produced by running noma check against the document AST, before or after a patch. The two sets are disjoint.

4.1 Validators MUST run pre and post apply

Every patch attempt MUST trigger two validation runs:

Pre-validation runs against the document state immediately before any op in the list is applied. The outcome is recorded in the transcript field pre_validation as "ok", "warn", or "error". By default, pre-validation errors are informational only: the patch proceeds regardless. The rationale is that blocking a patch on pre-existing errors would prevent agents from fixing those exact errors.

A strict mode MAY be offered. When strict mode is active and pre_validation resolves to "error", the entire op-list is rejected. The transcript records patch_result: "rejected" with code: "pre_validation_blocked" in diagnostics. See §3.4 for the full strict-mode policy.

Post-validation runs against the document state immediately after all ops are applied and the new bytes are written. The outcome is recorded as post_validation. Post-validation is always informational: the patch is committed before post-validation runs, and its result does not reverse the write. The caller decides how to respond — for example, by emitting a corrective follow-up patch if post_validation: "error".

When noma check is run as a standalone CLI command (outside the patch flow), it runs a single validation pass equivalent to pre-validation and exits non-zero if any "error" severity diagnostic is present.

4.2 ValidationSummary derived; Diagnostic[] canonical

The transcript carries two representations of validation outcome:

  • Summary stringspre_validation: "ok" | "warn" | "error" and

post_validation: "ok" | "warn" | "error". These are derived values, computable from the structured array.

  • Structured arraydiagnostics: Diagnostic[] (optional transcript field; always

present in noma check output). This is the canonical form.

When the structured array is present, it is the source of truth. The summary string MUST be derivable from it by the following rule: take the highest severity among all diagnostics for the relevant phase — "error" beats "warn" beats "info"; if the array is empty, the summary is "ok".

Implementations MUST NOT emit a summary of "ok" when the structured array for that phase contains an "error" severity entry, and vice versa.

4.3 The Diagnostic shape

The canonical Diagnostic type is defined in src/ast.ts and is used in both the standalone validator and in transcript records. Implementations MUST use this shape.

interface Diagnostic {
  severity: "info" | "warning" | "error";
  code:     string;         // machine-readable rule name, kebab-case
  message:  string;         // human-readable explanation
  pos?:     { line: number; column: number };  // 1-based source position
  nodeId?:  string;         // canonical id of the offending node, if applicable
  phase?:   "pre" | "post"; // MUST be present in transcript records; absent in noma check output
}

Field-by-field rules:

  • severity is required. The three levels form a total order for summary derivation:

"error" > "warning" > "info".

  • code is required. Values are kebab-case strings drawn from §4.4 (validator rules) or

§3.5 (patch error codes). Unknown codes MUST be tolerated by readers (§1.3 extension model).

  • message is required. It is a human-readable, localizable string; callers MUST NOT

parse it programmatically. Use code for machine branching.

  • pos is optional. When present, line and column are 1-based. For file-level

diagnostics (e.g., broken-reference) there may be no meaningful source position; pos is then absent.

  • nodeId is optional. When present, it is the canonical id of the node that triggered

the diagnostic. Callers may use it to locate the block for display or follow-up ops.

  • phase MUST be present in any Diagnostic emitted inside a transcript record. It MUST

be "pre" for diagnostics from the pre-validation run and "post" for diagnostics from the post-validation run. When a Diagnostic is emitted by noma check (the standalone CLI), phase MUST be absent — the CLI runs a single nameless pass.

Transcript-only diagnostic note. base_sha_drift (§3.4) is emitted by the patch engine, not by noma check. It appears in diagnostics with severity: "warning" and phase: "pre", but it is never produced by a validator rule. It is the one diagnostic code that can appear in a transcript without a corresponding noma check rule.

4.4 Standard diagnostic codes

The following table lists every rule code the v0.6.0 validator emits. Each row names the code, its severity, and the condition that triggers it. Codes can be suppressed per-block with the noverify flag attribute, or per-run with --ignore-rule <code>.

Document integrity

CodeSeverityCondition
duplicate-iderrorTwo or more nodes carry the same canonical id.
broken-referenceerrorA for=, parent=, dataset=, or [[wikilink]] points at an id that does not exist in the document (checked against both canonical ids and aliases).

Profile conformance

CodeSeverityCondition
unknown-profilewarningFrontmatter profile: (or profiles:) declares a profile name the validator does not recognise. Known profiles: minimal, technical, research, memory.
out-of-profile-directivewarningA directive block whose name is not in the declared profile's allowlist appears in the document.

Research-profile semantic checks

These rules fire in any document; they are most meaningful under the research profile.

CodeSeverityCondition
claim-without-evidencewarningA ::claim block has no ::evidence or ::counterevidence block targeting it via for=. Gated on requireEvidenceForClaims option (default: on).
evidence-missing-forwarningAn ::evidence or ::counterevidence block has no for= attribute pointing at a claim.
risk-without-ownerwarningA ::risk block has no owner= attribute.
decision-without-statuswarningA ::decision or ::adr block has no status= attribute.

Agent-task checks

CodeSeverityCondition
agent-task-without-scopewarningAn ::agent_task or ::todo block has neither a scope= attribute nor a non-empty body or children.

Computed-interaction checks

These rules validate the static contract for ::control, ::computed_metric, and ::computed_plot. They do not require the future browser runtime to be present.

CodeSeverityCondition
control-missing-defaultwarningA numeric ::control block (slider, range, number, checkbox, or toggle) has no numeric default= value for static rendering and LLM context.
control-out-of-range-defaultwarningA numeric ::control block's numeric default= falls outside its declared min= / max= range.
control-invalid-lockwarningA ::control block declares an unsupported DOCX content-control lock mode. Valid modes are control, content, all, unlocked, and none.
computed-missing-formulawarningA ::computed_metric or ::computed_plot block has no formula= attribute or formula: body line.
formula-parse-errorerrorA computed block's formula is not valid numeric formula syntax or calls a function outside the allow-list.
computed-unknown-dependencyerrorA computed block's formula references an identifier that is not a ::control, another computed block, or a declared plot-domain variable.
computed-chain-too-deepwarningA computed block depends on a computed chain deeper than two levels, or participates in a computed cycle.

State-change checks

CodeSeverityCondition
state-change-missing-blockwarningA ::state_change block has no block= attribute pointing at the changed block.
state-change-missing-from-towarningA ::state_change block is missing one or both of from= and to= attributes.

Citation checks

CodeSeverityCondition
stale-citationwarningA ::citation block's accessed= date is older than the stale window (default: 365 days; overridable via stale_citation_days in frontmatter or per-block stale_after_days=).

Diagram checks

CodeSeverityCondition
diagram-missing-kindwarningA ::diagram block has no kind= attribute (mermaid, graphviz, or drawio expected).
diagram-missing-sourcewarningA ::diagram block has no source body.

Plot checks

CodeSeverityCondition
plot-missing-dataerrorA ::plot block has neither a data= attribute nor a dataset= reference.
plot-unknown-dataseterrorA ::plot block's dataset= references an id that does not resolve to a ::dataset block.
plot-unknown-columnerrorA ::plot block's column= names a column not declared in the referenced dataset.
plot-mixed-delimiterswarningA ::plot block mixes comma-separated data= with space-separated xlabels= (or vice versa). Use commas throughout.

Plotly checks

CodeSeverityCondition
plotly-missing-specwarningA ::plotly block has no JSON spec body.
plotly-invalid-jsonerrorA ::plotly block's body is present but is not valid JSON.

Figure checks

CodeSeverityCondition
figure-missing-altwarningA ::figure block has neither an alt= nor a caption= attribute, unless the block carries noverify.

Dataset checks

CodeSeverityCondition
dataset-src-missingwarningA ::dataset block has a src= attribute but the file could not be loaded (missing or unreadable).

Escape-hatch checks

CodeSeverityCondition
escape-hatch-untrustedwarningAn ::html, ::svg, or ::script block does not carry the trusted flag attribute. Add trusted to acknowledge the escape hatch, or noverify to suppress all checks on that block.

Meta-rule

CodeSeverityCondition
unknown-ignore-ruleinfo--ignore-rule <code> was passed a rule code that the validator does not recognise. This diagnostic is emitted rather than silently discarding the flag.

New codes MAY be added in a minor version without breaking conforming implementations. Removing a code requires a major version bump.

5. Transcript record

This section is normative. It specifies the file format for transcript records, the complete required and optional field schemas, the hash semantics that underpin integrity and replay, the replay-determinism guarantee, and the compatibility rules for readers and writers.

5.1 JSONL append-only, one record per attempted op

A transcript is a JSON Lines file: each line is a single, self-contained valid JSON object terminated by \n. The file is append-only — records are never modified or deleted after they are written.

Conventional path. The transcript file lives alongside the source document, named by appending .patches to the document filename. For example: thesis.nomathesis.noma.patches. Implementations MAY use a different path if the caller specifies one; the conventional name is the default.

One record per attempted op. A transcript record MUST be emitted for every op that was attempted, regardless of outcome. This includes:

  • applied — the op resolved, policy did not block, and new bytes were written.
  • rejected — the op did not produce written bytes (target missing, id conflict, strict

mode blocked, op-list abort, etc.).

  • noop — the op resolved cleanly but produced byte-identical output.

No attempted op is silently omitted. Audit integrity depends on the completeness of the append-only log.

5.2 Required field schema

Every v1.0 transcript record MUST include all of the following fields. A record missing any required field is not a valid v1.0 record.

{
  protocol_version: "1.0",          // literal string — ONLY transcript version field
  tool_version: string,             // semver of the tool that wrote the record
  op_id: string,                    // UUID v4, unique per record
  ts: string,                       // ISO 8601 UTC timestamp (e.g. "2026-05-11T14:32:00.000Z")
  actor: {
    kind: "human" | "agent" | "tool",
    name: string,                   // display name of the actor (human username, agent ID, etc.)
    model?: string,                 // e.g. "gpt-5.5" — present when kind is "agent"
    version?: string                // version of the agent/tool, if known
  },
  doc_uri: string,                  // file:// URI of the document being patched
  pre_sha256: string,               // full sha256 hex of doc before op (integrity-grade)
  post_sha256: string,              // full sha256 hex of doc after op (integrity-grade)
  pre_sha: string,                  // first 8 lowercase hex chars of pre_sha256 (display only)
  post_sha: string,                 // first 8 lowercase hex chars of post_sha256 (display only)
  op: PatchOp,                      // the patch operation object (one of the §3.1 set)
  patch_result: "applied" | "rejected" | "noop",
  pre_validation: "ok" | "warn" | "error",
  post_validation: "ok" | "warn" | "error"
}

protocol_version is the sole transcript version discriminator. The Phase 0 v: 1 field is dropped. Readers MUST reject records claiming protocol_version: "1.0" that are missing other required fields.

partial is NOT a valid patch_result value. Op-list atomicity (§3.2) ensures that a list never partially commits. A multi-op failure produces one transcript record per attempted op: the failing op carries its own error, and each preceding op is marked rejected with code: "op_list_aborted" (§3.5).

5.3 Optional ledger fields

Implementations MAY include any of the following fields. Readers MUST ignore optional fields they do not recognise (extension model, §1.3).

{
  reason?: string,                  // human narrative: why this patch was applied
  parent_op_id?: string,            // op_id of a causally prior record (causation chain)
  base_sha256?: string,             // actor's claim: sha256 of the doc state it patched against
  diagnostics?: Diagnostic[],       // structured; each entry carries phase: "pre" | "post"
  elapsed_ms?: number,              // wall-clock time in milliseconds for this op
  prev_entry_sha256?: string,       // sha256 of the previous line in the transcript (tamper evidence)
  signature?: null | Signature      // RESERVED — emit null in v1.0; shape undefined until v1.1
}

base_sha256. The actor computes this over the bytes it read before preparing the patch. When base_sha256pre_sha256, the document was modified between when the actor read it and when the patch arrived. This is a concurrency signal. See §5.4 for hash semantics. See §3.4 for the base_sha_drift diagnostic.

diagnostics. Structured Diagnostic records as defined in §4.3. In transcript context, each entry MUST carry phase: "pre" (from pre-validation) or phase: "post" (from post-validation). When present, diagnostics is the source of truth and the pre_validation / post_validation summary strings MUST be derivable from it (§4.2).

prev_entry_sha256. The sha256 of the previous line in the transcript file (raw bytes including the terminating \n). This chains entries for tamper-evidence audits. Absence is permitted and common.

signature. RESERVED. The Signature shape (algorithm, key id, encoded value) is not defined in v1.0. Implementations MUST emit null when including this field, or omit it entirely. Forward-compatible: the field slot is reserved so v1.1 can define it without a schema conflict.

5.4 Hash semantics

pre_sha256 and post_sha256 are SHA-256 computed over the document's UTF-8 byte sequence as read or written by the patch engine. No normalization is applied:

  • Line endings are preserved as found in the source. LF is assumed; CRLF is preserved

if present. The spec does not promise normalization between platforms.

  • A byte-order mark (BOM), if present, is included in the hash.
  • Trailing newlines are preserved exactly as in the source.

Timing.

  • pre_sha256 is computed immediately before applying the first op in the list (or

the single op). No read or mutation occurs between this computation and the first op.

  • post_sha256 is computed immediately after the new bytes are written to disk. No

further mutation occurs between the write and this computation.

Display aliases.

  • pre_sha is the first 8 lowercase hex characters of pre_sha256. Display only.
  • post_sha is the first 8 lowercase hex characters of post_sha256. Display only.

Implementations MUST use the full _sha256 fields for all integrity comparisons. The 8-char _sha aliases MUST NOT be used for integrity decisions; they are for human display (log lines, review UIs) only.

base_sha256 (optional, actor-supplied). The actor computes this over the bytes it read, using the same algorithm (SHA-256, no normalization, raw UTF-8 bytes). When both base_sha256 and pre_sha256 are present, a reader can determine whether the document changed between when the actor prepared the patch and when it was applied.

For a rejected op, post_sha256 MUST equal pre_sha256 — no bytes were written, so the post-state hash equals the pre-state hash.

For a noop op, post_sha256 MUST equal pre_sha256 — the op resolved but produced byte-identical output.

5.5 Replay determinism

Transcripts serve two purposes: audit and replay.

Audit. Every record establishes provenance: who acted, when, against which exact document state, with what op, and with what result. Human reviewers and automated policy tools use the transcript as an immutable history.

Replay. Given:

  1. A base .noma source file whose SHA-256 equals the pre_sha256 of the first

applied record in the transcript, and

  1. An ordered list of transcript records,

then applying each applied record's op in document order MUST produce a result whose SHA-256 equals the post_sha256 of the last applied record processed.

rejected and noop records are audit-only. Replay MUST skip them: they produced no bytes and carry no state change. Applying a rejected op during replay would corrupt the replay state.

Replay determinism is part of the conformance contract. The conformance fixture patch/replay-chain (§Annex C) exercises a three-op transcript chain and verifies that replay reproduces the final sha256 byte-exactly.

5.6 Compatibility rules

The following rules govern both emitters and readers of v1.0 transcript records.

Emitters (writers) MUST:

  • Include all required fields defined in §5.2.
  • Emit protocol_version: "1.0" on every record.
  • Compute pre_sha256 and post_sha256 using the algorithm in §5.4 (no normalization,

raw UTF-8 bytes, timing as specified).

  • Emit post_sha256 equal to pre_sha256 for rejected and noop records.
  • Emit patch_result as one of "applied", "rejected", or "noop" only.
  • Emit signature: null if including the signature field at all.

Emitters MAY:

  • Include any optional field from §5.3.
  • Include additional implementation-specific fields not listed in this spec.

Readers MUST:

  • Ignore unknown fields (§1.3 extension model).
  • Use pre_sha256 / post_sha256 (full 64-char hex) for all integrity comparisons.
  • Treat pre_sha / post_sha as display-only values and MUST NOT use them for

integrity decisions.

  • Recognise protocol_version: "1.0" as the v1.0 discriminator.

5.7 Phase 0 migration

Phase 0 transcripts (the format emitted by the v0.5.x reference implementation, using the v: 1 field) are legacy-only and are NOT retroactively compatible with v1.0.

Phase 0 records lack the following required v1.0 fields: op_id, pre_sha256, post_sha256, doc_uri, structured actor, tool_version, and patch_result. Reconstructing these fields from a Phase 0 record would require fabrication, which would defeat the audit and replay guarantees of the transcript.

Migration policy:

  • A converter MAY wrap Phase 0 lines in a separate legacy_phase0 import format. Such a

wrapper MUST carry a clear non-v1.0 marker and MUST NOT set protocol_version: "1.0".

  • A converter MUST NOT claim v1.0 transcript compatibility for a migrated Phase 0 record.
  • Tooling that needs v1.0 records starts emitting them fresh. There is no automatic

upgrade path from Phase 0 to v1.0.

6. Source span guarantees

This section is normative. It specifies the line-span guarantees that conforming parsers MUST provide for the nodes they emit, the two-tier trust model for valid versus recovered documents, per-node-type span rules, and two hard rules — code-fence directive suppression and section-id collision suffixing — that are prerequisites for correct span computation.

6.1 Tier 1 — valid documents

For documents that parse without errors (no recovery needed), spans are exact and normative. The pos.line and endLine fields on every AST node are 1-based and inclusive: a node that starts on line 3 and ends on line 7 carries pos.line: 3, endLine: 7.

Conforming implementations MUST produce spans that satisfy the per-node-type rules in §6.3 for all Tier-1 inputs. Span precision is part of the conformance contract: the conformance suite fixture valid/basic-section and valid/frontmatter-only (Annex C) include span assertions that a conforming parser MUST pass.

6.2 Tier 2 — invalid or recovered documents

When the parser encounters malformed input and applies error recovery — unclosed fences, mismatched colon depths, truncated frontmatter, or any input that triggers a best-effort reconstruction — spans on the recovered subtree are diagnostic hints, not normative.

Implementations SHOULD still emit the most accurate span they can compute. Tools MAY use Tier-2 spans in error messages and editor highlights. However:

  • Implementations MUST NOT rely on Tier-2 spans for patch addressing. Canonical block ids

are always the correct addressing mechanism (§2.1).

  • Implementations MUST NOT use Tier-2 spans for transcript replay. Replay depends on

pre_sha256 and post_sha256 (§5.5), not on line numbers.

Tier classification is implicit: a document is Tier 1 if and only if noma check produces no "error" severity diagnostics with a parse origin. Tier 2 covers everything else.

6.3 Per-node-type span specification

The following table gives the normative span rule for every node type in the v1.0 AST union. All line numbers are 1-based and inclusive. "Closing fence line" refers to the line containing the matching :: or ` that closes the block.

Inline content (inline markdown, inline math, inline code spans within a paragraph, table cell text) does NOT receive sub-spans in v1.0. Spans are block-granularity only.

Node typestartLineendLine
document1 (always)Last physical line of the source file, excluding any trailing-newline artifact that produces a spurious empty final line
frontmatter1 (always)Line of the closing --- fence, inclusive
section (implicit, heading-derived)Line of the # heading that opens the sectionLine before the next heading at the same or shallower depth, or the last line of the document if no such heading follows
section (explicit, ::section{}::)Opening fence lineClosing fence line, inclusive
directive (any ::name{}::)Opening fence lineClosing fence line, inclusive
code (fenced, ` ` )Opening fence lineClosing fence line, inclusive
paragraphFirst line of prose in the paragraphLast line of prose in the paragraph
listLine of the first list itemLine of the last list item (including any continuation lines of a multi-line item)
list_itemThe item's own lineThe item's own line for single-line items; last continuation line for multi-line items
quoteLine of the first > prefixLine of the last > prefix in the block
thematic_breakIts lineIts line
tableHeader row lineLast body row line

6.4 Code-fence directive suppression rule

Code fences (` ` ` ) MUST suppress directive recognition for their entire interior. A :: pattern on any line between the opening and closing fence is treated as literal content belonging to the CodeNode`; it MUST NOT be interpreted as a block boundary, a directive open, or a directive close.

This rule is normative for v1.0 parsers. A parser that triggers directive recognition inside a fenced code block is non-conforming.

Example — :: inside a fenced code block is content, not a block boundary:

The following is a single code node whose content includes two :: lines. The parser MUST NOT start a new directive at line 3 or treat line 5 as a closing fence for any directive:

# Section

(backtick-fence noma)
::claim{id="example" confidence=0.9}
Illustrative claim text.
::
(closing backtick-fence)

After parsing, the section contains exactly one code node. No directive node is created. The code node's span covers the opening fence through the closing fence, inclusive.

The same rule applies inside ::directive bodies: a code fence opened within a directive body suppresses :: recognition until the matching closing fence for the code block.

6.5 Section-id collision suffixing rule

When two or more headings produce the same slug under the deterministic slugification algorithm, the parser MUST resolve the collision by appending a numeric suffix in document order:

  • The first occurrence keeps the bare slug (e.g., overview).
  • The second occurrence receives suffix -2 (e.g., overview-2).
  • The third receives -3, and so on.

Suffixing is deterministic: the result depends only on document order and is stable across re-parses of unchanged content. The suffixed value is the canonical id for all patch operations (§2.1).

Example — duplicate implicit headings:

## Overview

First overview section. Its id is "overview".

## Overview

Second section with the same heading. Its id is "overview-2".

The patch wire MUST use overview-2 to address the second section. Using the bare slug overview in that position would address the first.

Explicit {id="…"} overrides are never auto-suffixed. If two directive blocks carry the same explicit id= value, the parser does not disambiguate them. The validator emits a duplicate-id diagnostic (error severity; see §4.4). Agents MUST NOT emit patches addressing a document with known duplicate-id errors because block identity is ambiguous in that state (see §2.1).

Example — duplicate explicit ids produce a diagnostic, not a suffix:

::claim{id="main-claim"}
First claim.
::

::claim{id="main-claim"}
Second claim with the same explicit id.
::

Running noma check on the above produces:

error  duplicate-id  Two or more nodes carry the same canonical id "main-claim".

No suffix is applied. The document is invalid until one of the ids is changed.

Annex A — Capability descriptor (normative)

A.1 Sidecar location and frontmatter pointer

The capability descriptor lives in a sidecar file adjacent to the .noma source. The sidecar filename is the document filename with .capabilities.yml appended:

thesis.noma  →  thesis.noma.capabilities.yml

A document's frontmatter MAY declare a pointer to the sidecar:

agent_capabilities: ./thesis.noma.capabilities.yml

The pointer is advisory — it assists discovery tools and editors. Implementations MUST resolve the descriptor from the sidecar path regardless of whether the pointer is present in frontmatter. The pointer MUST NOT be used as the authoritative capability source, and its absence MUST NOT be interpreted as "no capabilities file exists." The canonical lookup is always <document-filename>.capabilities.yml relative to the document.

A.2 Why sidecar, not frontmatter

Frontmatter is document content. Agents patch content. Embedding a capability declaration inside content creates a self-modifying authority surface: an agent could issue a replace_block or update_attribute operation that overwrites the rules governing its own operations. That is not a permission system.

Default policy is read-only / unspecified, not "all." The descriptor uses allowlist semantics: an absent descriptor, or a descriptor that does not mention a block type, grants no patch permissions to agents. Agents can only perform operations that the descriptor explicitly permits.

Implementations MUST NOT default to "permit everything" when no sidecar is found. The absence of a capability descriptor MUST be treated as equivalent to a descriptor that grants no ops.

A.3 Sidecar schema (normative)

The sidecar is a YAML file with a single top-level key nomaAgent. The following example illustrates the full v1 descriptor shape:

nomaAgent:
  version: 1
  profile: default
  blocks:
    claim:
      ops: [replace_block, update_attribute]
      attrs:
        confidence:
          type: number
          min: 0
          max: 1
    evidence:
      ops: [add_block, replace_block, delete_block]
  ids:
    rename: false
  validation:
    required: true

Field reference:

FieldTypeRequiredDescription
nomaAgent.versionintegerYesDescriptor schema version. MUST be 1 for v1.0 descriptors. Implementations SHOULD reject descriptors with an unrecognised version number.
nomaAgent.profilestringNoNamed profile for this descriptor, e.g. default, research, engineering. Implementations MAY ship preset profiles that agents can reference. Has no effect on allowlist evaluation — the blocks map is always the authoritative grant.
nomaAgent.blocksobjectNoMap from directive name to per-block policy object. A directive name absent from this map has no granted ops.
nomaAgent.blocks.<name>.opslistNoPermitted patch ops on this block type. For strict v1.0 descriptors, entries MUST be values from the §3.1 op set: replace_block, add_block, delete_block, update_attribute, rename_id. The reference implementation also accepts the post-v1.0 extensions listed in §1.5. Ops absent from the list are denied. An empty list is equivalent to no entry for this block name.
nomaAgent.blocks.<name>.attrsobjectNoOptional per-attribute schema for update_attribute calls targeting this block type. A key absent from this map carries no constraint beyond the ops grant.
nomaAgent.blocks.<name>.attrs.<key>.typestringNoExpected value type: string, number, or boolean. Implementations MAY validate the proposed value against this type and reject mismatches.
nomaAgent.blocks.<name>.attrs.<key>.minnumberNoMinimum inclusive value (applies when type is number).
nomaAgent.blocks.<name>.attrs.<key>.maxnumberNoMaximum inclusive value (applies when type is number).
nomaAgent.blocks.<name>.attrs.<key>.enumlistNoEnumerated allowed values (applies when type is string or number). A proposed value not in this list SHOULD be rejected.
nomaAgent.ids.renamebooleanNoWhether rename_id is permitted across all block types. Default false. Even if rename_id appears in a block's ops list, it MUST also be enabled here.
nomaAgent.validation.requiredbooleanNoIf true, post-patch validation errors SHOULD be surfaced as a warning to the caller. Equivalent to requesting strict-mode post-validation reporting (see §3.4). Does NOT block writes; informational only in v1.0.

A.4 Enforcement (deferred to v1.1)

v1.0 defines the descriptor shape only. No runtime enforcement is implemented in the v1.0 reference implementation.

Specifically:

  • The patch_block MCP tool (Annex B) does NOT consult the sidecar before applying a

patch in v1.0. All ops remain available to any caller regardless of what the descriptor says.

  • Implementations MAY parse and validate the descriptor's YAML structure for early

feedback (e.g., reject malformed YAML or unknown version values) but MUST NOT block patch operations based on descriptor content in v1.0.

v1.1 will gate patch_block operations against the descriptor. Operations that violate the descriptor will produce patch_result: "rejected" with a new error code. The reserved name suggestion for that code is capability_denied; its exact definition and the full enforcement semantics are to be defined in v1.1 and are not part of the §3.5 v1.0 error taxonomy.

Implementers planning ahead MAY parse the descriptor and surface advisory warnings when a proposed op has no matching grant. They MUST label such warnings as advisory only and MUST NOT reject the patch on their basis in a v1.0-conforming implementation.

A.5 Versioning and forward compatibility

The schema defined in A.3 is frozen under the §1.2 SemVer promises as of v1.0. Additional optional fields and additional advisory checks are additive (minor); removing a field or changing its meaning is a major change.

Implementations that consume the descriptor SHOULD pin to a specific nomaAgent.version value and treat an unexpected version number as a signal to fall back to the default read-only / unspecified policy rather than attempting to interpret an unknown schema.

This annex is covered by the compatibility promises of §1.2. See §1.4.

Annex B — MCP-over-stdio binding (normative)

B.1 What this binding is

This annex specifies the MCP-over-stdio binding — one concrete realization of the Noma agent protocol carried over the Model Context Protocol (MCP) using the stdio transport. The tool package name is @ferax564/noma-mcp-server.

The binding exposes four MCP tools: read_doc, list_ids, validate_doc, and patch_block. Each tool accepts a JSON input object and returns a JSON-encoded body in the MCP text content array. All four tools are grounded in the reference implementation at packages/mcp-server/src/index.ts.

This is one binding among future bindings. HTTP and WebSocket bindings are v1.1+ items (§B.8). The binding does not define new protocol semantics — patch operations, validation, and transcript records all follow the normative core (§§2–5).

B.2 Authentication and transport

The stdio binding carries no authentication surface. The server process runs under the caller's process identity: filesystem access, file permissions, and OS-level credentials govern what operations succeed.

This is acceptable for local-agent integration — e.g., Claude Code, single-machine MCP clients — where the calling process is already trusted. It is NOT acceptable as a network service or multi-tenant deployment.

Future bindings (HTTP, WebSocket) WILL have authentication requirements. Those bindings are out of v1.0 scope; see §B.8.

B.3 Tool: read_doc

Purpose. Parse a .noma file and return a shallow block summary of the full document tree. Agents use this to orient themselves before issuing patch operations.

Input schema.

{ "file": "string — absolute path to the .noma file" }

Output. JSON-encoded object. Each block summary in the blocks array reflects a node in the document tree, in document order, including nested children:

{
  blocks: Array<{
    id?:         string;         // canonical id, if present
    type:        string;         // "directive", "section", "paragraph", "code", etc.
    name?:       string;         // directive name (present only when type = "directive")
    attrs?:      Record<string, string | number | boolean>;  // directive attributes (excludes id)
    title?:      string;         // section title (present when type = "section")
    level?:      number;         // heading depth (present when type = "section")
    aliases?:    string[];       // alias list, if any
    childCount:  number;         // number of direct children
    lines:       [number, number];  // 1-based inclusive [startLine, endLine]
    patchable:   boolean;        // true iff the block has a non-empty canonical id
  }>
}

patchable: true means the block carries a canonical id and MAY be addressed by patch operations. patchable: false blocks (anonymous paragraphs, auto-unlabeled code fences, etc.) MUST NOT be targeted by patch operations.

System errors (file not found, permission denied, book manifest path) are returned with isError: true in the MCP response. Book manifests (.noma.yml) are not valid targets for this tool.

B.4 Tool: list_ids

Purpose. Enumerate the canonical IDs present in a .noma file and the complete alias map. Agents use this to resolve an alias to its canonical id before constructing a patch (§2.2).

Input schema.

{ "file": "string — absolute path to the .noma file" }

Output.

{
  ids:     string[];                  // ordered list of all canonical ids in the document
  aliases: Record<string, string>;    // alias → canonical id (one entry per alias)
}

The aliases map is keyed by alias value; each value is the canonical id of the node that alias resolves to. An agent that encounters [[risks]] SHOULD look up "risks" in this map to obtain the canonical id before emitting a patch_block call.

System errors are returned with isError: true. Book manifests are not valid targets.

B.5 Tool: validate_doc

Purpose. Run the Noma validator against a .noma file. The validation profile is read from the document frontmatter (profile: / profiles: key). This is equivalent to noma check but returns machine-readable JSON rather than terminal output.

Input schema.

{ "file": "string — absolute path to the .noma file" }

Output.

{
  ok:          boolean;       // true iff no "error" severity diagnostics are present
  diagnostics: Diagnostic[];  // full structured array; shape follows §4.3
}

The ok field is true when no diagnostic carries severity: "error". Warning-only documents return ok: true. Agents SHOULD inspect the diagnostics array for warnings before issuing corrective patches.

Note: ok is boolean, not the "ok" | "warn" | "error" summary string used in the transcript record (§5.2). The boolean form is sufficient for this read-only tool because the transcript summary string is a patch-flow concept. Callers that need the three-value form MAY derive it from diagnostics using the §4.2 derivation rule.

System errors are returned with isError: true. Book manifests are not valid targets.

B.6 Tool: patch_block

Purpose. Apply a block-level patch operation to a .noma file. Implements the §3 semantics. Appends one v1.0 transcript record to <file>.patches (§5) regardless of outcome.

Input schema. All fields follow the Zod schema in packages/mcp-server/src/index.ts.

{
  file:           string;          // absolute path to the .noma file (required)
  op:             PatchOp;         // discriminated union of the five §3.1 ops (required)
  reason?:        string;          // agent-provided justification; stored in transcript
  expected_sha?:  string;          // SHA-256[:8] of file before patch — prevents lost updates
  actor?: {
    kind:     "human" | "agent" | "tool";
    name:     string;
    model?:   string;
    version?: string;
  };                               // caller identity recorded in transcript; defaults to
                                   // { kind: "agent", name: "unknown" } if absent
  base_sha256?:   string;          // 64-char SHA-256 of the doc state the agent prepared against
  parent_op_id?:  string;          // UUID of a causally prior transcript record
}

The op field MUST be one of the five v1.0 op objects defined in §3.1. Book manifest paths (.noma.yml) are rejected as unsupported_op.

Output — success. When the patch engine completes without a system fault:

{
  ok:               true;
  post_validation:  "ok" | "warn" | "error";   // derived from postDoc diagnostics
  transcript_entry: TranscriptLine;             // the complete v1.0 record written to .patches
  diagnostics:      Diagnostic[];               // post-apply diagnostic array (§4.3 shape)
}

post_validation and diagnostics always reflect the post-apply state. When patch_result in transcript_entry is "noop" (the op resolved but produced byte-identical output), the file is NOT rewritten; post_validation still reflects the validated (unchanged) document.

Output — failure.

{ ok: false; error: string; code?: string }

code is a §3.5 error code when the failure is a patch engine error (e.g., target_missing, sha_mismatch). code is absent when the failure is a system fault (filesystem error, unexpected exception).

MCP error envelope. The tool uses isError: true only for system faults (filesystem read/write failure, unexpected exception, book manifest path). User-facing patch errors — target_missing, sha_mismatch, id_conflict, invalid_content, and all other §3.5 codes — are returned as normal MCP content with ok: false in the body. This separation lets agents inspect code and branch on failure type without unwinding the MCP call stack.

Side effects:

  • The file IS rewritten when patch_result: "applied".
  • The file is NOT rewritten when patch_result: "noop" or "rejected".
  • The transcript file <file>.patches is appended in ALL cases, including rejections and

noops. Transcript writes that fail are silently swallowed — a transcript failure MUST NOT propagate as a tool error.

B.7 Error envelope

The MCP error envelope (isError: true) is reserved for system errors — conditions the caller cannot inspect or recover from by examining the patch body:

  • Filesystem errors: file not found, permission denied, write failure.
  • Transport or unexpected runtime exceptions.
  • Book manifest path supplied as file (the book manifest is not a valid patch target

per §2.3; this is treated as a system boundary error, not a user-facing patch error).

All other failures — every §3.5 PatchError code — are returned as normal MCP content (isError absent or false) with { ok: false, error: string, code: string }. This two-tier distinction means:

  • System error path (isError: true): the caller knows something went wrong at the

infrastructure level; no code value to inspect; the tool cannot be retried without changing the environment.

  • User error path (ok: false body): the caller receives a machine-readable code

value from §3.5 and MAY take corrective action (resolve the target id, fix the content fragment, update expected_sha, etc.).

Implementations MUST NOT surface user-facing patch errors as isError: true. Doing so would collapse the distinction and force callers to parse error strings for code values.

B.8 Forward-compatibility notes

This binding is status: normative. Its current tool/argument/error shapes are frozen under §1.2. The items below are additive directions that do not change the v1.0 binding:

  • Non-functional fixes. The binding MAY receive patch releases (v1.0.x) for

documentation corrections, field rename clarity, or schema alignment with the normative core. No semantic changes will ship as patch releases.

  • HTTP binding. A future @ferax564/noma-http-server binding over HTTP/1.1 or HTTP/2 will

expose the same four tools plus authentication requirements. HTTP binding is a v1.1 item. It will define session tokens, per-request identity, and transport-layer security requirements not present in the stdio binding.

  • WebSocket binding. A persistent-session WebSocket binding is v1.1+. It MAY support

server-sent events for live validation feedback and multi-op transaction grouping.

  • New tools. v1.1 MAY add: patch_block_list (batched atomic op list), transcript_read

(read-only transcript introspection), and describe_capabilities (sidecar descriptor query). These are not in scope for v1.0.

  • Capability enforcement. The patch_block tool does NOT consult the capability

descriptor sidecar (Annex A) in v1.0; all ops remain available to any caller. The descriptor is advisory — consumers MAY enforce it at the SDK layer. Tightening the MCP server to enforce descriptors is additive (new optional behavior). See Annex A.4 for the rationale.

This annex is covered by the compatibility promises of §1.2. See §1.4.

Annex C — Conformance suite

C.1 Purpose and scope

The conformance suite is the v1.0 specification's executable validator. Any implementation claiming "Noma Agent Protocol v1.0 compliant" MUST pass every fixture in the corpus defined in §C.5.

The suite exercises the following areas of the protocol:

  • Parsing. Input files MUST produce the expected canonical ID set and alias map.
  • Validation. Inputs that violate protocol invariants MUST produce the expected

diagnostic codes at the expected severities.

  • Source-to-source roundtrip. For valid inputs, renderNoma(parse(x)) MUST produce

the expected bytes, and parse(renderNoma(parse(x))) MUST produce a structurally identical AST to parse(x).

  • Span fidelity. For fixtures that declare expected.spans.json, every node-with-id

MUST have startLine and endLine matching the fixture's declared values.

  • Patch op semantics. Applying the op(s) in patch.json to input.noma MUST produce

byte-exact output matching expected.post.noma.

  • Patch error semantics. Fixtures that declare expected.error.json MUST cause the

reference patchSource to reject the op with a PatchError carrying the expected code (§3.5). This freezes the error-code surface alongside the happy-path op surface.

Passing the corpus confirms that every locked patch operation (§3.4), the validation contract (§4), the span guarantees (§6), the error-code surface (§3.5), and the roundtrip property all behave as specified.

C.2 Fixture directory layout

All fixtures live under examples/conformance/, organized into four tracks:

examples/conformance/
  valid/<name>/        — happy-path inputs; no validator errors expected
  invalid/<name>/      — inputs that exercise validator diagnostics
  patch/<name>/        — patch op application; at least one op in patch.json
  patch-error/<name>/  — patch ops that MUST be rejected with a known error code

A fixture is a directory containing input.noma plus zero or more expected-output files (see §C.3). The noma verify harness discovers fixtures by walking the corpus root and treating any directory that contains input.noma as a fixture.

Fixture names MUST be unique within the corpus (across all three tracks). Names MUST use lowercase ASCII letters, digits, and hyphens only. Fixture directories MUST NOT be nested more than one level below their track directory.

C.3 Expected-file vocabulary

Each expected-output file is optional unless otherwise noted. An implementation claiming v1.0 conformance MUST check every expected-output file present in a fixture.

input.noma — REQUIRED. The fixture's .noma source, read verbatim as UTF-8. The verify harness parses this file to produce the document AST used by all subsequent checks within the fixture.

expected.ids.json — OPTIONAL. A JSON object with schema:

{ "canonical": ["id1", "id2"], "aliases": { "id1": ["alias-a"] } }

canonical is the full list of canonical IDs in document-walk order (sorted before comparison). aliases is a map from canonical ID to the list of alias strings registered on that node. When present, the verify harness MUST compare the parsed document's ID set and alias map against this file. Order within each list is not significant; both sides are sorted before comparison.

expected.diagnostics.json — OPTIONAL. A JSON array of objects with schema:

[{ "code": "duplicate-id", "severity": "error" }]

When present, the verify harness runs the validator on the parsed document and MUST compare the resulting { code, severity } pairs against this array. Message text and source positions are NOT asserted — only code and severity. Both sides are sorted before comparison.

expected.roundtrip.noma — OPTIONAL. The exact byte sequence of renderNoma(parse(input.noma)). When present, the verify harness MUST:

  1. Compare the rendered output against this file byte-exactly.
  2. Re-parse the rendered output and compare the resulting AST against parse(input.noma)

structurally. This asserts the roundtrip property: parse(renderNoma(parse(x))) ≡ parse(x).

expected.spans.json — OPTIONAL. A JSON object mapping node ID to span:

{ "intro": { "startLine": 3, "endLine": 7 } }

Line numbers are 1-based and inclusive. When present, the verify harness MUST check every node-with-id against this map using the node's pos.line (startLine) and endLine fields. Nodes whose IDs are absent from the map are skipped — partial span assertions are valid.

patch.json — OPTIONAL. A single PatchOp object or a JSON array of PatchOp objects. When present (and expected.post.noma is also present), the verify harness MUST apply the op(s) to input.noma in order using the reference patchSource implementation, then compare the result against expected.post.noma byte-exactly.

expected.post.noma — OPTIONAL, REQUIRED if patch.json is present and expected.error.json is absent. The exact byte sequence of the document after all patch ops in patch.json have been applied. The verify harness MUST NOT pass a fixture that declares patch.json but omits both expected.post.noma and expected.error.json.

expected.error.json — OPTIONAL. A JSON object with schema:

{ "code": "target_missing" }

When present, the verify harness MUST apply the op(s) in patch.json and assert that application rejects with a PatchError whose code equals the declared value (one of the §3.5 error codes). A fixture that declares expected.error.json MUST NOT also declare expected.post.noma; the harness MUST fail the fixture if the patch succeeds, throws a non-PatchError, or throws a PatchError with a different code.

C.4 The noma verify CLI

Invocation:

noma verify <fixture-dir>

<fixture-dir> is the path to the corpus root (typically examples/conformance/). Nested discovery is recursive — the harness walks all subdirectories and treats any directory containing input.noma as a fixture.

Exit codes:

CodeMeaning
0All discovered fixtures pass.
1At least one fixture fails.
2<fixture-dir> argument is absent or the path does not exist.

Output format:

The harness emits one line per fixture, then a summary line:

PASS  examples/conformance/valid/basic-section
FAIL  examples/conformance/patch/rename_id  — span mismatch for "intro": got [3, 9], expected [3, 7]
SKIP  examples/conformance/valid/aliases

40 fixtures, 39 passed

Fixtures are sorted alphabetically by path before output. Output MUST be deterministic: given the same corpus, the same invocation MUST produce byte-identical output across runs.

The SKIP status is reserved for fixtures the harness cannot evaluate (e.g., a fixture directory that is missing input.noma entirely). Skipped fixtures are NOT counted as passed. A corpus with any skipped fixture SHOULD be treated as a configuration error by CI.

Wiring into CI:

Implementations MUST run noma verify examples/conformance as part of their standard test suite. The pages.yml workflow runs npm test which invokes the verify harness via the test suite. A non-zero exit code MUST fail the build.

C.5 Minimum corpus (v1.0)

The normative v1.0 minimum corpus is the set an implementation MUST pass to claim "Noma Agent Protocol v1.0 compliant." With the v1.0 freeze it covers the entire frozen surface: all parse/validate properties, the five core ops (§3.1), the extended ops (§1.5), and every error code reachable through them (§3.5). The corpus ships 40 fixtures.

This section lists the core fixtures; §C.5.1 lists the extended-op fixtures. Both groups are normative — an implementation MUST pass all 40.

FixtureExercises
valid/basic-sectionImplicit section IDs derived from heading slugs; paragraph spans.
valid/explicit-section::section{} directive with an explicit id= attribute; span exactness.
valid/aliasesAlias registration via heading {aliases="..."} and frontmatter; expected.ids.json alias map comparison.
valid/inline-tablePipe-table parsing, column alignment, inline markdown inside cells.
valid/code-fence-with-colonsFenced code blocks suppress :: directive detection inside their body (§3.9).
valid/frontmatter-onlyYAML frontmatter sub-node; document span when no block content follows.
patch/replace_blockreplace_block: target resolves, content replaces the addressed block byte-exactly.
patch/add_blockadd_block: new block inserted at the declared position relative to parent.
patch/delete_blockdelete_block: addressed block and its children removed; surrounding whitespace normalized.
patch/update_attributeupdate_attribute: named attribute updated on target node; id attribute is protected.
patch/rename_idrename_id: canonical ID changed; for=, [[wikilink]], and reference attributes retargeted; aliases= untouched.
patch/replay-chainMulti-op list applied in order; final state matches expected.post.noma; establishes transcript-replay equivalence.
patch-error/target_missingreplace_block addressing a non-existent id rejects with target_missing.
patch-error/parent_missingadd_block against a non-existent parent rejects with parent_missing.
patch-error/id_conflictrename_id onto an existing canonical ID rejects with id_conflict.
patch-error/invalid_contentadd_block whose content is not a parseable directive rejects with invalid_content.
patch-error/id_attribute_protectedupdate_attribute targeting the id key rejects with id_attribute_protected.
invalid/duplicate-idTwo nodes carry the same canonical id; validator emits duplicate-id at error severity.
invalid/missing-evidence-targetAn evidence directive references a non-existent claim ID via for=; validator emits a broken-reference diagnostic at error severity.

These 19 core fixtures cover the parse/validate properties (6 valid + 2 invalid), the five core ops plus a multi-op replay chain (6 patch), and the error codes reachable through the core ops (5 patch-error).

C.5.1 Extended-op fixtures (normative)

The remaining 21 fixtures exercise the §1.5 extended operations (20) and the baseHash precondition (1) — all normative as of v1.0. A v1.0-conformant implementation MUST pass these too.

FixtureExercises
patch/replace_bodyreplace_body: inner body replaced while the opening attribute line is preserved.
patch/update_headingupdate_heading: heading title replaced while an explicit id= stays stable.
patch/move_blockmove_block: block relocated under a new parent; fence depth re-normalized at the destination.
patch/remove_attributeremove_attribute: a non-id attribute removed source-preservingly.
patch/add_commentadd_comment: targeted ::comment block inserted with author metadata.
patch/resolve_commentresolve_comment: existing ::comment marked resolved without rewriting its body.
patch/add_footnoteadd_footnote: targeted ::footnote inserted near the referenced block.
patch/add_endnoteadd_endnote: targeted ::endnote inserted near the referenced block.
patch/add_change_requestadd_change_request: tracked insert/delete/replace revision block inserted.
patch/update_table_cellupdate_table_cell: one body cell rewritten; untouched rows preserved verbatim.
patch/update_table_header_cellupdate_table_header_cell: one header cell rewritten without touching body rows.
patch/insert_table_rowinsert_table_row: a body row inserted at the declared index.
patch/delete_table_rowdelete_table_row: a body row removed by zero-based index.
patch/insert_table_columninsert_table_column: a column (header + cells) inserted at the declared index.
patch/delete_table_columndelete_table_column: a column removed by index or header label.
patch/update_dataset_cellupdate_dataset_cell: one data cell rewritten inside an inline YAML row array.
patch/insert_dataset_rowinsert_dataset_row: a data row inserted at the declared index.
patch/delete_dataset_rowdelete_dataset_row: a data row removed by index.
patch/insert_dataset_columninsert_dataset_column: a schema field + per-row cell inserted.
patch/delete_dataset_columndelete_dataset_column: a schema field + per-row cell removed.
patch-error/sha_mismatchAn op whose baseHash precondition does not match the target block rejects with sha_mismatch.

The 19 core fixtures plus these 21 extended fixtures total 40. All fixtures use the same directory layout and expected-file vocabulary, so the same noma verify harness runs the entire corpus.

Corpus evolution rules:

  • Adding fixtures in a v1.x minor release is backward-compatible. Existing conformant

implementations remain conformant.

  • Removing fixtures from the minimum corpus requires a major version bump.
  • Fixture contents (expected-output files) MUST NOT change in a patch release. A

content change is at minimum a minor release; a change that causes a previously-passing conformant implementation to fail is a major release.

C.6 Adding a new fixture

To extend the corpus:

  1. Choose the appropriate track (valid/, invalid/, or patch/) based on what the

fixture exercises.

  1. Create a directory with a descriptive, lowercase-hyphenated name under that track.
  2. Add input.noma containing the fixture's source.
  3. Add the expected-output files (§C.3) relevant to what the fixture exercises. A fixture

MAY contain a subset of expected-output files — it need only assert the properties germane to its purpose.

  1. Run npm run noma -- verify examples/conformance to confirm the new fixture passes.
  2. Submit via pull request. Every corpus change MUST include a CHANGELOG entry under

[Unreleased] describing the fixture and what property it covers.

Fixture authors SHOULD prefer narrow fixtures: each fixture SHOULD exercise one protocol property clearly. Fixtures that attempt to cover many properties at once are harder to diagnose when they fail and harder to keep synchronized as the spec evolves.