madelogdocs

Concepts

Every field of an event and where it comes from, the hash rule with a worked example, what madelog verify prints on a good and a tampered log, what the badge confirms, and the exact thresholds behind patterns and moments.

Events

The log is one table, events, in .madelog/log.db. An event is never updated or deleted; a correction is a new event. Each row holds:

FieldTypeWhere it comes from
seqinteger, from 1Assigned on append: the previous event's seq plus one
idUUID version 7Generated on append. Time-ordered, so ids sort like the chain. madelog hide and madelog summary edit take one
tsRFC 3339, UTC, millisecondsThe clock at the moment of appending, such as 2026-09-18T07:53:48.617Z. For a commit this is when the hook ran, not the commit's own author date
kindone of intent, commit, ai_session, decision, pattern, summary, hideThe command that wrote it
commit_sha40 hex characters, or nullWhat the event is anchored to. commit and ai_session: the commit. intent and decision: HEAD at the time, null before the first commit. pattern: the last commit the pattern covers. hide: the hidden session's commit. summary: null
payloadcanonical JSON textThe event's data, shape per kind below
prev_hash64 hex charactersThe previous event's hash; 64 zeros for the first event
hash64 hex charactersThis event's hash, computed as described under The hash chain

The table has three indexes (kind, commit_sha, ts), uses the WAL journal (you will see log.db-wal and log.db-shm next to it), and carries two triggers that refuse UPDATE and DELETE:

$ sqlite3 .madelog/log.db "UPDATE events SET payload = replace(payload, 'SQLite', 'Postgres') WHERE seq = 3"
Error: stepping, madelog: events is append-only (19)

The triggers stop accidents, not intent. Someone with the file can drop them; madelog verify is what catches that.

Payloads

Every payload is a flat JSON object. The full JSON Schema is schema/event.schema.json in the repository.

KindFields
intentwhat, for_whom, tools (list of strings), record_ai_sessions (bool). Empty answers are empty strings and an empty list
commitsha, message (the full message, trailing newline included), author (Name <email>), parents (list of shas), files (list, sorted by path), secs_since_prev (wall-clock seconds since the previously recorded commit event, null for the first). Each file: path, added, removed, status (added, modified, deleted, renamed) and diff_excerpt when there is one
ai_sessiontool, model (or null), session_id, prompt (or null), response_excerpt (always null), files (each: path, ai_lines, ai_lines_modified_by_human), accepted (always true)
decisiontext, private (bool)
patternpattern (revert, retry, fix_after_ai or stall), commits (list of full shas), detail (one sentence)
summaryweek_start and week_end (YYYY-MM-DD, Monday and Sunday), text, generated_by (ollama:<model>, heuristic, remote:<model> or user), edited (bool)
hidetarget_kind (always ai_session), target_id (the hidden event's id)

One commit event as madelog log --json prints it:

{
  "seq": 2,
  "id": "01a0b381-e273-7473-93a7-566c69a0eb6d",
  "ts": "2026-09-18T07:53:48.915Z",
  "kind": "commit",
  "commit_sha": "ec66d42714214edf41acca2c555bfd13b6682c44",
  "payload": {
    "author": "Ana Kovač <ana@example.com>",
    "files": [
      {
        "added": 4,
        "diff_excerpt": "@@ -1,3 +1,6 @@\n+mod reconcile;\n+\n fn main() {\n-    println!(\"ledger\");\n+    let matched = reconcile::run(\"bank.csv\", \"invoices.csv\");\n+    println!(\"{matched} matched\");\n }",
        "path": "src/main.rs",
        "removed": 1,
        "status": "modified"
      },
      {
        "added": 3,
        "diff_excerpt": "@@ -0,0 +1,3 @@\n+pub fn run(_bank: &str, _invoices: &str) -> usize {\n+    0\n+}",
        "path": "src/reconcile.rs",
        "removed": 0,
        "status": "added"
      }
    ],
    "message": "feat: reconcile skeleton\n",
    "parents": ["96596713135fe602e3492ff10d475789e8ee988c"],
    "secs_since_prev": null,
    "sha": "ec66d42714214edf41acca2c555bfd13b6682c44"
  },
  "prev_hash": "a1abfea23283a58e84689f3c2e4d2b4af445e26436e752e6aaabe98a4fd3ad69",
  "hash": "463144de03d7489b2b65b2b682e657e3daed4d9cf693a301e58f97599d07d5fa"
}

Diff excerpts

A commit's files are diffed against the first parent with three lines of context and rename detection. A file gets a diff_excerpt only when all of these hold: the patch is text (git and libgit2 both say it is not binary), the path is not ignored by .gitignore or .git/info/exclude, and the path matches no privacy.redact pattern. The excerpt is the first 20 lines of the patch starting at the first @@ hunk header; the diff --git, index and mode lines are dropped. A file without an excerpt has no diff_excerpt key at all, never null. Line counts and status are recorded either way.

Re-running madelog analyze or madelog summarize --force appends new pattern and summary events rather than editing old ones. The page renders the newest summary for each week.

The hash chain

Each event's hash covers the previous event's hash, so a change to any row breaks every row after it:

hash = sha256(prev_hash + "\n" + ts + "\n" + kind + "\n" + canonical_json(payload))

All four parts are UTF-8 text. prev_hash is the previous event's hash in lowercase hex, or 64 zeros. ts and kind are the strings as stored. canonical_json is the payload with object keys sorted bytewise, no whitespace, strings escaped the way serde_json does it (only ", \ and control characters are escaped; non-ASCII text is kept as is). The result is 64 lowercase hex characters. In Python the same canonical form is json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).

Two details matter for anyone recomputing a hash. A commit file without an excerpt has no diff_excerpt key. An AI session with no model or no prompt has "model":null and "prompt":null; the keys are present. Both follow from how the payload structs serialise, and both are part of what was hashed.

A worked example

The published test vector, an intent event at the start of a chain. The exact bytes hashed, as a Python string:

>>> msg = ("0" * 64 + "\n2026-09-17T09:00:00Z\nintent\n"
...        '{"for_whom":"a small studio","record_ai_sessions":true,"tools":["claude-code"],"what":"a madelog cli"}')
>>> hashlib.sha256(msg.encode()).hexdigest()
'3b941a508d089cec26830c48b4882cce349ce15f659504de83107e7683a24546'

The same recipe over a real log reproduces every stored hash. This script reads .madelog/log.db and recomputes the chain from the genesis value; it stops at the first event that does not add up:

import hashlib, json, sqlite3

prev = "0" * 64
rows = sqlite3.connect(".madelog/log.db").execute(
    "select seq, ts, kind, payload, prev_hash, hash from events order by seq")
for seq, ts, kind, payload, prev_hash, h in rows:
    canonical = json.dumps(json.loads(payload), sort_keys=True,
                           separators=(",", ":"), ensure_ascii=False)
    want = hashlib.sha256(f"{prev}\n{ts}\n{kind}\n{canonical}".encode()).hexdigest()
    assert prev_hash == prev and h == want, seq
    prev = h
print("ok, root", prev)

Run against the repository from Getting started, it prints the root madelog verify prints, which is also the root the server returned from /v1/verify/north-fable-4xt3:

$ python3 verify.py
ok, root 6cc1406d82499fc6f69483a603f1f5efe04ac8767edd31674a4f1afaf3b18535
$ madelog verify
chain ok
16 events, root 6cc1406d82499fc6f69483a603f1f5efe04ac8767edd31674a4f1afaf3b18535

The chain root is the hash of the last event. An empty log has the genesis root of 64 zeros.

What madelog verify checks

madelog verify reads every event in order and recomputes the chain from the first one. For each event it checks, in this order, that seq is the previous one plus one, that prev_hash equals the previous event's hash, and that hash equals the recomputed value. On a good log:

$ madelog verify
chain ok
8 events, root fe97dbc626e14dc52a58c6f49352bf0467bed76c7ae4dd4aadc02729ae4f9265

On the first mismatch it prints the sequence number and the reason on standard error, and exits 1. Three ways to break a copy of that log with sqlite3, and what each one prints.

Changing a payload. The triggers have to be dropped first; a plain UPDATE is refused:

$ sqlite3 .madelog/log.db "DROP TRIGGER events_no_update; UPDATE events SET payload = replace(payload, 'SQLite', 'Postgres') WHERE seq = 3"
$ madelog verify
chain broken at seq 3: hash does not match the event contents (18 events read)

Changing a payload and recomputing that event's own hash. The event now checks out on its own, and the next one does not, because its prev_hash still names the old value:

$ madelog verify
chain broken at seq 4: prev_hash does not match the previous event (18 events read)

Removing an event:

$ sqlite3 .madelog/log.db "DROP TRIGGER events_no_delete; DELETE FROM events WHERE seq = 5"
$ madelog verify
chain broken at seq 6: expected seq 5, found 6 (15 events read)

Rewriting every hash from the tampered event onward is possible; it produces a different root. Verification is local: it tells you the file is consistent with itself, not that it matches anything outside the machine. That is what the published root adds.

The root and the badge

When you publish, madelog signs root + "\n" + published_ts + "\n" + slug with the ed25519 key in ~/.config/madelog/device.key and sends root, first and last event timestamps, publish timestamp, signature and HTML to the server. The server checks the signature against the public key it holds for your device id, stores the page, and appends the root to a history it keeps per slug.

The badge on the page reads: "Records from <first> to <last>, root <first 12 characters>, confirmed by the server on <received time>", with the full root under it and a link to /v1/verify/<slug>. That endpoint returns the root and timestamps the server holds:

{
  "slug": "north-fable-4xt3",
  "root": "6cc1406d82499fc6f69483a603f1f5efe04ac8767edd31674a4f1afaf3b18535",
  "first_ts": "2026-09-18T07:53:48.617Z",
  "last_ts": "2026-09-18T07:53:49.507Z",
  "received_at": "2026-09-18T07:53:50.471573Z",
  "device_id": "5ea56900-b0d3-4683-aa32-fe1e3eca821c"
}

Anyone can compare the three: the root on the page, the root at the verify endpoint, and the root madelog verify computes from the log. first_ts and last_ts are the first and last event in the log at publish time, and the last event is usually a summary or pattern appended by publish itself, so last_ts is a few seconds before received_at.

What the server stores

Four tables. devices: an id, the 32-byte public key, an email column that nothing fills in this version, and a creation time. pages: the slug, the device id, the HTML, the root, first_ts, last_ts, published_ts, received_at and deleted_at. roots: one row per publish with the slug, the root and the time received, kept forever. billing_events: raw webhook payloads, none until billing exists.

The server never sees the log, a diff that is not already on the page, a prompt that is not already on the page, a private note, a hidden session's contents, your private key, or your email. It sets no cookies and has no sessions. Privacy has the exact request body.

What "confirmed" means

Confirmed means: at received_at, a device holding a specific private key handed the server a chain whose root was this value. Three things follow.

  • The log cannot be rewritten after that moment without the root changing. A rewritten log produces a different root, and the server still holds the old one.
  • The root does not prove the events are true, only that they were recorded when the chain says and not altered since. A person could type misleading notes; the chain would faithfully record that they did, and when.
  • Re-publishing replaces the page and appends a new root to the server's history. The history is kept even after madelog unpublish, and a deleted slug is never handed to anyone else.

A page rendered with --local carries no confirmation and says "Not yet published". The word "verified" is reserved for a chain that was checked against a published root.

Patterns

madelog analyze runs four rules over the commit events, each carrying the ai_session events anchored to it, and records each match once as a pattern event anchored to the last commit involved. The thresholds are constants in cli/src/patterns.rs:

PatternRuleThresholds
revertThe commit's subject starts with "revert" (any case), or its file changes are the inverse of an earlier commit's: the same set of paths, and for every file this commit's added equals the earlier removed and the reverse. The earlier commit is the nearest one within the window. At least one file must be lopsided; a commit where every file has added == removed never countswindow 48 hours
retryA run of consecutive commits that touch the same set of paths, all within the window measured from the first, whose subjects share enough words with the first subject. Words are lowercase alphanumeric tokens; overlap is Jaccard (shared words divided by all distinct words in both). One pattern per run, covering every commit in itat least 3 commits, 2 hours, overlap at least 0.60
fix_after_aiA commit whose subject contains a word starting with fix, bug, wrong, broke, revert or oops (so fixed, bugs, broken count; prefix does not), or whose own sessions report ai_lines_modified_by_human above zero; and, looking back within the window, the nearest earlier commit that has AI-attributed lines in at least one of the paths this commit touches24 hours
stallA gap between two consecutive commits of at least the window, where the later commit touches at least the file count or changes at least the line count (added + removed over all files)5 days, 10 files or 300 lines

Patterns are printed in commit order. When two land on the same commit, the order is revert, retry, fix after AI, stall. The detail sentence is built from templates with gaps written as words: "two hours", "six days", "47 minutes", "less than a minute".

$ madelog analyze
4 patterns in this log:
  fix_after_ai  fb7eeec d366a14          d366a14 reworked AI-written code in src/import.rs two hours after fb7eeec.
  retry         fcd9b5f b17e06a 1595716  Three commits in 50 minutes reworked the same file.
  revert        ea23143 50f2f57          Reverted 2 files changed in ea23143 one hour earlier.
  stall         50f2f57 72ddd54          After six days without commits, 72ddd54 changed 12 files (444 lines).
recorded 4 new pattern events

Detection is deterministic: the same log gives the same patterns in the same order. A pattern is identified by its kind and its set of commits, so a second run finds the same four and prints nothing new to record. Patterns are computed from event timestamps, which the hook sets when the commit is made; a repository whose history was made before madelog init has no commit events for it and no patterns.

The word overlap can be checked by hand. wip: matching by amount and wip: matching by amount and date share four words out of six distinct: 0.67, a retry. wip: matching by amount, date and reference against the first shares four of seven: 0.57, not a retry.

Moments

A moment is one of the short stories the page tells. Candidates come from patterns and decisions, never from raw diffs, and are listed in this order:

  1. Every fix_after_ai pattern, in commit order. Title: "Fixing AI-written code in <path>" when one path is shared, otherwise "Fixing what the AI wrote". The body names both commits, the gap between them, and the number of lines Git AI attributed to the model.
  2. Every direction change: a public decision whose next commit adds or deletes files in a directory no earlier commit had touched (parents count as touched). Title: "A decision, then a new <dir>/", or "A decision, then new files at the top level".
  3. Every stall pattern, longest gap first. Title: "<Gap> away, then <n> files at once". The body ends with how long the next commit took to follow, or "That is the last commit recorded."

The first five candidates in that order are shown; a log with fewer shows what it has, and a log with none shows one sentence saying the timeline is the whole story. Titles and bodies are template sentences built from the events, and each moment links to the records it was built from. In this version no language model takes part; the generated marker on a moment is reserved for a later version that might retitle them. Moments are recomputed whenever the page is rendered and are not stored as events.

Weeks

Summaries and the page's week headings use the same buckets. Week 1 is the calendar week, Monday to Sunday in your machine's local time zone, that holds the first commit event; later weeks count from that Monday in steps of seven days. Every event falls into a week by its own timestamp: commits, public decisions and patterns are what a week contains. A week with none of those does not exist for --week. A pattern event carries the timestamp of the madelog analyze run that found it, which says nothing about when the work happened, so it is bucketed by its commit_sha instead — the week of the last commit it covers — and falls back to its own timestamp only when it has no anchor. Summaries covers what is written for each week.