dira decision memory for AI coding agents
$dira whydec-0015
int-0002Zero-ceremony operation — one binary, no server, no daemonactive 2026-07-29
dec-0002One file per entry, not an append-only JSONL ledgeraccepted 2026-07-29
dec-0005A storage interface (local FS | GitHub API) is committed to before the first surface shipsaccepted 2026-07-29
dec-0015The derived cache stays honest by content hash, not by modification timeaccepted 2026-07-30
✗ Keep EntryInfo.Version as modification-time-nanos plus size, the heuristic git, make and rsync useIt is a heuristic, and its hole is reachable rather than theoretical. `state: accepted` and `state: rejected` are the same eight characters, so the edit that reverses a decision changes no file size — and any restore that puts modification times back (rsync -a, cp -p, tar -p, a backup unpacked over a checkout) changes no mtime either.
✗ Add ctime and inode to the metadata version, which the kernel updates on any inode change and utimes cannot forgeIt closes the practical hole for free — the stat is already being done — and it was measured working: after a size-preserving edit with mtime restored, ctime had moved by a second.
✗ A bare sha256 of the file rather than the git blob object idSame cost, strictly less use.
✗ Drop SQLite and answer every query straight from the files at 29.5msTempting at 200 entries, and honestly close: the cache saves 15.1ms of a 40ms budget for 6.5MB of binary and eleven modules on the allowlist, and the first invocation after the cache is deleted costs 55.5ms, which is worse than having no cache at all.
…1 further refusals on record
int-0002Zero-ceremony operation — one binary, no server, no daemon
dec-0002One file per entry, not an append-only JSONL ledger
dec-0005A storage interface (local FS | GitHub API) is committed to before the first surface ships
dec-0015The derived cache stays honest by content hash, not by modification time
✗Keep EntryInfo.Version as modification-time-nanos plus size, the heuristic git, make and rsync use
✗Add ctime and inode to the metadata version, which the kernel updates on any inode change and utimes cannot forge
✗A bare sha256 of the file rather than the git blob object id
✗Drop SQLite and answer every query straight from the files at 29.5ms
…1 further refusals on record
dec-0015 ◆ accepted 2026-07-30

Arising from dec-0002 — One file per entry, not an append-only JSONL ledger

The derived cache stays honest by content hash, not by modification time

The decision

`EntryInfo.Version` on the local backend is the entry file's **git blob object id** — `sha1("blob " + len + "\x00" + content)`, byte for byte what `git hash-object` prints — and not the modification time and size it shipped with. The derived read cache is a SQLite database at `.dira/cache/index.db`, built with the pure-Go `modernc.org/sqlite`.

Together those two make dec-0002's "if the cache and the files ever disagree, the files win" a guarantee rather than an expectation.

Why the version had to change

E1-L1 shipped `mtime-nanos + size` and handed the choice on explicitly, with an estimate that a content hash would cost about 13ms per full scan. Measured, it costs **6.9ms against 2.2ms** — 4.7ms, roughly a sixth of the estimate, because the estimate had folded in the YAML parse. Reading and hashing 200 entry files is 241KB of I/O and a hash; it is not a parse.

At 4.7ms out of a 40ms budget, the heuristic is not worth defending. Its hole is not theoretical: `accepted` and `rejected` are the same length, so the edit that reverses a decision preserves the file's size, and any restore that preserves modification times hides it completely. The acceptance test for that case is red under the old scheme — verified by putting it back, not assumed — and green under this one.

The git blob format rather than a bare hash is deliberate. E7's github backend gets blob shas free from the Contents API, so both backends produce the same version for the same content, which is dec-0005 actually paying off rather than being asserted. And a human can reproduce dira's version with `git hash-object` and no dira at all.

How the cache is kept from becoming authoritative

Two structural mechanisms, neither of which is a habit anyone has to keep:

1. **Nothing dira prints comes from the cache.** `Index.Entry` and `Index.Entries` go straight to `ledger.Store` and read the file. The cache answers *which* entries a query is about; the files answer what each of them says. 2. **No query can run against an unreconciled cache.** `index.Open` reconciles before it returns — list the ledger, compare every content hash, re-read exactly what differs, drop rows for entries that are gone — and there is no method that reaches the database without that having happened. There is no fast path to skip, which is the form the lane's "staleness detection cannot be skipped for speed" constraint had to take.

An unwritable or read-only `.dira/cache/` is not an error. The same schema is built in memory, the same SQL runs over it, a notice says why, and the exit code is 0. One implementation, so a cached answer and an uncached one cannot drift apart — which is a stronger claim than the differential test that also checks it.

What it costs, measured

Over the 200-entry fixture, in process, spawn excluded:

| | | |---|---| | no cache — list, read and parse all 200 | 29.5ms | | cache warm — hash scan, open, reconcile, render the brief | **14.8ms** | | cache cold — the same, plus building the database | 55.5ms | | `dira reindex` over 200 entries, one pass | 51.1ms |

The binary goes from 2.76MB to 11.79MB: **+6.52MB** for `modernc.org/sqlite` and its translated libc, **+2.51MB** for the ledger codec's YAML and JSON Schema dependencies. None of it is start-up time — process entry to `main` is unchanged at ~3µs, and the median wall clock of `dira version` is the same for both binaries — so the cost against int-0002 is download size and supply-chain surface, not the latency int-0002 is about.

The cold number is the uncomfortable one and is recorded rather than buried: the first invocation after `rm -rf .dira/cache` is nearly twice what having no cache at all would cost, and it breaks even on the second. E1-L6 measures with the cache deleted before every run, which measures a state that occurs once per clone as though it were the steady state; that predicate should be read with this number in hand.

Alternatives on record — 5

✗Keep EntryInfo.Version as modification-time-nanos plus size, the heuristic git, make and rsync userefused It is a heuristic, and its hole is reachable rather than theoretical. `state: accepted` and `state: rejected` are the same eight characters, so the edit that reverses a decision changes no file size — and any restore that puts modification times back (rsync -a, cp -p, tar -p, a backup unpacked over a checkout) changes no mtime either.

Under that scheme the cache then reports `accepted` for a file that says `rejected`, forever, with no reindex able to notice, which is exactly the one failure dec-0002 says the product cannot survive. The saving is 4.7ms per invocation out of a 40ms budget, measured, and a near-certainty bought at that price is still not a guarantee. `TestAnEditBehindTheCacheResolvesToTheFile/an edit preserving both size and modification time` is red under this option and green under the one taken; that was verified by reverting, not assumed.

revisit ifa ledger grows past roughly 2,000 entries, where the hash scan crosses 50ms and stops being affordable on every invocation — at which point the answer is probably a scan bounded by directory mtime rather than a return to trusting per-file mtime

✗Add ctime and inode to the metadata version, which the kernel updates on any inode change and utimes cannot forgerefused It closes the practical hole for free — the stat is already being done — and it was measured working: after a size-preserving edit with mtime restored, ctime had moved by a second.

But it is still a claim about when the inode was touched rather than about what the file contains, it is absent on Windows and needs build-tagged access to syscall.Stat_t on every other platform, and it buys a weaker guarantee than the 4.7ms option for more code. Complexity for a weaker property loses.

revisit ifthe hash scan becomes the binding cost and a cheaper pre-filter is wanted, in which case ctime is the right pre-filter and the hash stays as the confirmation

✗A bare sha256 of the file rather than the git blob object idrefused Same cost, strictly less use.

The git blob form — sha1 over "blob <len>\x00" then the content — is what `git hash-object` prints, so a human debugging a cache can reproduce dira's version with git and no dira, and it is what E7's github backend gets free from the Contents API, which means the two backends produce *equal* versions for equal content and a ledger can change backend without a full reindex. sha1's collision weakness is not in this threat model: this detects accidental divergence, not forgery, and anyone who can write the entry file can simply write whatever they like into it.

revisit ifdira ever needs the version to authenticate rather than to detect change, at which point the requirement has changed and so should the hash

✗Drop SQLite and answer every query straight from the files at 29.5msrefused Tempting at 200 entries, and honestly close: the cache saves 15.1ms of a 40ms budget for 6.5MB of binary and eleven modules on the allowlist, and the first invocation after the cache is deleted costs 55.5ms, which is worse than having no cache at all.

What decides it is that the saving scales with the ledger and the 6.5MB does not — the part the cache removes is the YAML parse of every entry, which is 15ms of the 29.5 at 200 entries and grows linearly, while the hash scan it cannot remove is only 6.9ms. It also breaks even after two invocations, and hooks fire several times a session. dec-0002 and the L0 acceptance line both name SQLite, so dropping it needs a superseding entry rather than an omission — this records the measurement that would justify one.

revisit ifthe binary size becomes a real complaint, or a ledger's typical size settles low enough that the parse the cache removes stops mattering

✗mattn/go-sqlite3, the cgo binding, instead of the pure-Go modernc.org/sqliterefused cgo means a C toolchain per target, so E0's goreleaser matrix could no longer cross-compile darwin-arm64 and linux-amd64 from one runner without a cross-compiler per platform.

That is a permanent tax on every release for a derived cache over 200 rows, where SQLite's own speed is not the binding constraint — the measured database work is 2.1ms of a 14.8ms warm read, and the file I/O around it is the rest.

revisit ifmodernc.org/sqlite's translated libc becomes a correctness or performance problem in practice, which would be a reason to reconsider the driver and not the cache

Every decision in this repo is recorded like this one.

Keep your own →