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 |
|---|---|---|
|
yes |
Human-readable title, rendered as the page heading |
|
yes |
Array of section objects (may be empty) |
|
no |
String array for filtering (e.g. |
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 |
|---|---|---|
|
yes |
Lowercase-hyphen slug (e.g. |
|
yes |
Display heading for the section |
|
no |
Markdown body. Stored in the index as the node’s |
|
no |
Heading depth 2–6. Defaults to |
|
no |
Section-level tags (e.g. |
|
no |
Array of |
|
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 |
|
Function |
|
Doc nodes use the file path relative to docs/, with separators replaced by dots:
What |
Node ID |
|---|---|
Doc file |
|
Section |
|
Nested child section |
|
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 |
|
0 |
Child section |
|
1 |
Grandchild section |
|
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_stalenesschanges, 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.
Proxy linking: consumer docs link to docs, not raw code¶
Consumer-facing pages like this one describe capabilities, not individual functions. So instead of linking straight to code, they link to a dev-doc section that documents the capability — and that dev-doc section is what binds to the code. This forms a chain:
code function <--documents-- dev-doc section <--documents-- consumer-doc section
The same documents edge is used at every hop. Because axiom-graph propagates staleness transitively along these edges, the consumer page inherits drift without ever naming a function:
compute_staleness (changed)
↑ documents
staleness design::architecture LINKED_STALE via ...::compute_staleness
↑ documents
concepts/staleness::how-it-works LINKED_STALE via staleness design::architecture
Two things fall out of this:
Stability under renames. When a symbol is renamed or moved, the dev-doc layer absorbs the change. The consumer page rides the chain and keeps pointing at a stable doc target instead of a node ID that just moved.
Honesty without coupling. Consumer prose stays correct even though it never references code directly — the dev-doc proxy is the binding surface.
This is exactly how the page you’re reading is wired. The full mechanism — transitive propagation, the dev-doc proxy, and the publish loop — is the subject of the docs-honesty loop.
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 |
|---|---|
|
Render a document (or one |
|
Create a new DocJSON file and index it in one step. |
|
Whole-replace one section’s content, heading, or ID. Only that section is touched. |
|
Edit part of a section’s content without re-sending the whole body: append ( |
|
Append a section to an existing doc — optionally nested under a |
|
Remove a section and everything nested under it. |
|
Add or remove |
A few behaviors worth knowing:
Dot-path targeting. Pass a dot-path section ID (
database-layer.tables.indexes) toupdate_sectionand 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_idre-paths every child whose ID was prefixed by the old slug.Auto-slug. If you omit a section’s
idinwrite_doc, axiom-graph derives a slug from the heading ("Database Layer"→database-layer). Set an explicitidonly when you want a particular slug for cross-references.Partial edits over whole-replace.
update_sectionalways replaces the whole section body;patch_sectionappends, 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$, orCtrl-^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.