Chapter 4 — The Ledger Pattern and Friends
Draft status: author draft, gate-checked; human verification pending. Outputs are real transcripts; the dead run in the registry listing is a real mid-task process death.
Five shapes, most of memory
Operator memory feels endlessly various until you sort a few months of it, at which point it collapses into a handful of recurring shapes. Things done. Places reached in streams being read. Choices made and revised. Sessions begun and ended. Files fetched or produced. This chapter gives each shape its table — the ledger, the cursor, the config history, the run registry, the artifact index — worked as running code with the chapter 3 disciplines already applied. They are patterns, not a framework: no library to adopt, no dependency to carry, just shapes to copy and adapt, which for estates meant to outlive their tooling is a feature and not a modesty. Each section states the shape’s contract — what it promises a successor — because the contract, not the columns, is what makes a pattern transferable.
A word on how the five relate before meeting them singly. The run registry is the spine: everything else that happens, happens during some run, and rows elsewhere carry the run’s id so the estate can answer “what else did the session that did this also do?” — the question incident reviews are made of. The ledger records the runs’ outward acts; the cursor and config tables record their resumable inward state; the artifact index binds the file system’s holdings into the same web of provenance. One estate, five tables, joined — the composition section at the end runs the queries that only the joined whole can answer.
The ledger: things done, once, with their fates
The estate’s centerpiece is the pattern chapter 2 previewed twice, now assembled. A ledger row is an operation with a fate: what was to be done, proof it was decided (the intent, committed before acting), and what became of it (the outcome, committed after). The idempotency key makes the row a guard as well as a record:
import sqlite3
db = sqlite3.connect("estate.db")
db.execute("""
CREATE TABLE ledger (
id INTEGER PRIMARY KEY,
op_key TEXT NOT NULL UNIQUE, -- idempotency key: this operation, ever, once
action TEXT NOT NULL,
intent_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
outcome TEXT, -- NULL = fate unknown; successor must resolve
outcome_at TEXT,
run_id INTEGER REFERENCES runs(id), -- the run that owns this act (composition, below)
CHECK ((outcome IS NULL) = (outcome_at IS NULL)) -- outcome and its time arrive together, or neither does
) STRICT
""")
with db:
db.execute("INSERT INTO ledger (op_key, action) VALUES (?, ?)",
("restart-nginx-2026-08-28", "systemctl restart nginx"))
with db:
db.execute("""UPDATE ledger SET outcome = 'is-active reported active',
outcome_at = strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE op_key = ?""",
("restart-nginx-2026-08-28",))
try:
with db:
db.execute("INSERT INTO ledger (op_key, action) VALUES (?, ?)",
("restart-nginx-2026-08-28", "systemctl restart nginx"))
except sqlite3.IntegrityError:
print("retry recognized: this operation is already in the ledger")
print(db.execute("SELECT op_key, outcome FROM ledger").fetchone())
retry recognized: this operation is already in the ledger
('restart-nginx-2026-08-28', 'is-active reported active')
The contract, spelled out. Every world-touching act appears here before it
happens, so a successor never inherits invisible history. A NULL outcome is a
promise of honesty, not a gap: it marks exactly the operations whose fate must
be resolved by reading the world, and WHERE outcome IS NULL is the successor’s
first ledger query. For that marker to stay trustworthy the two outcome columns
must move as a unit, which is what the paired CHECK —
(outcome IS NULL) = (outcome_at IS NULL) — enforces: both filled (a resolved
fate with its timestamp) or both NULL (a clean fate-unknown row), never the
nonsense middle of an outcome time hanging beside a missing outcome, which would
make WHERE outcome IS NULL and “is this resolved?” disagree. (A one-sided
check — forbidding only outcome-without-time — leaves that middle open; the
equality closes both sides at once.) The UNIQUE refusal is the pattern’s quiet triumph — the
retried operator in the listing learned it was a retry from the schema, at
insert time, before running the restart again. And the discipline that keeps
all this true is append-and-complete: intent rows are inserted, their outcome
fields are completed, and nothing is ever deleted or rewritten — corrections
are new rows referencing old ones, the same append-only covenant the register’s
book demanded of ledgers in prose, now held by habit and CHECK together.
Two design notes earn their space. The op_key is chosen, not generated, when the operation has a natural once-ness — “rotate credentials for host X during window W” — and generated (and stored with the task that carries it) when it does not; what matters is that the key’s scope match the once-ness you mean, which is a decision the pattern forces into the open. And the action column records the command as composed, because the successor auditing an incident wants what was actually dispatched — the register’s exact-transcripts rule, applied to memory.
The cursor: where reading stopped
The second shape is the one this book’s own predecessor kept in a flat file and called a bookmark: for any stream consumed incrementally — a journal, a feed, a log directory, an API’s paginated history — the estate records how far reading got, so the next session reads only what is new. The cursors table from chapter 3 gets its writer, the upsert:
import sqlite3
db = sqlite3.connect("estate.db")
db.execute("""CREATE TABLE cursors (stream TEXT PRIMARY KEY, position TEXT NOT NULL,
advanced_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))) STRICT""")
def advance(stream, position):
with db:
db.execute("""INSERT INTO cursors (stream, position) VALUES (?, ?)
ON CONFLICT(stream) DO UPDATE SET
position = excluded.position,
advanced_at = excluded.advanced_at""", (stream, position))
advance("journal:nginx.service", "cursor=s=abc;i=44f0")
advance("journal:nginx.service", "cursor=s=abc;i=4512")
print(db.execute("SELECT stream, position FROM cursors").fetchall())
print("rows:", db.execute("SELECT count(*) FROM cursors").fetchone()[0])
[('journal:nginx.service', 'cursor=s=abc;i=4512')]
rows: 1
One row per stream, always current, atomically replaced — the upsert (INSERT
that becomes UPDATE on key conflict) is the exact tool for state whose history
does not matter, and the listing’s second call landing as an update, not a
second row, is the semantics on display. The contract has three clauses worth
enforcing by convention. Positions are opaque: the cursor stores whatever
resume token the stream’s own tooling emits — a journald cursor string, an HTTP
ETag, a line offset — and no consumer ever parses it, so streams can change
their token format without breaking the estate. Advancement is transactional
with processing: the cursor moves in the same transaction that records what
was done with the new entries (chapter 2’s units-of-meaning rule; a cursor
advanced before its entries are handled is data loss wearing a bookmark). And
staleness is the reader’s first question: advanced_at exists so a successor
can distinguish a stream read minutes ago from one abandoned in June — the
difference between resuming and re-validating.
Configuration with a memory
Operators make choices — polling intervals, thresholds, target lists — and the midden stores them as bare current values, which answers “what is the setting?” and is mute before the questions that actually arise: what was it before, who changed it, and why? The estate stores configuration as history and derives the present from it:
import sqlite3
db = sqlite3.connect("estate.db")
db.executescript("""
CREATE TABLE settings (
id INTEGER PRIMARY KEY,
key TEXT NOT NULL,
value TEXT NOT NULL,
set_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
set_by TEXT NOT NULL,
reason TEXT NOT NULL,
run_id INTEGER REFERENCES runs(id) -- the run that made this change (composition, below)
) STRICT;
CREATE VIEW settings_current AS
SELECT key, value, set_at, set_by, reason FROM settings s1
WHERE id = (SELECT max(id) FROM settings s2 WHERE s2.key = s1.key);
""")
rows = [("poll_interval", "300", "author-session", "default"),
("poll_interval", "60", "author-session", "expedite request 2026-08-28"),
("retention_days", "90", "author-session", "default")]
with db:
db.executemany("INSERT INTO settings (key, value, set_by, reason) VALUES (?,?,?,?)", rows)
print("current:", db.execute("SELECT key, value FROM settings_current ORDER BY key").fetchall())
print("history of poll_interval:",
db.execute("SELECT value, reason FROM settings WHERE key='poll_interval' ORDER BY id").fetchall())
current: [('poll_interval', '60'), ('retention_days', '90')]
history of poll_interval: [('300', 'default'), ('60', 'expedite request 2026-08-28')]
The mechanics are two ideas stacked. Writes are pure appends — nothing
UPDATEs, so no choice is ever erased by the next one — and the view derives
the current value as “the latest row per key”, giving every consumer a table
that reads exactly like the flat config it replaced. (Views are the estate’s
politeness layer generally: a stored query wearing a table’s name, letting the
schema serve the stranger’s common questions pre-composed.) The required
reason column is the pattern’s soul, and it is required precisely because it
is what nobody records voluntarily. Every debugging session that ever ended
with “who set this to 60?!” was mourning this column. The row that answers it
here — an expedite request, dated, attributed — is this book’s own production
history, recorded the way the pattern demands.
The run registry: sessions and their ends
The fourth shape records the operators themselves. A run row marks a session’s
birth (operator identity, task, start time) and — completed at exit, honestly —
its end and outcome. Its power is what incomplete rows mean. Because the
start is committed at startup and the end only at a clean exit, a row with
ended_at NULL whose operator is no longer alive is a session that died
mid-work, and the registry makes that inheritance visible instead of
archaeological. Demonstrated with a genuinely killed run:
import sqlite3, subprocess, sys
db = sqlite3.connect("estate.db")
db.execute("""
CREATE TABLE runs (
id INTEGER PRIMARY KEY,
operator TEXT NOT NULL,
task TEXT NOT NULL,
started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
ended_at TEXT,
outcome TEXT CHECK (outcome IN ('ok','failed','abandoned') OR outcome IS NULL)
) STRICT
""")
db.commit(); db.close()
child = '''
import sqlite3
db = sqlite3.connect("estate.db")
with db:
db.execute("INSERT INTO runs (operator, task) VALUES ('session-77', 'rotate logs')")
import os; os._exit(1) # died mid-task; ended_at and outcome never written
'''
subprocess.run([sys.executable, "-c", child])
db = sqlite3.connect("estate.db")
open_runs = db.execute("""SELECT id, operator, task, started_at FROM runs
WHERE ended_at IS NULL""").fetchall()
print("unfinished business inherited by the successor:")
for r in open_runs: print(" ", r)
unfinished business inherited by the successor:
(1, 'session-77', 'rotate logs', '2026-08-28T18:00:49Z')
Session 77 died between its first commit and its last, and the registry holds
exactly the truth: a rotate-logs run began at 18:00 and never reported back.
The successor’s protocol writes itself from the row: read the world (were the
logs rotated?), consult the ledger for session 77’s intents (chapter 2’s gap,
now navigable by join), then close the row honestly — outcome = 'abandoned',
with a note — so the registry converges to a complete history instead of
accreting mysteries. The registry’s second dividend is aggregate: because
every run lands here, “how have runs been ending lately” is one GROUP BY —
failure rates by task, duration drift, the trend that distinguishes a flaky
week from a broken change. The register’s previous book put calibration in the
operator’s conduct; the registry is where the calibration data has been
accumulating all along.
The artifact index: files, vouched for
The fifth shape closes the loop chapter 1’s taxonomy opened. Artifacts live in the file system; the estate holds their papers — identity, origin, and a content hash that converts “I think this is the file” into arithmetic:
import sqlite3, hashlib, pathlib
def sha256(p): return hashlib.sha256(pathlib.Path(p).read_bytes()).hexdigest()
pathlib.Path("model-config.yaml").write_text("layers: 32\n")
db = sqlite3.connect("estate.db")
db.execute("""CREATE TABLE artifacts (
path TEXT PRIMARY KEY, sha256 TEXT NOT NULL,
origin TEXT NOT NULL, fetched_at TEXT NOT NULL,
run_id INTEGER REFERENCES runs(id) -- the run that produced this file (composition, below)
) STRICT""")
with db:
db.execute("INSERT INTO artifacts (path, sha256, origin, fetched_at) VALUES (?,?,?,?)",
("model-config.yaml", sha256("model-config.yaml"),
"generated by session-77", "2026-08-28T18:40:00Z"))
path, recorded = db.execute("SELECT path, sha256 FROM artifacts").fetchone()
print("verify:", path, "MATCHES" if sha256(path) == recorded else "DRIFTED")
pathlib.Path(path).write_text("layers: 32\nquantized: true\n") # someone touched it
print("verify:", path, "MATCHES" if sha256(path) == recorded else "DRIFTED")
verify: model-config.yaml MATCHES
verify: model-config.yaml DRIFTED
The second verification caught the edit — someone (here, the listing itself,
playing the world’s usual role) changed the file after it was indexed, and the
hash said so. That one bit, MATCHES or DRIFTED, is the difference between an
estate that describes its files and one that vouches for them: the
register’s proof-of-target discipline, precomputed and stored. The index’s
columns follow the provenance rules of chapter 3 (origin answers “where
from”, fetched_at answers “how stale”), and its verification query — every
row, hash recomputed, mismatches reported — is a standing job chapter 7 will
fold into the estate’s larger trust apparatus. Deliberately absent: the file
contents. The blob column exists and the index declines it, because chapter
1’s taxonomy holds — streaming bytes is the file system’s talent, vouching is
the database’s, and the hash marries them without confusing them.
The sixth shape: work that waits
One variation on the ledger earns shape status of its own, because it turns
the estate from memory into coordination: the queue. Where the ledger
records work already decided, a queue holds work waiting for a worker — and
the estate can serve it to concurrent claimants without a broker, using the
atomic read-modify-write that chapter 1 introduced, now with the modern
RETURNING clause handing back what was claimed:
import sqlite3
db = sqlite3.connect("estate.db")
db.execute("""CREATE TABLE queue (id INTEGER PRIMARY KEY, task TEXT NOT NULL,
claimed_by TEXT, claimed_at TEXT, done_at TEXT) STRICT""")
with db:
db.executemany("INSERT INTO queue (task) VALUES (?)",
[("verify backups",), ("prune graveyard",), ("rotate keys",)])
def claim(worker):
with db:
row = db.execute("""UPDATE queue SET claimed_by = ?, claimed_at = strftime('%Y-%m-%dT%H:%M:%SZ','now')
WHERE id = (SELECT min(id) FROM queue WHERE claimed_by IS NULL)
RETURNING id, task""", (worker,)).fetchone()
return row
print("worker A claims:", claim("A"))
print("worker B claims:", claim("B"))
print("worker A claims:", claim("A"))
print("worker B claims:", claim("B"))
worker A claims: (1, 'verify backups')
worker B claims: (2, 'prune graveyard')
worker A claims: (3, 'rotate keys')
worker B claims: None
Each claim is one transaction — find the oldest unclaimed task, stamp it
with the claimant, return it — so two workers arriving simultaneously
cannot claim the same row (the single-writer queue of chapter 5 serializes
them), and the drained queue answers None, the affirmative nothing the
register’s previous volume taught shots to say. The shape’s obligations
follow the ledger’s family line: completion is a second write (done_at,
plus outcome evidence), so a claimed-but-never-completed row is the queue’s
version of the unfinished run — visible inheritance, reclaimed by a
staleness rule (claimed more than an hour ago by a worker whose registry
row has ended: back to the pool, with a note). This is not a message broker
and does not pretend to be — no pub/sub, no cross-host delivery, chapter
8’s boundaries apply — but for the workload it fits — one machine’s workers
sharing a task list through the file they already share — it replaces a
broker service with twelve lines, and the claim-lock at its center is the
same one this book’s own publisher documents for its critic seats:
self-service, claim-locked, no coordinator.
Choosing keys: the once-ness decision
The ledger’s op_key looked like a detail and is actually the pattern’s hardest
design question, so it earns a worked treatment. The key’s job is to make the
schema refuse a second recording of the same operation — which means the
key must encode what “same” means, and “same” is a decision about the world,
not the database. Three scenarios, three different correct keys. A nightly
certificate renewal: the operation recurs by design, so the key includes the
occasion — renew-web-cert:2026-08-28 — and a retry within the night is
refused while tomorrow’s run is new. A migration applied to a host: once ever,
so the key is timeless — apply-schema-v7:db-host-2 — and any future attempt,
weeks later, is correctly recognized as already done. A user-requested
one-off: once per request, so the key carries the request’s identity —
purge-quarantine:req-4415 — and the same user asking again tomorrow is a
new request, new key, new row. Get the scope too narrow and retries slip
through (a key with a timestamp to the second refuses nothing, since every
retry mints a fresh second); too broad and legitimate recurrences are refused
(the migration key without the host would block host 3 because host 2 was
done). The test that settles every case: if two rows carried this key, would
the second necessarily be a mistake? — and the key is built from exactly the
facts that make the answer yes. Surrogate ids (the INTEGER PRIMARY KEY every
table carries) answer a different question — row identity for joins — and the
two must not be conflated: the surrogate is for the estate’s bookkeeping,
the op_key is for the world’s.
What the ledger refuses to hold
Patterns are defined by their exclusions as much as their columns, and two exclusions keep ledgers healthy. Reads stay out — the previous volume’s rule (“only writes get ledger lines”) carries over with its reasoning intact: an estate that ledgers its reads drowns its writes in noise, and the registry already accounts for sessions wholesale. The judgment call arrives with consequential reads — the probe that decided a failover, the check that justified a purge. Those enter the record not as ledger rows but as evidence on the write they motivated: the outcome column of the action they triggered, or a fact row with provenance, keeping the ledger’s every line an act upon the world.
Secrets stay out absolutely, and the rule needs stating because ledger columns
attract them — the action that ran with a token, the config value that is a
password. The estate is one readable file; it travels in backups, gets opened
by strangers (that is its purpose), and mixes lifetimes (chapter 8’s
retention will happily keep a ledger row for years past any credential’s
rotation). Secrets therefore appear in the estate only as references — the
name of the key in the system keyring, the path to the credentials file, the
identity of the vault entry — never as values; the action column records the
command with the secret’s reference, exactly as the previous volume’s
transcripts learned to show $TOKEN rather than its expansion. The stranger
inheriting the estate learns where every secret lives and holds none of them,
which is the correct shape of that inheritance.
The standing questions are part of the pattern
Each shape shipped with example queries, and the framing deserves promotion:
a pattern is not adopted until its standing questions are written down beside
it — named, tested, kept with the schema the way chapter 3 keeps comments.
The ledger’s four: what is unresolved (outcome IS NULL, oldest first)?
what did run N do? has this op_key been seen? what failed in the last week?
The cursor’s two: where is stream S? which streams have gone stale? The
config table’s three: current values (the view); history of key K; what
changed since date D? The registry’s three: open runs; outcomes by task over
window; duration drift. The artifact index’s two: verify everything; what
did run N produce? Fourteen queries, each a line or two, and together they
are the estate’s interface — the successor’s briefing (chapter 8 composes
it), the handoff message’s evidence, the monitoring hooks. Writing them down
at adoption time costs minutes and does something subtler than convenience:
it tests the schema against its purpose while the schema is still cheap to
change. A shape whose standing questions turn out awkward to write — a join
that needs a column nobody stored, a filter on a field inside a blob — is a
shape caught misdesigned on day one instead of month six, which is the
cheapest schema review an unattended operator will ever get.
Order, and where it really comes from
One subtlety spans all five shapes and surfaces in incident reviews at the
worst moments: what orders the history? The intuitive answer — the
timestamp columns — is the fragile one. Timestamps tie (the second is this
book’s stated precision, and a busy session commits several truths per
second), and clocks move (NTP corrections, timezone accidents on machines
less disciplined than chapter 3 demands), so two rows’ timestamps can
disagree with the order the estate actually experienced. The reliable
answer is already in every table: the INTEGER PRIMARY KEY is allocated
monotonically as rows commit, so id order is commit order within an
estate, and every “what happened next” question — the incident walk, the
correction chain, the settings view’s “latest per key” — keys on id, with
timestamps serving their real purposes: humans, staleness pricing, and
joins against the world’s clocks (logs, journals) that ids cannot reach.
The convention costs nothing to adopt and one bad afternoon to retrofit,
which is why it is stated here, between the shapes it quietly orders.
(Its boundary is the estate itself: ids order one file’s history; across
estates or against the world, timestamps — pinned UTC, chapter 3 — are
the only shared clock, carrying exactly the caveats above.)
Composition: one estate, queryable whole
The five shapes pay their real dividend joined. The join key is the run_id
column already declared in the ledger, settings, and artifact schemas above —
run_id INTEGER REFERENCES runs(id), set on each write to the registry row of
the run that made it — and with that one shared key the estate becomes a single
navigable account of the operator’s whole history. (The per-pattern listings
earlier ran each shape in isolation, with no registry to point at, so their
run_id sat NULL; in a live estate, every write happens during a run and
carries its id, which is what makes the queries below resolve.) The incident query: everything session 77 did —
its ledger intents, its setting changes, its artifacts — in one pass, from
the run id the registry handed you. The audit query: every world-action whose
outcome is NULL, oldest first, with the run that owes it. The trust query:
every artifact fetched by runs that later failed, for re-verification. The
calibration query: median run duration by task, this month against last.
None of these is an engineering project; each is a SELECT against tables this
chapter already built, which is the payoff chapter 1 promised when it said
state you cannot query is barely state at all. The midden could not answer
one of them.
A day in the composed estate makes the joins concrete. A session wakes,
registers its run (registry row 214, operator session-92, task “monthly cert
sweep”), and asks the ledger whether the sweep’s op_key has been seen — new
month, new key, clean insert: intent recorded. It reads the cursor for the
certificate transparency stream, fetches what is new, and finds one
certificate nearing expiry. The renewal is a world-action: intent row with
op_key renew-mail-cert:2026-09, the renewal runs, the functional probe
passes, outcome completed — one transaction per truth, exactly as chapter 2
drew the boundaries. The new certificate file lands in the artifact index
with its hash and origin; the cursor advances in the same transaction that
recorded what the new entries produced; a journal entry (chapter 6) writes
the sentence a future searcher will want. The session ends; the registry row
closes with outcome ok. Nothing in the day required coordination, and yet
every question a supervisor, successor, or incident review could ask — what
ran, what changed, what proves it, what was produced, where reading stopped —
has one answer, in one file, joined by run id 214. That is the composition
argument in narrative form: not that five tables are tidier than five files,
but that the day’s whole shape became queryable because its parts agreed
on keys.
The patterns also compose downward into discipline the register’s book left as conduct. Its evidence blocks now have an address (outcome columns); its change ledger has a schema instead of a format convention; its handoff message’s five answers are five queries. And one estate serves one operator lineage at a time so far — every listing in this chapter wrote from a single connection. Real estates get written by concurrent generations: the timer firing while the interactive session works, the second agent dispatched in parallel. Two operators, one file, no coordinator — that is chapter 5, and the engine has been waiting for it.