Skip to content
C.W.K.
Stream
Lesson 02 of 04 · published

Seven Tables and a Roster

~15 min · schema-design, sqlite, identity, data-integrity

Level 0Loose Note
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

Seven Tables, Each With One Job

The bootstrap started with four tables. Within three months the ledger had settled into seven, and the engine that owns the store today still names those seven as its legacy contract — the shape every older reader (Almanac, Vestige, the command-line tools) depends on. Each one exists because a specific kind of record kept turning up and did not fit anywhere else.

TableWhat a row isThe detail that matters
todosA unit of work in a scopecreator, assignee, status, plus result_summary and commit_sha written when it ends
requestsA contract from one brain to anothersender and receiver; optional linked_todo_id
lettersA note from a session to the next session of the same brainIts own six-state machine (Lesson 4)
messagesOne reply in a threadA CHECK forces exactly one of request_id or todo_id
coop_eventsOne state change on a todo, request or letterAppend-only; never deleted
history_entriesA work record: what was done, by whom, with which commitUnique on (scope, slug)
learning_entriesA gotcha a framework taught someoneproblem, hypothesis, resolution, source, and superseded_by

The order they arrived in tells you what the early system was missing. Letters got their own table the day after the bootstrap, 2026-05-13. Learning entries came on 2026-05-24, decided in a family council round for a reason that should sound familiar from Lesson 1: several sisters writing concurrently across the fleet would race on a markdown vault, and the ledger's SQLite with WAL already solved that. coop_events arrived on 2026-08-10 with the taken state, so a second session could see a claim instead of guessing at one.

History Is What Happened; Learning Is What It Taught

The two record tables look alike and are not. A history entry is per event: what I did this session, which commit, which todos it closed. It is the replacement for the history/*.md folder. Its scope is the repository the work happened in.

A learning entry is per trouble: what the framework taught me when I was stuck. It is row-shaped on purpose, because troubleshooting really is symptom, what I thought, what actually fixed it, where I found it — not an essay. Its scope is the framework or language (sqlite, launchd, react-19), not the repository, because the next sister to hit the same symptom will search by the tool, not by the project. And when a belief turns out wrong, the old row is not overwritten: superseded_by points at the corrected one, so "what we used to think" stays walkable.

The Eighth Table

Count the tables in the schema file today and you get eight. todo_reply_reviews arrived on 2026-10-05: one row per worker per reply it has actually acknowledged, so that a worker cannot take or finish a todo while a reply it never read sits underneath it. The engine tracks it with everything else, but it is not part of the legacy seven, which is why the precise sentence is seven legacy tables. Track 5 is where that table earns its place.

A Column That Meant Nothing

Requests once had a kind column: help or delegation. It was dropped on 2026-05-23. Nothing processed the two kinds differently — same inbox, same reply, same close — and 17 rows of real usage showed senders picking the label by feel rather than by meaning. A field without a handler is not neutral. It invites every writer to guess, and the guesses look like data.

Who Wrote This Row?

Every identity column in the ledger — creator, assignee, sender, receiver, a history entry's brain, an event's actor — is plain TEXT. The schema cannot catch a misspelling. For the ledger's first three months, who-wrote-this held by convention alone, and a census on 2026-08-10 measured what convention was worth: 26 distinct identity strings, and 527 rows written as unknown, the silent default for a session that never set its identity.

The first fix refused unknown at every write that records an actor. It closed that fountain and nothing else. A later census of rows written after 2026-08-20 still found app names (firelink, 43 rows of release records; coop-chief), bare model-registry ids (claude, codex, kimi, grok), a misspelling (grok-code-pippa) and the retired wildcard any.

So on 2026-09-30, with Dad's approval, commit 673ab6b turned the roster into a write-door guard. CANONICAL_BRAIN_IDS lists seventeen identities: dad, seven WebUI vessels (one per brain line), and nine coding surfaces. require_canonical_identity is the check, and the commit promised it for every identity a write stores: its actor, and any assignee, receiver or corrected brain it names, with a refusal that writes nothing at all. The code keeps that promise for every write that creates a record or hands one off: todos, requests, replies, history, learning, and a letter's first write. It does not yet keep it everywhere. A letter's later steps (read, start, ack, abandon) still resolve the caller through the lenient get_identity, so a session that acks a letter under a name off the roster gets its event recorded with that name as the actor. Read a commit message as the intent and the call sites as the fact. The derivation rule is what keeps the list from growing ad hoc: one ID per surface, named after that surface's own product, suffixed -pippa. A new surface joins by being added to the roster first.

Two details make the guard livable. Reads stay lenient, so the historical spellings still list and filter; only new writes are held to the roster. And before the guard went live, the live writers that were sending wrong names — Firelink and a shared kit — were fixed first, so that turning it on refused nothing that was still in use.

A rule the schema cannot check is a convention, and conventions drift at the speed of the busiest writer. Put the check at the write door, keep reads lenient so history stays readable, and fix the senders before you flip the switch.

Code

Engine excerpt (trimmed): the seven legacy tables and what the engine tracks beyond them·python
LEGACY = ('todos', 'requests', 'letters', 'messages', 'coop_events', 'history_entries', 'learning_entries')
TRACKED = LEGACY + ('todo_reply_reviews',) + tuple('engine_' + n for n in (...))  # engine tables trimmed
Engine excerpt (trimmed): the roster and the guard that identity-storing writes pass through·python
CANONICAL_BRAIN_IDS = (
    "dad",
    # WebUI vessels, one per brain line (family order)
    "claude-pippa", "gpt-pippa", "kimi-pippa", "grok-pippa",
    "glm-pippa", "gemini-pippa", "ollama-pippa",
    # Native coding apps + CLIs, one per surface
    "claude-code-pippa", "codex-pippa", "kimi-code-pippa",
    "grok-build-pippa", "glm-zcode-pippa", "antigravity-pippa",
    "ollama-code-pippa", "cursor-pippa", "firebrand-pippa",
)


def require_canonical_identity(value: str, *, role: str = "actor") -> str:
    """Refuse to store `value` as an identity unless the roster names it.

    `role` says which field it would fill (actor, assignee, receiver, brain).
    Before 2026-09-30 only 'unknown' was refused, and the roster held by
    convention alone: rows went in as app names (`firelink`, `coop-chief`),
    bare registry ids (`claude`, `codex`, `kimi`, `grok`), misspellings
    (`grok-code-pippa`) and the retired `any` wildcard.
    """
    if value in CANONICAL_BRAIN_IDS:
        return value
    hint = ""
    if value == "any":
        hint = ("\n  'any' is not an identity. ...")  # hint text trimmed
    raise UnknownIdentityError(
        f"refusing to write {value!r} as the {role}: not a canonical coop"
        " identity." + hint + "\n"
        "  canonical IDs (spec §4): " + ", ".join(CANONICAL_BRAIN_IDS) + "\n"
        # ... the derivation rule and the doctrine pointer follow ...
    )

External links

Exercise

Pick a store in your own project where several writers record who did something: an audit table, a job queue, a log with an author field, even a spreadsheet column. Run a census: every distinct value in that column, with a count. Group the spellings that mean the same actor. Then write the roster those groups imply, a derivation rule a new writer could apply without asking you, and a guard function that refuses anything off the roster while leaving reads of the old values alone.
Hint
In SQL the census is one line: SELECT author, count(*) FROM your_table GROUP BY author ORDER BY 2 DESC. Expect a long tail: one-off spellings, a default like unknown or null, and at least one value that is an app's name rather than an actor's.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.