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

Rows Leave the Replay, Never the Record

~18 min · master-chief, context-compaction, pagination, sync

Level 0Visitor
0 XP0/33 lessons0/11 achievements
0/100 XP to next level100 XP to go0% complete

Out of the Replay, Never Out of the Record

When compaction takes rows, it does not delete them. They stay in the messages table, flagged: compacted_at says when, compacted_by names the summary row that now stands for them. The summary row is inserted with a timestamp just before the first kept turn, so the flat order reads old rows, summary, recent turns; a small table records which archived row it follows on screen and how many it replaced; and every kept row whose parent left is re-parented onto the summary, so the chain the model walks stays whole. The replay, and the healing that repairs it, read only rows whose compacted_at is empty. Everything that looks a row up by its key still finds it: the request-id check that stops a resent turn from running twice, the report check that stops a report from landing twice, turn status, and Vestige's pointers into the conversation. Deleting a conversation still takes its compacted rows with it.

What Deleting Cost: Cold Review Finding #4

The first compaction archived the rows' JSON into a side table and deleted them, and every keyed lookup lost them. The reviewer traced the chain. A phone asks whether a turn it sent arrived; the lookup by its request id now returns nothing, so turn status answers 404, which a client reads as "never arrived, send it"; the phone's outbox resends; the Chief route's duplicate check misses too; and a stale instruction runs again. The report check lost its rows the same way, so the same report could land twice. Recall excluded the current conversation, so the archived part of the Chief was unreachable from inside it. And Vestige's pointers to those rows no longer resolved, which broke the very recovery path the Chief's own instructions promised. The fix (2154ed97) flags instead of deleting, and at startup the side table is folded back into the messages and dropped.

The Log Gets a Marker, Never an Edit

The JSONL is never rewritten. A compaction appends one pippa_compaction line carrying the brain, the mode (model, digest or background), the counts, the summary row's id, the summary's full text and the ids of the rows it took. A digest's upgrade appends a shorter one, mode upgrade: the brain, the summary row's id and its new text, with no counts and no row ids, because the digest's own marker already named the rows it stands for. Before that, the summary existed only in SQLite (cold review #13), and "where did my history go" had no trail in the log.

Recall Reaches the Rows

Each compacted row's search vectors are flagged compacted. Recall normally skips the current conversation, because its replay is already in the prompt; the filter now reads "another conversation, or a compacted row", so a conversation that never ends can recall its own past. The Chief's standing instruction then says what to do with that: "When earlier context is missing after compaction, use Vestige recall and open the source records before asking Dad to repeat it." Rows that stay are what make that sentence true. When compaction deleted them, it pointed at nothing.

The Visible History Never Shrinks, and Never Arrives Whole

What Dad scrolls is the display transcript: every archived row, oldest first, then the live rows. It is served one window at a time: the newest window by default, the window before a row when scrolling up, or everything from a held row when refreshing. Every window stops at 1,000 messages at most or 512 KiB of JSON, whichever comes first; rows leave its oldest end until it fits, so the newest always come, and a single row larger than the budget is served alone. The WebUI asks for 300, the Pippa app for 200. Compaction summaries never ride a page (coop #374). On 2026-10-09 the Chief's newest 200 messages weighed 2.47 MB, mostly one summary of 524 KB sent three times over (as content, canonical text and speech text) plus a run of tool-heavy turns; the phone refused the page and drew a stale copy without the MacBook's turns. Since the archived rows are on the page themselves, the summary is replay state, not conversation, and a row that hung from it hangs from the archived row it followed.

Sync Reads Only What Moved

A WebUI refresh reads the newest page and joins it to what the screen already holds: find the first held row the new page contains, keep everything before it, take the page from there; if they do not meet, read from the held start. Before that (cold review #13), every change re-downloaded everything since the tab opened. Telegram's poll asks one cheap question every five seconds first: (epoch, version). The version is an in-memory counter bumped on every change; the epoch is a random value minted at each engine start, because counters restart at zero and "version 3" before a restart can mean different contents after it. Unchanged, it skips. Moved, it reads only the newest 100 messages, and it caches the version only once every message it read is settled. The cold review checked this one and found it sound.

The Archive Writes Parts

The daily conversation archive is append-only Markdown, there so that deleting a conversation is survivable. It copied the whole Chief every day, its size growing with the square of the Chief's age, about 10 GB a year by the reviewer's estimate, and the corpus engine indexed every copy (cold review #9). Now each conversation's directory keeps a manifest of which message versions the archive holds and which sealed segments it has read. The first write is the whole conversation; every later write is a part holding only new messages (and edited ones under their new text); only unread segments are read; summaries are left out (ef5d6138).

Bound what you hand over, never what you keep. The replay, the page, the poll and the archive write are all bounded. The record is not: every row stays findable by its key, reachable by recall, and on the page when Dad scrolls back.

Code

Engine excerpt (faithfully trimmed, the conversation store): rows flagged in place, never deleted·python
now = datetime.now(timezone.utc).isoformat()
removed = set(remove_ids)
kept_ids = {m.id for m in to_keep}

# Out of the replay, never out of the record (coop #351): the rows
# stay in ``messages``, flagged, so keys and pointers still find
# them and a conversation delete still takes them with it.
cursor = await db.execute(
    f"SELECT id, created_at FROM messages WHERE id IN ({placeholders})", remove_ids,
)
archived = [{"id": row[0], "created_at": row[1]} for row in await cursor.fetchall()]
await db.execute(
    f"UPDATE messages SET compacted_at = ?, compacted_by = ? WHERE id IN ({placeholders}) AND compacted_at IS NULL",
    (now, summary_id, *remove_ids),
)
# ... the summary row is inserted just before the first kept turn,
# and every kept row whose parent left is re-parented onto it.
Engine excerpt (verbatim, the recall filter): the current conversation is skipped, except for its compacted rows·python
clauses: list[dict] = []
if exclude_conversation_id:
    # The current conversation's replay is already in the prompt; its
    # compacted rows are not (coop #351), so they stay retrievable. A
    # conversation that never ends (Master Chief) recalls its own past.
    clauses.append({"$or": [
        {"conversation_id": {"$ne": exclude_conversation_id}},
        {"compacted": True},
    ]})
A minimal sketch you can run: the same compaction as a delete and as a flag, and what a retry key and a page see afterwards·python
# A minimal sketch: compaction that flags rows out of the replay, against
# the first version's delete. Same rows, same retry key, two outcomes.
import sqlite3


def build() -> sqlite3.Connection:
    db = sqlite3.connect(":memory:")
    db.execute("CREATE TABLE messages (id TEXT PRIMARY KEY, seq REAL, role TEXT, content TEXT,"
               " external_request_id TEXT, compacted_at TEXT, compacted_by TEXT)")
    for i in range(6):
        db.execute("INSERT INTO messages VALUES (?, ?, ?, ?, ?, NULL, NULL)",
                   (f"m{i}", i, "user" if i % 2 == 0 else "assistant", f"turn {i}",
                    f"phone-{i}" if i % 2 == 0 else None))
    return db


def compact(db: sqlite3.Connection, old: list[str], *, delete: bool) -> None:
    marks = ",".join("?" * len(old))
    if delete:  # the first version: archived elsewhere, gone from messages
        db.execute(f"DELETE FROM messages WHERE id IN ({marks})", old)
    else:       # rows leave the replay, never the record
        db.execute(f"UPDATE messages SET compacted_at = 'now', compacted_by = 's1'"
                   f" WHERE id IN ({marks})", old)
    # The summary sorts just before the first kept row.
    db.execute("INSERT INTO messages VALUES ('s1', 3.5, 'assistant', '[summary of m0..m3]', NULL, NULL, NULL)")


for delete in (True, False):
    db = build()
    compact(db, ["m0", "m1", "m2", "m3"], delete=delete)
    replay = [r[0] for r in db.execute("SELECT id FROM messages WHERE compacted_at IS NULL ORDER BY seq")]
    retry = db.execute("SELECT id FROM messages WHERE external_request_id = 'phone-0'").fetchone()
    page = [r[0] for r in db.execute(
        "SELECT id FROM messages WHERE content NOT LIKE '[summary%' ORDER BY seq")]
    print("DELETE" if delete else "FLAG  ", "| replay:", replay,
          "| retry key phone-0 finds:", retry[0] if retry else "nothing, so 'may be sent'",
          "| page:", page)

External links

Exercise

Find a place in your own project that deletes or moves rows to keep something small: a cleanup job, an archive table, a 'trim history' step. List every reader that looks those rows up by key (dedupe checks, status endpoints, foreign keys, search indexes, links from other systems) and write down what each one does on a miss. Then change the cleanup to flag rows out of the hot path instead, add the flag to the hot query, and write one test per reader proving it still finds a flagged row.
Hint
Grep for the table name and for every column used as a lookup key, not just for the cleanup function. The readers that bite are the ones written months later by someone who never saw the cleanup: a retry path, an export, a link in another app.

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.