A tiny append-only temporal/provenance key-value database in C.
Licensed under the MIT License.
The append-only record log is the source of truth. The current accepted value of a key — the Canon — is a deterministic fold over that log, never a stored mutable field. The on-disk index is a disposable cache: delete it and the database rebuilds it from the log without losing a fact.
nisaba> inscribe project.status "active"
1
nisaba> inscribe project.status "paused"
2
nisaba> canon project.status
"paused"
nisaba> history project.status
1 "active"
2 "paused" supersedes=1
Schisms: competing claims are never hidden
Most databases silently resolve a conflict by overwriting: last write wins.
Nisaba refuses to do that. A new write normally supersedes the current head,
but --fork records a competing claim instead. A key with two or more live
heads is a schism, and every read of that key reports it rather than
guessing:
nisaba> inscribe project.owner "alice"
1
nisaba> inscribe project.owner "bob" --fork
2
nisaba> canon project.owner
schism: 2 competing claims
$ echo $?
4
nisaba> schisms project.owner
1 "alice"
2 "bob"
nisaba> history project.owner
1 "alice"
2 "bob"
canon returns exit code 4 and prints no value, so a script or agent cannot
mistake a schism for one. The diagnostic goes to stderr; stdout carries only
the machine-readable form. With --json that is an explicit "schism":true
object:
$ nisaba --json memory.db canon project.owner 2>/dev/null
{"key":"project.owner","value":null,"schism":true,"heads":2}
$ echo $?
4To resolve a schism you must get back to exactly one live head. Every path
appends records; none rewrites the log. A single --supersedes edge removes
only the one head it names, so it cannot merge two heads by itself:
# Simplest: withdraw the losing claim. One head remains.
nisaba> retract 1
3
nisaba> canon project.owner
"bob"
# Or install a new preferred claim over one head, then withdraw the other.
nisaba> inscribe project.owner "bob" --supersedes 1
3
nisaba> canon project.owner
schism: 2 competing claims # heads are now 2 and 3
nisaba> retract 2
4
nisaba> canon project.owner
"bob"
The general rule: keep only one path of live claims. Superseding a head
creates a new one, so pair each --supersedes with a retract of the head
it does not cover, or retract down to a single head and re-assert.
make # builds libnisaba.a and the nisaba CLI
make test # runs the unit and integration tests
make lint # strict compile + clang-tidy (hard gate)
make ci-local # lint + build + test + ASan/UBSan
make install # bin, lib, and header into ~/.local (override with PREFIX=)
make help # all targetsInteractive:
$ nisaba memory.db
NISABA
The tablet remembers.
nisaba> inscribe user.name "Vittorio"
1
nisaba> canon user.name
"Vittorio"One-shot, from a shell script or an agent:
nisaba memory.db inscribe project.status active
nisaba memory.db canon project.status
nisaba memory.db history project.status
nisaba memory.db scan project
nisaba --json memory.db canon project.status
nisaba --format=raw memory.db canon project.statusskills/nisaba/SKILL.md teaches coding agents to drive nisaba: the CLI
contract, JSON shapes, exit codes, schism resolution recipes, and the sharp
edges. Install it into any harness that reads SKILL.md skill directories:
skills/install.sh # every detected harness skills dir
skills/install.sh ~/.claude/skills # or name one: ~/.config/opencode/skills, .claude/skills, ...inscribe KEY VALUE [--witness W] [--source S]
[--supersedes ID] [--valid-time T] [--fork]
canon KEY [--as-of ID]
history KEY
scan PREFIX [--limit N]
retract ID
schisms KEY
witness ID
checkpoint | verify | init | stats
Global options: --json (one JSON object per result line), --format=raw
(values without quotes), --readonly, --quiet.
Exit codes: 0 ok, 1 not found / no live claim, 2 usage, 3 I/O,
4 schism, 5 corrupt, 6 internal (out of memory or another engine
condition the command surface does not name).
The rules are few and deterministic. Everything else follows from them.
- Append only. Every write appends an immutable record carrying a log-sequence id (its inscription id and its transaction time), a witness, and an optional supersession edge. Nothing is ever updated in place.
- Canon is derived, not stored. The current value of a key is folded from the log on demand (and cached in the index, which is disposable).
- Supersession is a DAG. A new claim replaces the current single head.
--supersedes IDlinks to a specific predecessor.--forkadds a second live head instead of replacing, which is what creates a schism. canonis three-valued. The unique live head's value;nullwhen no claim is live; a reported schism when two or more heads compete.- Exit codes are part of the contract.
canonexits4on a schism and1when a key has no live claim, so the ambiguity is visible to scripts. - Conflict is never silently resolved. Nisaba will not pick a winner.
Resolution is an explicit
inscribe --supersedesorretract.
These are deliberate choices. Each one is a place where Nisaba differs from "just overwrite the value."
-
Newest-by-wall-clock never wins. Canon depends only on the append order (
lsn) and explicitsupersedesedges. Timestamps are recorded, but they never decide anything, so replaying a log always yields the same answer even under clock skew or equal timestamps. -
recorded_atis informational. It defaults to the wall clock at append but has no authority.--valid-time Tstores a caller-supplied validity time; it is data for the caller, not an input to Canon. -
Retraction does not resurrect.
retract IDwithdraws that record. It does not restore what the record superseded. If the retracted record was the only live head, the key becomesnull(exit1), not its predecessor. -
Retraction wraps an existing record.
retract IDfails with "not found" ifIDdoes not exist. It appends a new record (with a new id) whose target is the named one. -
A retraction is indexed under its target's key. Even though the tombstone record is its own frame with its own id, it participates in the key's chain, so
history KEYshows both the claim and its withdrawal. -
Forking an already-forked key stays forked. When a key already has two or more heads, a plain
inscribedoes not quietly pick a winner. -
A
--supersedesedge removes exactly one head. It cannot merge two competing heads into one. Resolving a two-head schism needs either aretractof the losing head, or a--supersedesof one head plus aretractof the other. Superseding a head adds a new head (the new record), so it only reduces the head count when it retires the named one. -
schismswith no competing heads is empty, not an error. On a key with one head or none,schisms KEYsucceeds and prints nothing. It errors only when the key itself is unknown. -
Prefix scan is a byte range, not a hierarchy. There are no namespaces or shards.
scan project.matches keys whose bytes begin withproject.. The entire key space is one ordered byte space in one log. -
The index is a cache you may delete.
rm memory.db.idxloses nothing: the next open rebuilds it from the log. Reads prefer the B+tree only when its snapshot is exactly caught up with the log, so a stale index can never change an answer. -
Only compaction removes bytes. Absent
compact, no operation ever destroys a recorded fact, including retraction, which is itself a record. This is the headline guarantee. -
A crash can only lose a tail. Recovery truncates an incomplete final frame. It never corrupts or rewrites earlier records, because none were written in place.
-
Themed names are CLI-only. "Inscription", "Canon", "witness", and "schism" appear in the command surface. The storage code and headers use ordinary names (
Record,Log,Index,BTree,KeyState), and the file format is self-describing TLV, so the theme never constrains it. -
Canon reads are O(chain). Resolving a key walks that key's records; the index gives the chain and its head, but
--as-offolds the prefix.
memory.db append-only framed record log (source of truth)
memory.db.idx paged B+tree index snapshot (disposable cache)
The log is a 64-byte header followed by records framed with magic, length,
lsn, a TLV body, and a CRC-32C trailer. Because records are only ever
appended, recovery has nothing to undo: a scan stops at the first invalid
frame and truncates the torn suffix, so a crash can only lose an incomplete
tail, never corrupt the prefix. checkpoint rewrites the index snapshot
atomically; a missing or stale index is simply rebuilt from the log.
include/nisaba.h public API and record model
src/util.c buffers, varints, CRC-32C
src/codec.c TLV record body encoding
src/log.c framed append-only log and recovery
src/index.c in-memory ordered index
src/model.c Canon fold over the supersession DAG
src/btree.c paged B+tree index snapshot
src/db.c archive open/inscribe/read API
src/main.c command-line interface
tests/ unit and integration tests
