Manual
Reference.
Seven tools, three environment variables, six error codes, and a handful of hard limits. All of it fits on one page, which is the point.
01 / Data model
What a document becomes.
You store documents. Everything else is derived, lives in the same file, and is removed with the document it came from.
store_document
|
+--> docs .......... id, title, content, tags, sha256, timestamps
|
+--> chunks ........ 800 codepoints, stride 700, so 100 overlap
| |
| +--> chunks_fts .. FTS5 trigram ............. stage 1
| |
| '--> vec_chunks .. sqlite-vec vec0 (soon) ... stage 2
|
+--> edges ......... src, rel, dst, doc_id ......... stage 2
|
'--> audit_log ..... request_id, args, rows, duration_ms
Chunking is deterministic: the same text always produces the same chunks, so the
same query returns the same result. Deleting a document removes its chunks,
full-text rows, vectors, and edges in the same transaction, and the database runs
with secure_delete on, so the bytes are overwritten rather than left in
free pages.
02 / Configuration
Three environment variables.
| variable | default | meaning |
|---|---|---|
| HADANO_DB | hadano.db | Path to the database file. Created on first use. Set this — the default is relative to the working directory. |
| HADANO_EMBEDDINGS | off | off or local. With local the vec0 extension is loaded and the vector tables are created. |
| HADANO_REQUEST_LOG | full | full, redacted, or off. Controls how much of each call's arguments is written to the audit log. |
Connection settings
These are fixed by the specification and not configurable, because changing them changes correctness rather than preference.
| pragma | value | why |
|---|---|---|
| journal_mode | WAL | Readers do not block the writer |
| foreign_keys | ON | Derived rows cannot outlive their document |
| busy_timeout | 5000 ms | Wait rather than fail on a brief lock |
| secure_delete | ON | Deleted content is overwritten, not orphaned |
03 / Tools
The seven tools.
Every response carries a request_id. Every call, successful or not,
is written to audit_log with its arguments, row count, and duration.
store_document
Save a document, or replace one with the same ID.
| parameter | type | default |
|---|---|---|
| title | string | required |
| content | string | required |
| id | string | generated |
| tags | list of string | none |
| relations | list of {src, rel, dst} | none |
{ "id": "8abacdf04d1a49b28f84347cb1d94180",
"chunks": 2,
"sha256": "aea22d7ff5920020d108b1f44cc0473e..." }
Relations for a document are replaced wholesale on every save. If you
store the same document again with no relations, its edges are removed.
search_documents
Stage 1. Keyword search over FTS5 with BM25 ranking. No model, no network.
| parameter | type | default |
|---|---|---|
| query | string | required |
| top_k | integer | 10 |
| max_chunks_per_doc | integer | 3 |
{ "documents": [
{ "doc_id": "8abacdf0...",
"title": "Deployment runbook",
"score": 0.0325,
"matched_chunks": [
{ "chunk_id": 2, "seq": 1, "text": "..." } ] } ] }
A document's score is capped by max_chunks_per_doc, so
one long document cannot crowd out the rest by matching in many places. Query text is
treated as data, never as instructions.
search_knowledge
Stage 2. Expands from chunks that stage 1 actually matched — at most three seeds. Never searches globally.
| parameter | type | default |
|---|---|---|
| seed_chunk_ids | list of integer, at most 3 | required |
| top_k | integer | 10 |
| mode | auto | vector | graph | both | auto |
{ "related_chunks": [],
"related_edges": [
{ "src": "runbook", "rel": "cites",
"dst": "rollback-policy",
"doc_id": "8abacdf0...", "source": "graph" } ],
"truncated": false }
auto resolves by what the database actually has. With
HADANO_EMBEDDINGS=off it resolves to graph traversal. Vector expansion is
specified and its query path exists, but nothing generates embeddings yet, so it
returns nothing today.
query_graph
Walk the relation graph outward from a node, in both directions, up to three hops.
| parameter | type | default |
|---|---|---|
| node | string | required |
| depth | integer, 1 to 3 | 1 |
| rel | string | all relations |
{ "nodes": ["database", "hadano"],
"edges": [
{ "src": "hadano", "rel": "is_a",
"dst": "database", "doc_id": "8abacdf0..." } ],
"truncated": false }
Results are capped at 500 edges. When the cap is hit,
truncated is true — it is never silently dropped.
get_document
Read a document back whole, by ID.
get_document(id = "8abacdf0...") { "id", "title", "content", "tags", "sha256", "created_at", "updated_at" }
delete_document
Remove a document and everything derived from it, in one
transaction. An unknown ID is E_NOT_FOUND, not a silent success.
db_status
Counts, versions, and the integrity check result recorded at startup.
{ "db_path", "size_bytes",
"docs", "chunks", "edges", "vectors",
"embeddings_mode", "integrity_at_boot",
"sqlite_version", "vec_version" }
04 / Limits
Hard limits.
Exceeding any of these is E_TOO_LARGE. The set is closed — no
other size check raises it.
| field | limit | counted in |
|---|---|---|
| content | 1,048,576 | UTF-8 bytes |
| title | 512 | codepoints |
| query | 1,024 | codepoints |
| tags | 20 | items |
| each tag | 64 | codepoints |
| relations | 100 | items |
| src, dst | 128 | codepoints |
| rel | 64 | codepoints |
Chunking and result caps
| what | value |
|---|---|
| chunk window | 800 codepoints |
| chunk stride | 700 codepoints |
| chunk overlap | 100 codepoints |
| seeds accepted by search_knowledge | 3 |
| graph traversal depth | 3 hops |
| edges returned by query_graph | 500 |
| per-call time budget | 2,000 ms |
05 / Errors
Six codes, and no more.
The set is closed. Adding a seventh is forbidden by the specification, and the implementation refuses to construct an error outside it. Messages never contain internal paths, SQL, or stack traces — those go to the server log only.
| code | raised when |
|---|---|
| E_VALIDATION | Wrong type, wrong format, out of range, duplicate, or an unknown mode |
| E_TOO_LARGE | One of the size limits above is exceeded. Nothing else raises it |
| E_NOT_FOUND | The document ID or seed chunk ID does not exist |
| E_TIMEOUT | The call passed its time budget, or a lock outlasted busy_timeout |
| E_INTEGRITY | A database invariant is broken — a failed integrity check at startup, a missing vector, a restore that does not match its header |
| E_INTERNAL | Anything else. If you see this, it is a bug worth reporting |
06 / Command line
Outside the client.
Four entry points. None of them need the MCP server to be running.
# start the MCP server yourself (your client normally does this) python -m hadano.server # consistent snapshot, safe to take during writes python -m hadano.backup --db ~/hadano.db --out ~/backups/ # export everything as JSONL, one document per line python -m hadano.dump --db ~/hadano.db --out cabinet.jsonl # import into a database file python -m hadano.dump --restore --db ~/new.db --in cabinet.jsonl # reproduce the published numbers python -m hadano.bench --scale 100000 --reps 20 --db ~/bench.db
What restore does, exactly
- Replaces documents that have the same ID
- Restores original timestamps, rather than stamping the import time
- Rebuilds chunks, full-text rows, and edges from the document
- Checks the restored counts against the dump header, and stops on a mismatch
- Is idempotent — running it twice changes nothing the second time
What restore does not do
- Delete documents that are absent from the dump
- Propagate a deletion made on another machine
A dump is a state, not a history. That makes it correct for backup and migration, and wrong as a two-way sync: restoring an older dump brings back documents you deleted. Carrying deletions requires a record that a deletion happened, which is a separate design.
07 / Operating notes
Things worth knowing.
- One writer. A local database has a single writer. Tool calls inside one server process are serialized, so concurrent calls from your client are safe
- Every call is logged.
audit_logholds the request ID, tool, arguments, row count, duration, and outcome. SetHADANO_REQUEST_LOG=redactedto keep the shape without the content - Search input is data. Query text is never interpreted as an instruction, and results carry no privileged meaning
- Integrity is checked at startup and the result is reported by
db_status, so a corrupted file is visible rather than silently wrong - Cold start on a 50 MB database is 954 ms including that check. After that, document search is 1.79 ms at the 95th percentile
The behaviour above is fixed by a specification that pins every value range, SQL statement, error code, and acceptance test, so it can be rebuilt from the document alone. If something here disagrees with what the software does, the software is wrong.