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. The one exception is the audit log, which is kept beside the file on purpose.
store_document
|
+--> docs .......... id, title, content, tags, sha256, timestamps
|
+--> chunks ........ 800 codepoints, stride 700, so 100 overlap
| |
| +--> index ....... character trigrams ...... stage 1
| |
| '--> vectors ..... 384-dim (opt) ........... stage 2
|
'--> edges ......... src, rel, dst, doc_id ......... stage 2
<db>-log/ ............. audit, beside the file on purpose
|
'--> one dated file per call day: request_id, tool,
argument shape, 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, index entries, vectors, and edges in one transaction, so nothing derived outlives the document.
Note what deletion does not promise. The freed space returns to the file's free list and is reused, but the old bytes are not overwritten. If the content has to be unrecoverable from the file itself, encrypt the disk.
Not available yet. The package is not on PyPI, so pip install hadano-ai-cabinet fails today. This reference describes the tools as implemented; it is not something you can install right now. See /facts.json (installable: false).
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 only in this version. Not in this version — vector search was built against the storage layer that has since been replaced, and has not been reconnected. Setting it is refused at startup rather than silently ignored. |
| HADANO_REQUEST_LOG | redacted | full, redacted, or off. Controls how much of each call's arguments is written to the audit log. The default records argument shapes and hashes, not contents; full keeps the first 1,000 characters of each string argument. |
Vector search
Not connected in this version. It was built against the
storage layer that has since been replaced. Setting
HADANO_EMBEDDINGS to anything but off is refused when
the server starts, with the reason, rather than accepted and quietly ignored.
The settings below are what it is fixed to when it returns. They are listed because they are not configurable and never were — changing any of them would invalidate every stored vector. On the layer where it was measured, it installed 25 more packages, downloaded a 240 MB model, and took resident memory from 22 MB to 604 MB. Keyword and graph search never touched a vector.
| fixed value | why it is fixed |
|---|---|
paraphrase-multilingual-MiniLM-L12-v2 | The model is not configurable. Changing it invalidates every stored vector |
| 384 dimensions | Matches the vector table definition |
fastembed==0.8.0 | 0.8.0 changed this model from CLS to mean pooling. Upgrading changes every embedding, so the pin is load bearing |
Documents stored before you switched it on have no vectors. Store them
again and the replace path rebuilds them; until then, asking for vector expansion on
one of their chunks returns E_INTEGRITY rather than pretending. There is
no reindex command, because the path that already exists does the job.
Connection settings
These are fixed by the specification and not configurable, because changing them changes correctness rather than preference.
| pragma | value | why |
|---|---|---|
| writers | one | The file takes an exclusive lock while open |
| transactions | undo journal | A crash mid-write rolls back to the last commit |
| derived rows | same transaction | Chunks, index entries, and edges cannot outlive their document |
| file location | local only | A network share or a cloud-sync folder is refused, because file locking is not reliable there |
Which files exist on disk
Most single-file databases leave two extra files sitting next to the main one for as long as anything is connected. This one does not, and the difference changes what you have to copy and what a crash leaves behind.
normally hadano.db
during a write hadano.db
hadano.db.jrn <- the bytes that are about to be overwritten
after commit hadano.db
| Item | A write-ahead log | Here |
|---|---|---|
| Extra files | two, always present | one, only while writing |
| What they hold | the new data | the old bytes |
| Reading | main file merged with the log | the main file alone |
| After a crash | replay the log forward | replay the journal backward |
| Readers vs writer | do not block each other | the writer holds the file |
The order at commit is load bearing: the data is flushed first, and only then is the journal removed. The other way round leaves a moment where the journal is gone and the data is not yet on disk — nothing to roll back to. On the next open, a leftover journal is replayed backward to the last commit and anything the transaction appended is truncated, so a half-written state never survives.
Why the old bytes and not a log
A write-ahead log buys one thing above all: readers keep reading the last committed state while a write is in flight. That is the right trade for a database several processes share.
This one is held by a single process at a time. The file takes an exclusive lock when it opens, and tool calls are serialised behind it. There is no second reader for a log to protect, so the payment for one would be pure overhead: a shared-memory index next to the file, snapshot bookkeeping so readers know where a log ends, and a checkpoint step to fold it back in — usually on a background thread. Three moving parts, bought for a guarantee this design already gives away on purpose.
Saving the old bytes instead costs one file that exists only mid-write, and it keeps a claim on the front page literally true: the file is the database. With a log, the newest data lives outside the main file until a checkpoint, so “copy the file” quietly means “copy the file and hope”.
The bill for that comes due in the last row of the table above, and it is not small: a reader waits for the writer. Storing a one megabyte document blocks queries for the duration. Concurrent readers do not scale, because there are none. If several people or processes need the same knowledge base at once, this is the constraint that says use something else — or wait for the hosted tier, which exists to move the file behind one process rather than to share it.
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 a character-trigram index, ranked by BM25. 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. In this version it
always resolves to graph traversal, because vector search is not connected;
asking for vector or both is E_VALIDATION.
The rule it is meant to follow once vectors return: auto takes both
when embeddings are on, and a seed with no vector gives E_INTEGRITY
— never a silently empty result.
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",
"engine_version", "index_format",
"vector_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.
Five entry points today. None needs the MCP server to be running. Snapshot and JSONL export are being rebuilt for the current storage engine.
# start the MCP server yourself (your client normally does this) python -m hadano.server # reproduce the published numbers python -m hadano.bench --scale 100000 --reps 20 --db ~/bench.db # delete a database and everything that belongs to it (lists first; deletes nothing) python -m hadano.reset --db ~/hadano.db # share one database with several clients over HTTP: issue a key, grant, serve python -m hadano.hcserve key issue --db ~/hadano.db --label sales python -m hadano.hcserve grant add --db ~/hadano.db --key KEYID --verb read --scope "*" python -m hadano.hcserve serve --db ~/hadano.db --bind 127.0.0.1 --port 8941 # import a table, one row per document, replacing rows that share a key python -m hadano.import_table listings.csv --db ~/hadano.db --key id --title name --tags city
hcserve keeps the database in one process
hcserve is the shared-access path: one resident process owns
the database file and answers HTTP requests from many clients. A request
needs an API key, which the server stores only as a hash; permissions are
default-deny and granted per verb (read, write,
delete, admin) and scope (everything, a tag, or one
document); every request leaves one audit line; each key has a per-minute
limit. It binds the loopback address unless a private address is named, and
refuses a public address unless a TLS terminator in front of it is declared.
Issuing, revoking and granting keys happen only on the machine that runs
it, never over HTTP.
Reset deletes files, and says exactly which
hadano.reset removes the database file, its journal, and its
audit-log directory — by deleting the files, never by opening the engine.
By default it only lists what would go; deleting requires --apply
plus the database's file name typed again as a confirmation, so it stays a
deliberate human act. It refuses symlinks, junctions, zero-byte files, files
that are not a Hadano database, and paths inside synced folders.
Stated plainly, because the opposite would be false: reset does not touch
hadano-*.db backup copies (it cannot tell which database they
belong to — it lists them instead), cannot reach JSONL exports, synced
copies, or shadow copies, and deleting a file does not scrub the bytes from
the storage device — full-disk encryption is the layer for that. One
audit line recording the reset itself is written afterwards.
Being rebuilt. The dump and restore commands were written for the storage layer this project has since replaced, and they do not work against a current database file. The behavior below is the design they are being rebuilt to; until that lands, a backup is a copy of the file. This rebuild is planned.
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. A database is held by one client at a time - the file takes an exclusive lock - so each call leaves exactly one line. The audit log holds the request ID, tool, argument shapes, row count, duration, and outcome. Argument contents are not recorded by default; set
HADANO_REQUEST_LOG=fullif you want the first 1,000 characters of each string argument kept - 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 - The check runs at startup, once, and its result is cached in
db_status. Startup and search timings have not been re-measured since the storage engine was replaced, so no figures are quoted here - Embedding is the slow part, when you enable it. Throughput is 17–21 chunks per second, so a one megabyte document takes about half a minute to store. The per-call time budget is measured inside the query engine and does not interrupt it
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.