# nodum

> DB-native knowledge graph: a typed graph of Markdown-content nodes and typed edges in **one SQLite file** — not files with an index over them. Every mutation flows through a deterministic, LLM-free service layer that validates it, moves it through a `proposed → active → archived` state machine, appends it to an event log with full before/after payloads, snapshots a node version, and leaves it reversible via `undo`. `[[wikilinks]]` in content materialize as `mentions` edges on write. Retrieval is hybrid search: an FTS5 BM25 index and a sqlite-vec chunk-embedding index (local in-process model, no daemon and no API key), fused by reciprocal rank fusion and re-ranked by graph expansion. The graph also maintains itself: an internal agent runs deterministic consolidation jobs inside a **cycle**, and a human can take a whole cycle back with one command. Four surfaces share that one store — a JSON-emitting CLI, an HTTP API, an MCP server for external agents, and a React web UI. Installable from PyPI with `pipx install nodum`; there is no server to run and no database URL.

## Docs

- [Overview](https://nodum.vcoeur.com/): what nodum is, why one file, what makes it agent-native, and the current phase status.
- [Install](https://nodum.vcoeur.com/install/): `pipx install nodum`, the optional `embeddings` and extraction (`pdf`/`ocr`/`audio`) extras, database path resolution, and verifying an install.
- [Quick start](https://nodum.vcoeur.com/quick-start/): build a graph, watch a wikilink become an edge, ingest a folder of documents, search it, read its history, then the agent proposal flow.
- [Concepts](https://nodum.vcoeur.com/concepts/): typed graph, the state machine, the event log, actors and privilege, consolidation cycles and the gardener, projectors, assets, and why the surfaces cannot drift.
- [Architecture](https://nodum.vcoeur.com/architecture/): the service spine and the thin adapters over it, module by module.
- [Commands](https://nodum.vcoeur.com/commands/): the full CLI surface plus the conventions every command shares.

## For agents

- **Self-orient with `nodum schema-dump`.** It prints this CLI's entire command tree as JSON — every command, its one-line help, its parameters, and one level of subcommands. It needs no database, so it works immediately after install. Note it is a different thing from `nodum schema <type>`, which reports one *node or edge type's* catalog entry read from the database.
- **Every command prints exactly one JSON object on stdout** and nothing else on the success path, so parse stdout directly. There is no `--json` flag — JSON is the only output format. Human-facing messages and errors go to stderr. Errors are always one line with exit 1, never a traceback.
- **List commands wrap rows in a named key plus a count**: `{"nodes": [...], "count": 2}`. The HTTP API returns the same envelope bytes, so a client parses one shape whichever surface it talks to.
- **You are not a human, and the surfaces enforce that.** The CLI is human-only: every command that touches the graph requires `--as human:<id>` — reads included, since reads are grant-scoped too — and there is no way to name an agent on it. You drive the MCP server, which authenticates you with the agent token in `NODUM_AGENT_TOKEN` — minted by `nodum agent create <name>` (shown once, stored hashed; the human carries it in the MCP client config's env block, never on a command line). Your writes land per your grants: `suggest` on a space queues them as `proposed` in the review queue, `edit` lands them `active`. There is no auto-accept mechanism — an agent earns `edit`, or it waits.
- **Editing does not overwrite — under `suggest`.** An agent `node update` on a `suggest` grant stages a `proposed` *version* recording which fields it named. Accepting applies **only those fields** to the node as it stands at that moment, so a human edit made while your proposal waited is not reverted. **On an `edit` grant the same call overwrites the node in place**, which is why the MCP `update_node` tool is annotated destructive while the additive tools are not.
- **Say which space you are writing into.** `create_node` takes a `space` (an id or a name; `main` when you omit it), as `ingest_file`, `ingest_url` and `request_upload_url` do. Name one you hold no grant on and it is refused in the same words as a space that was never created — that identical wording is deliberate, so a refusal is never an existence oracle. Every write answers with the `space_id` it actually landed in: check it rather than assuming, because a keyword this server does not declare is **discarded rather than refused**, so a misspelt argument is silent.
- **Your `[[wikilinks]]` cross spaces only as far as your grants do.** A link into a space you may only suggest in stays a `proposed` `mentions` edge even when the node carrying it is live, and a reviewer who holds nothing on that space cannot activate it by accepting your node. Equally, saving a node never retires a mention into a space you cannot read — an unresolvable link is not a removed one.
- **Review authority is a human, or `edit` on the item's space.** `accept`, `reject`, `archive`, every `review` subcommand and the whole curative tier (`merge_nodes`, `retype`, `supersede_edge`, `bulk_relink`, `consolidate`) are limited to those; `undo`, `rollback` and `cycle-abandon` stay human-only, and the consolidation journal (`cycle-list`, `cycle-get`) is human-only as well — it says what the gardener did across every space in the file, and reading it would tell you the shape of territory you hold no grant on. Account and grant administration (`nodum human …`, `nodum agent …`, `nodum grant/revoke/grants`, `nodum space-*`) is human-only too — you cannot mint or re-level your own access.
- **Over MCP the read and additive tiers are the whole registry.** The review tools, the curative tools, and reversal with the journal that records it (`undo`, `rollback`, `abandon_cycle`, `get_cycle`, `list_cycles`) are never registered — they are absent from the tool list, not refused at call time — so whatever your grants, you can propose but never dispose over MCP. This is structural, not a filter — do not try to route around it. The curative tier is **built and in use** on the CLI; keeping it off this surface is a standing decision, not a gap waiting to be filled. One call there could merge two nodes or rewrite five hundred edges, and the only thing that takes those back is a human's rollback.
- **A grant is a ceiling, not a mandate.** Holding `edit` does not oblige you to write live. If you are unsure of something, file it `proposed` and put it in front of a human — the service supports exactly that, and the internal gardener uses it for everything it infers. What you cannot do is ask to land *above* your grant: that is refused outright rather than quietly downgraded, so a state you named is always the state you got or an error you can read.
- **You are not the only agent writing here.** An internal agent (`agent:builtin-gardener`) runs consolidation cycles — one at a time against a file, guarded by the journal itself rather than by a lock in any one process, so a cycle can be in progress while you are reading and writing — and files `proposed` `duplicate_of` and `relates_to` edges for a human to review, prunes exact-duplicate edges and edges incident to archived nodes, and reports neglected nodes. Its edges are ordinary proposals with an ordinary actor — you will see them in reads and in the review queue, and they are suggestions rather than assertions. It also archives edges the pruning rules match, so an `active` edge you created may legitimately be archived by someone other than the human.
- **A write inside a cycle is reversed differently.** Every event carries an optional `cycle_id`. One with none is reversed by `undo`; one with a cycle id is reversed only by a human's `rollback <cycle-id>`, whole — because a curative operation writes several rows from one decision and reversing one of them would leave the other half standing. A bare `undo` with no sequence number gets that same refusal when the newest event belongs to a cycle: it names the cycle rather than reaching past it to an older event, and it names `rollback <cycle-id>` and no other verb — a reversal that reaches past a cycle is exactly what the refusal exists to stop, so it never points at one. Nothing you do over MCP writes inside a cycle, but events you read may belong to one.
- **The HTTP API is the human's surface.** It runs on password-login sessions (`POST /api/login`, server-side session rows, an `HttpOnly; SameSite=Strict` cookie), every `/api` route but login requires one, and no request field, header, or query parameter can set an identity. There is no agent authentication there by design. It carries the human's own controls and nothing of yours: the **space filter** (`?space=` on the node listing and search, plus the space lifecycle under `/api/spaces`), and the **consolidation journal** (`GET /api/cycles`, `POST /api/cycles` to run one, `GET /api/cycles/{id}`, `POST /api/cycles/{id}/abandon`, `POST /api/cycles/{id}/rollback`). That filter narrows what a human chooses to look at and is not a permission boundary — yours is your grant set, which applies underneath it and is the only thing that decides what exists for you. One thing on that surface *does* change your grant set: **archiving a space makes every grant on it inert**, so a space the human retires stops existing for you entirely — its nodes included, even ones you hold ids for — until the archive is undone. Your grant row survives, so this is not a revocation you can detect by asking; treat a space that stops resolving as gone.
- **Search is hybrid.** `nodum search <query>` returns `{query, k, hits}` where each hit carries `node_id`, `space_id`, `type`, `title`, `snippet`, `score`, and a per-signal `signals` breakdown (`bm25`, and `vector` when the embedding extra is installed). Without that extra, ranking degrades to BM25 rather than failing. A result list spans every space in scope unless `--space` narrowed it, which is why each hit names its own.
- **Derived indexes are projections of the event log**, so they can lag. `nodum projector status` reports each projector's checkpoint, backlog, and availability; `nodum projector run` applies pending entries; `nodum projector rebuild <name>` replays from event 0.
- **Graph reads**: `node get <id> --depth N` for a neighbourhood, `traverse`, `subgraph <root-id>` (bounded by node and edge caps, with a `truncated` flag), `find-path`, and `suggest-links <prefix>` for `[[` autocomplete — the last reads `nodes` directly, so it answers on a database whose projectors have never run.
- **Assets**: registration is content-addressed by sha256 and idempotent. `asset get` returns metadata and never bytes; `asset rendition` returns a generated, cached `thumb`, `preview`, or `page:<n>` (a 1-based page of a PDF, rasterised — this is how you *look at* a document whose layout, tables, or figures carry the meaning). **Agents receive derived representations, never original bytes over MCP** — `get_asset` hands you metadata, the extracted text, and a rendition. The one documented exception is `get_download_url`, which mints a single-use, minutes-long URL to the real file; both the mint and the redemption are written to the event log under your identity, so reaching for it is on the record. Prefer a page raster or the extracted text when either would do. An asset is as reachable as its describing nodes: you can read it if you can read an active `asset_ref` node carrying its hash, and bytes nobody has described yet are invisible to you.
- **Ingestion turns a document into a subgraph.** `ingest_file` and `ingest_url` over MCP (CLI: `nodum ingest file|url`) register the bytes, extract the text, and write an `asset_ref` node for the bytes, a `source` node holding the text, a `derived_from` edge, and one `block` per page. Every one of those is an ordinary `create_node`/`create_edge`, so the whole subgraph lands per your grant — `suggest` queues it all for review. Ingestion is **by reference**: pass a path this server can read or an `http`/`https` URL it can fetch; no base64 crosses MCP. If you share no filesystem with the server, ask `request_upload_url` for somewhere to PUT the bytes instead — that redemption *ingests*, so it answers with the whole subgraph rather than a bare asset, and it is typed by the **bytes** rather than by the `mime` you declared at mint time: images, PDF, the common audio containers and text are admitted, and anything else (a `.docx`, a `.zip`, a renamed binary) is refused, because over the network the server takes a file from a stranger. The type test reads the *bytes*: a leading signature, or — for text — a check that neither the first nor the last 4 KiB looks binary, which is a heuristic rather than a guarantee that a renamed binary cannot get through. A declared size above the server's ceiling is a **413** at the mint, before anything moves, and a target space that no longer resolves is refused before any bytes are stored, so an upload that failed really did store nothing. A type the network surface will not take goes in through `nodum ingest file` on the server, where the operator owns the file; a refused PUT also **spends its token**, so retry by minting a new one rather than by resuming. It is idempotent per (hash, space), so re-ingesting the same document answers `created: false` rather than duplicating it — and that answer reports the page blocks the document already has, the cap it hit included, rather than every child the source node happens to carry. Extraction handlers are optional per format and **an absent one is a reported result, not an error** — the asset is still registered and described, and the answer says no text came out; `nodum ingest handlers` says which formats an install can read. Ingestion proposes sources and structure only: it does **not** extract claims.
- **A row cap below 1 is an error, everywhere one is taken** — `node list`, `edge list`, `events`, `review queue`, `suggest-links`, `subgraph`, `cycle-list`, and search's `k`. Do not pass 0 or a negative number to mean "no cap": there is no spelling for that, and the two things it used to do were both wrong quietly — a negative number reaching SQL returned *everything*, and one applied as a Python slice dropped that many rows off the end and answered as if nothing were missing. The refusal names the parameter you typed.
- **`--set key=value` is repeatable**, and values are parsed as JSON with a raw-string fallback: `--set year=1815` yields an integer, `--set venue=Nature` a string.
- **A rejection needs a reason.** Both `reject <id> --reason` and `review reject … --reason` require it and record it in the reject event's payload.

## Optional

- [Source on GitHub](https://github.com/vcoeur/nodum)
- [nodum on PyPI](https://pypi.org/project/nodum/)
