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.

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.

variabledefaultmeaning
HADANO_DBhadano.dbPath to the database file. Created on first use. Set this — the default is relative to the working directory.
HADANO_EMBEDDINGSoffoff or local. With local the vec0 extension is loaded and the vector tables are created.
HADANO_REQUEST_LOGfullfull, 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.

pragmavaluewhy
journal_modeWALReaders do not block the writer
foreign_keysONDerived rows cannot outlive their document
busy_timeout5000 msWait rather than fail on a brief lock
secure_deleteONDeleted 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.

parametertypedefault
titlestringrequired
contentstringrequired
idstringgenerated
tagslist of stringnone
relationslist 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.

parametertypedefault
querystringrequired
top_kinteger10
max_chunks_per_docinteger3
{ "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.

parametertypedefault
seed_chunk_idslist of integer, at most 3required
top_kinteger10
modeauto | vector | graph | bothauto
{ "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.

parametertypedefault
nodestringrequired
depthinteger, 1 to 31
relstringall 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.

fieldlimitcounted in
content1,048,576UTF-8 bytes
title512codepoints
query1,024codepoints
tags20items
each tag64codepoints
relations100items
src, dst128codepoints
rel64codepoints

Chunking and result caps

whatvalue
chunk window800 codepoints
chunk stride700 codepoints
chunk overlap100 codepoints
seeds accepted by search_knowledge3
graph traversal depth3 hops
edges returned by query_graph500
per-call time budget2,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.

coderaised when
E_VALIDATIONWrong type, wrong format, out of range, duplicate, or an unknown mode
E_TOO_LARGEOne of the size limits above is exceeded. Nothing else raises it
E_NOT_FOUNDThe document ID or seed chunk ID does not exist
E_TIMEOUTThe call passed its time budget, or a lock outlasted busy_timeout
E_INTEGRITYA database invariant is broken — a failed integrity check at startup, a missing vector, a restore that does not match its header
E_INTERNALAnything 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_log holds the request ID, tool, arguments, row count, duration, and outcome. Set HADANO_REQUEST_LOG=redacted to 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.