log.json

The whole worklog as one structured document. It is generated, never written by hand, and it is the only path from markdown to structured data:

worklog/*.md  ->  log.json  ->  the static site
                          \->  anything else, later

The static site is a consumer of log.json, not a second reader of the markdown. That is deliberate. The moment a second code path parses entries directly, the two disagree about something subtle and the bug appears only in whichever output nobody was looking at.

Shape

{
  "spec_version": 1,
  "generator": "cairns 0.1.0",
  "generated": "2026-09-20T23:40:00Z",

  "project": {
    "name": "Hellbender",
    "slug": "hellbender",
    "description": "Reverse engineering Hellbender ...",
    "base_url": "https://toyz.github.io/hellbender/"
  },

  "areas": [
    { "name": "decomp", "about": "facts pulled out of HELLBEND.EXE itself", "count": 30 }
  ],

  "entries": [
    {
      "id": "hellbender/50",
      "number": 50,
      "slug": "kreash-mix-is-the-end-of-a-table",
      "url": "https://toyz.github.io/hellbender/50-kreash-mix-is-the-end-of-a-table",
      "path": "worklog/0050-kreash-mix-is-the-end-of-a-table.md",
      "title": "KREASH.MIX is the end of a table",
      "date": "2026-09-20",
      "areas": ["decomp", "format"],
      "files": ["docs/formats/colour-tables.md"],
      "summary": "Every per-level .MIX in GAME.POD is zero bytes except KREASH.MIX ...",
      "supersedes": [6],
      "superseded_by": [],
      "resolves": [],
      "resolved_by": [],
      "still_unknown": "the span loop that would prove it has not been found yet",
      "body": "The colour tables page had three unknowns ...",
      "content_hash": "sha256:9f2a...",
      "extra": {}
    }
  ],

  "open_questions": [
    { "entry": 50, "text": "the span loop that would prove it has not been found yet" }
  ],

  "readme": "# Hellbender\n\nReverse engineering ..."
}

entries is ordered by number, ascending, always.

The fields that are not just copied through

body is markdown, not HTML. The payload stays renderer-agnostic, so a newer renderer can re-render an old log, and a consumer that wants plain text or a feed excerpt is not unpicking someone else's HTML to get it.

superseded_by is derived by inverting every supersedes in the log. This is the field the site uses to tell a reader standing on entry 6 that entry 50 later overturned it. Deriving rather than storing it keeps the correction recorded in exactly one place - the later entry, which is the only one that could have known.

resolved_by is derived the same way, by inverting every resolves. While it is non-empty the entry's question is closed and is absent from open_questions, so the list is what the project does not yet know rather than everything it has ever wondered.

still_unknown and open_questions. The former is the entry's own trailer, parsed out; the latter is every unresolved one collected in entry order, which is the log answering "what does this project still not know" without anyone maintaining a separate list. Entries whose trailer is nothing are absent from both.

content_hash is sha256: plus the hex digest of the entry file's bytes, exactly as on disk, front matter included. Verifiable with shasum -a 256 against the file, with no knowledge of this format at all. It is what lets a publish send only what changed, and what lets any future ingest dedupe without diffing prose.

readme is the project's README as markdown, when site.readme names one. It is carried in the document rather than read by the renderer, so the site is still built from this file alone - and so anything ingesting a log gets the project's own description of itself along with its entries.

extra holds any front matter key the spec does not define, passed through untouched, so a project can carry its own metadata without forking the format and without the tool needing to know what it means.

Determinism

Two runs over unchanged entries produce byte-identical output but for generated. cairns export --reproducible omits generated entirely, so CI can assert the payload has not changed without a timestamp defeating it.