DocJSON: structured docs where a section is its own node

A section is its own node

axiom-graph stores documentation as structured JSON (“DocJSON”), one .json file per document in your project’s docs/ directory. The content inside a section is still plain markdown. The JSON is just the envelope that gives axiom-graph the structure it needs to do the one thing plain markdown can’t: treat each section as its own node in the graph.

That single design choice is what this whole page is about. When a section is a node, axiom-graph can:

  • Link that section (not the whole document) to the exact code it describes.

  • Detect drift at section granularity (when that code changes, that section goes stale, not the whole file).

  • Return that section alone when an agent or human asks for it.

This is the core of axiom-graph’s value: shrinking what anyone has to load. An agent reads one section, not the whole document, the same way reading source returns a function by line range instead of the whole file. A doc section is a first-class node in the mesh (see also the ontology), so the two reads — retrieval and drift — apply to it like any other node.

This page covers the shape of a DocJSON file, how sections nest, how a section binds to code, and how that binding keeps consumer docs honest through a proxy chain.

The shape of a document

A DocJSON file has three top-level keys:

Key

Required

Description

title

yes

Human-readable title, rendered as the page heading

sections

yes

Array of section objects (may be empty)

tags

no

String array for filtering (e.g. "consumer", "guide")

A minimal document:

{
  "title": "Data Model",
  "tags": ["database", "schema"],
  "sections": [
    {
      "id": "nodes-table",
      "heading": "Nodes Table",
      "content": "Every indexed entity is a row in the `nodes` table. Each node has a unique `id`, a `node_type`, and summary text fields."
    },
    {
      "id": "edges-table",
      "heading": "Edges Table",
      "content": "Relationships are stored in the `edges` table. Each edge has a `from_id`, `to_id`, and `edge_type`."
    }
  ]
}

Each section object:

Key

Required

Description

id

yes

Lowercase-hyphen slug (e.g. "nodes-table"). Becomes part of the section’s node ID.

heading

yes

Display heading for the section

content

no

Markdown body. Stored in the index as the node’s level_2 field.

level

no

Heading depth 2–6. Defaults to depth + 2 (top-level ##, child ###, grandchild ####).

tags

no

Section-level tags (e.g. ["deprecated"]), independent of document tags.

links

no

Array of {"node_id": "..."} objects connecting the section to code or doc nodes.

sections

no

Nested child sections (up to 3 levels deep).

Note what is not a key: the document’s identity. A document’s node ID comes from its file path relative to docs/, not from any field in the JSON. docs/concepts/docjson.json becomes the node ID myproject::docs.concepts.docjson. You never set node IDs by hand.

How node IDs work

Every node in the graph has a unique ID derived from its location. Understanding the format helps when you add links or query the graph.

Code nodes follow {project}::{dotpath}::{name}:

What

Node ID

Module axiom_graph/db/nodes.py

axiom_graph::axiom_graph.db.nodes

Function upsert_node in it

axiom_graph::axiom_graph.db.nodes::upsert_node

Doc nodes use the file path relative to docs/, with separators replaced by dots:

What

Node ID

Doc file docs/architecture.json

axiom_graph::docs.architecture

Section overview in it

axiom_graph::docs.architecture::overview

Nested child section

axiom_graph::docs.architecture::overview.subsection

The part after :: is the section path. For a top-level section it’s just the slug; for nested sections it’s a dot-path (covered next). To discover existing node IDs, use axiom_graph_search or axiom_graph_list — never invent them.

Nested sections (depth up to 3)

A section can recursively contain sub-sections via an optional sections key. This lets you break a large topic into focused, individually addressable pieces without fragmenting it into a separate document — keeping related content together while still giving each piece its own node.

{
  "id": "database-layer",
  "heading": "Database Layer",
  "content": "Overview of the DB design.",
  "sections": [
    {"id": "tables", "heading": "Tables", "content": "Core table definitions..."},
    {"id": "migrations", "heading": "Migrations", "content": "How migrations work..."}
  ]
}

Dot-path node IDs. Nested sections use dot-separated paths after the :: separator, mirroring how axiom-graph names hierarchical code nodes (module.class.method):

What

Node ID

Depth

Top-level section

axiom_graph::docs.architecture::database-layer

0

Child section

axiom_graph::docs.architecture::database-layer.tables

1

Grandchild section

axiom_graph::docs.architecture::database-layer.tables.nodes-table

2

Depth limit: 3 levels (depth 0, 1, 2). The scanner warns and ignores a sections key on a depth-2 node. If you need to go deeper, that’s the signal to split into a separate document.

Containment becomes graph edges. Nesting emits parent-to-child composes edges, so the hierarchy is queryable like any other relationship — depth is just traversal distance. Heading level auto-maps to depth (## / ### / ####), and an explicit level still overrides.

Backward compatible. A section with no sections key is a leaf, exactly like the original flat format. Existing documents work with zero changes — nested sections are a strict superset.

Staleness flows down the tree. A parent section is marked LINKED_STALE when any child is stale, so you can trace drift from a document down to the exact sub-section that needs attention. See staleness.

Linking a section to code

The point of a section being a node is that it can be wired to the exact code it describes. Each entry in a section’s links array creates a documents edge from the section node to a code node:

{
  "id": "staleness-engine",
  "heading": "Staleness Engine",
  "content": "The staleness engine compares code hashes to detect when docs are out of date.",
  "links": [
    {"node_id": "axiom_graph::axiom_graph.index.staleness::compute_staleness"}
  ]
}

That one edge type, documents, does double duty — it is what axiom_graph_read_doc follows to show linked code summaries beneath a section, and it is what the staleness engine follows to flag the section when that code changes. One mesh, two reads. (Edge types are defined in the ontology.)

The payoff of section-granular linking is precision in both directions:

  • Precise context. An agent that needs to understand staleness reads the one section linked to compute_staleness, not the entire architecture document.

  • Precise staleness. When compute_staleness changes, that section is flagged — not the whole file, not its siblings. You know exactly which prose to re-check.

What to link. Link public functions, classes, decorators, and entry points the section explicitly describes — anything whose contract a code change could invalidate. The quick test: imagine someone rewrites the linked function; would a reader need to re-check this section? If yes, link it. If no, skip it. Do not link private helpers the section never mentions, or whole modules cited only for orientation — every link is a staleness trigger, and over-linking creates noise.

Editing sections through MCP

Because each section is a node, you patch one section at a time rather than rewriting a file. The MCP server is the primary surface for this — agents (and humans using an MCP client) manage docs without hand-editing JSON.

Tool

What it does

axiom_graph_read_doc

Render a document (or one section) as markdown, with node IDs annotated in comments.

axiom_graph_write_doc

Create a new DocJSON file and index it in one step.

axiom_graph_update_section

Whole-replace one section’s content, heading, or ID. Only that section is touched.

axiom_graph_patch_section

Edit part of a section’s content without re-sending the whole body: append (anchor="$"), prepend (anchor="^"), or Edit-style unique-match replace (old_string).

axiom_graph_add_section

Append a section to an existing doc — optionally nested under a parent_id or positioned after a sibling — without rewriting the file.

axiom_graph_delete_section

Remove a section and everything nested under it.

axiom_graph_add_link / axiom_graph_delete_link

Add or remove documents edges from a section to code nodes (batch-capable).

A few behaviors worth knowing:

  • Dot-path targeting. Pass a dot-path section ID (database-layer.tables.indexes) to update_section and the nested target resolves correctly. The fully qualified form (axiom_graph::docs.foo::database-layer.tables.indexes) works anywhere a section ID is accepted.

  • Renames cascade. Renaming a section in-place via new_id re-paths every child whose ID was prefixed by the old slug.

  • Auto-slug. If you omit a section’s id in write_doc, axiom-graph derives a slug from the heading ("Database Layer"database-layer). Set an explicit id only when you want a particular slug for cross-references.

  • Partial edits over whole-replace. update_section always replaces the whole section body; patch_section appends, prepends, or spot-replaces. The append/prepend modes skip the read-modify-write round trip — handy (and clobber-safe) for accreting sections like changelogs and ledgers. The ^/$ anchors are out-of-band parameters, so a body full of $VAR, $x^2$, or Ctrl-^ is never mis-parsed.

The human path (the CLI and viz) is secondary; for hand-authoring the raw format, see the configuration and CLI guides.

Saving a section verifies it

Saving a section through the MCP write tools (update_section, add_section, write_doc, the link tools) does more than rewrite JSON — it records a verification snapshot for any existing section node whose content or heading actually changed. axiom-graph compares each section’s stored hashes before and after the write; for every existing node whose code_hash (prose body) or desc_hash (heading) differs, it emits an AGENT_VERIFIED history row at the new hash.

The practical effect: the writer is the verifier. If the section you just edited was LINKED_STALE, that flag clears as a side effect of the save, because the new snapshot is now newer than the linked code’s last change. You don’t run a separate “mark clean” step for your own edit.

The scope is deliberately narrow:

  • Only existing nodes that actually changed are candidates. Brand-new sections (created by write_doc) are default-clean via the normal first-index path — no spurious verification.

  • Only the saved section clears. Parents, siblings, and the linked code nodes keep their own status; they get their own snapshots only when verified separately. (This preserves the sticky-LINKED_STALE invariant for everything you didn’t touch — see staleness.)

This is the mechanism behind the docs loop: edit the stale section, the save clears it, re-render the site. Clearing is a deliberate verification act, not an accidental side effect of any file write.

Build reconciles edges to the JSON

The links array in the JSON is the source of truth for a section’s documents edges. But JSON gets edited outside the MCP tools too — a raw editor, a bulk find-and-replace, a merge. To keep the graph from drifting away from the files, axiom_graph_build reconciles documents edges against the JSON links on every build.

For each section walked during a build, axiom-graph enforces that the DB’s set of documents edges for that section equals the section’s JSON links set exactly — including the empty set. Orphan edges left behind by external edits are deleted (recorded in history as LINK_REMOVED), and missing edges are added. The reconciler runs after stale-node purging and before broken-link detection, so a build leaves the mesh matching what’s actually on disk.

The takeaway for authors: the JSON is canonical. Hand-edit links if you like; the next build makes the graph agree. You don’t have to manually clean up edges after editing files directly.

Authoring a document, start to finish

Putting it together, the loop for writing and maintaining a DocJSON document:

  1. Create a .json file in docs/ (lowercase-hyphen names; subdirectories are fine). At minimum supply title and sections. Or create it in one step with axiom_graph_write_doc.

  2. Build with axiom_graph_build so axiom-graph indexes the new sections as nodes.

  3. Read back with axiom_graph_read_doc to confirm the rendered output and that linked node summaries appear under each section.

  4. Find gaps with axiom_graph_list_undocumented — code nodes with no inbound documents edge from any section.

  5. Link sections to the code (or dev-doc) they describe, via links in the JSON or axiom_graph_add_link.

  6. Check with axiom_graph_check to see which sections are LINKED_STALE (linked node changed) or BROKEN_LINK (linked node gone), then update the affected sections — which, by the writer-is-verifier rule above, clears them.

That last step is the steady state: code drifts, check surfaces exactly the sections that drifted with it, you fix the prose, the save clears the flag. The mesh stays trustworthy because staleness is the read that keeps it that way. To see the full publish-and-republish cycle for a site built on these docs, see the docs-honesty loop.