The model
Five types. Two kinds of edge.
That's the whole schema, and its smallness is the point: every optional field is a field that stops getting filled in.
Anatomy of a node #
One file per node, in nodes/<id>.md: YAML frontmatter, then
markdown prose. Never deleted, never renamed, never moved.
nodes/2026-07-05-mplastic-cheap-rung-falsified.md --- id: 2026-07-05-mplastic-cheap-rung-falsified 1 type: experiment title: "Cheap scoring rungs: MM/GBSA, short-MD, RFD3, …" project: mplastic 2 status: dead 3 created: 2026-07-05 updated: 2026-07-28 repo: git@github.example/binders.git commit: 4b3ee22 4 host: hpc-cluster parents: [2026-06-24-mplastic-eabf-verifier] 5 history: 6 - {date: 2026-07-05, status: running, note: four candidate cheap filters} - {date: 2026-07-28, status: dead, note: all four falsified …} --- ## Claim Some cheap score can pre-filter candidates before the expensive eABF verifier. ## Result All four falsified. Recorded here so they are not 7 re-attempted. - **Single-shot MM/GBSA.** Anti-correlated with pull-off force …
- 1 The id is permanent and equals the filename stem. Never renamed, never reused, never deleted.
- 2 One graph spans all projects. Cross-project edges are the hardest thing to reconstruct later, so they're never sharded away.
- 3
statusis a cache ofhistory[-1]. Hand-editing it is the single most likely way this graph rots, so the validator rejects the mismatch. - 4 A SHA, never a branch. Branches get merged and deleted; the validator refuses anything that isn't a SHA.
- 5 Lineage, not a folder. A cycle-checked DAG.
- 6
historyis an event log — which buys the time axis, the replay slider and the weekly digest for free, with no second thing to maintain. - 7 The body is prose. Claim / What I did / Result / Next is a convention, not a requirement.
Seven fields are required — id, type,
title, project, status,
created, updated — plus at least one
history entry. Everything else is optional, and an experiment
with no repo, commit or host is not a
second-class node: that's what makes a bench experiment or a literature
finding validate unchanged.
The five node types #
Shape means type, on the map and everywhere else. It never means anything else — colour is already carrying status, and a third visual axis would make the map undecodable.
| type | statuses | terminal | extra fields |
|---|---|---|---|
| experiment | planned running worked dead parked | worked, dead | repo commit host artifacts[] |
| direction | open parked closed | closed | — |
| seed | seed promoted killed | promoted, killed | origin contacts[] |
| milestone | planned submitted accepted done | accepted, done | venue date url |
| task | todo doing done dropped | done, dropped | due source |
dead is not closed
An issue tracker has two states and throws away the distinction that
matters here. dead means refuted — and it stays in the
graph, findable, with the reason attached.
Two node types wanted extra fields
Milestones reached for repo and artifacts. They
were made to use their bodies instead. One field has been added in the
project's life — relates, below — and it took a written
argument.
A literature finding is an experiment
There is no separate type for one, and reaching for a new type here is a
mistake. A literature finding has a claim, a method and a result — the corpus
is the instrument. status: dead on a claim the literature refuted
means the same thing downstream as a job that failed.
Write the claim the finding settles, not a summary of what you read.
“Can a reported final Tg be fitted as Tgp?” with
status: dead is a node someone finds by asking that question.
“Tg literature review” is not. See
starting from a literature review.
Status is an event log #
status and history are the same fact, and storing a
fact twice invites drift — so one is derived from the other.
history is the source of truth;
status is a cache of history[-1].status, kept
because it's what you actually grep for and what reads well in a diff.
The validator enforces that they agree. Use
cairn new_node --update, which moves status,
updated and history atomically.
planned is not terminal either — it exists so a
pre-registration can be truthful. An experiment created with no status is
running, which is what you want when logging work that already
happened.A claim written before the result is a pre-registration; written
after, it's a story. That difference is what makes a dead end
publishable — and it's the only reason planned is a status
instead of a convention.
Two kinds of edge #
Cairn has two edge sets, doing two different jobs, and keeping them separate is the single most load-bearing decision in the schema.
parents is lineage: what this work grew out of.
It is a DAG, it is cycle-checked, it drives the layout and the “isolate
lineage” view, and it carries no meaning beyond descent — every arrow looks
the same.
relates is meaning: typed, backlinked, and
deliberately not cycle-checked.
relates existed,
the July panel contradicting the June one was two identical grey arrows and a
sentence of prose that only read correctly from one of the two nodes.The four relation types
Four, and the bar for a fifth is a real node that needs it. An edge vocabulary nobody can keep in their head is one that gets used inconsistently, and an inconsistently-typed edge is worse than an untyped one.
The relation hues sit deliberately off the status palette (green / red / blue / amber), so a typed edge never reads as a status.
Why it had to exist
Two scramble panels, same control, opposite verdict:
nodes/2026-07-31-mplastic-r1-scramble-panel.md relates: - {to: 2026-06-25-mplastic-scramble-control, type: contradicts, note: "same control, opposite verdict: 3.1 sigma for order on crystalline PET, composition-only on the amorphous film. Surface disorder is the likely variable"}
Rules that differ from parents
- Not cycle-checked. Two results really can contradict each other, and that is a fact about the research rather than a defect.
- May point backwards in time. A result from August can contradict one from June.
- Backlinked. The renderer shows the inverse phrasing on the target, so a contradiction is equally findable from either node — otherwise you only find it from whichever node happened to be written second, which is the node the reader hasn't got to yet.
- A pair joined by both lineage and a relation draws one arrow, styled by the relation. The typed edge is the more specific claim.
noteis optional but warned about. A typed edge with no reason is an arrow nobody can act on.
Adding an edge is not a state transition, so it appends no history entry and needs no status change:
cairn new_node --update 2026-07-31-mplastic-r1-scramble-panel \ --relate "contradicts:2026-06-25-mplastic-scramble-control:composition, not order"
And a third, softer kind: [[wikilinks]]
In a node body, [[2026-07-01-polyid-network-ablation]] refers to
another node and renders as a link titled with that node's title.
validate warns if the target doesn't exist.
Use it for the connections that aren't lineage and aren't one of the four
relation types — “this builds on the wD-MPNN core”, “see the schema task
first”. A cross-project dependency goes here, in the body,
rather than in parents.
It is also Obsidian's own syntax, which is why nodes/ is already
an Obsidian vault with backlinks and full-text search over the same files, with
no export step. See the map.
Where a human reacts #
The agent writes the record; you read it later and have a reaction — a doubt,
a correction, “the number here is wrong”, “this is the one that matters”. That
goes under a ## Notes heading in the body, one dated bullet per
note:
## Notes - **2026-08-26** (jlaw) the spread looks bimodal — does it track the seed?
Written by cairn note <id> "…"; read back by
standup, digest, build/report.md and the
map. Three things about it are deliberate:
- It is a body edit, not a status change.
status,updatedandhistorydon't move, because a note isn't a claim about the state of the work. If it movedupdated, the replay slider would show a change that never happened. - No new field, against the resist-new-fields rule. The line
carries its own date, so it stays findable by reading the body — exactly how
## Nextis already read. - “Unanswered” is derived. A note dated at or after the node's last history entry is one no later work has responded to; once the thread moves, it stops being reported. That's what keeps it off a nag list.
It works in the other direction too, which is the answer to “can the agent
pick up on comments I left?” Yes — notes are in the node body, so they're
in standup's resume packet and in the session-start context.
A note is how you hand an instruction to next week's session without
writing a prompt.
Papers, and the DOI as the join key #
Any node may cite papers by bare DOI in refs:. Each cited paper
gets a page in papers/ with CrossRef-verified metadata:
doi: 10.1021/acs.jced.3c00123 title: Solvent effects on nucleophilic substitution rate constants authors: [R. Okonkwo, M. Feld] year: 2023 venue: J. Chem. Eng. Data bibkey: okonkwo2023solvent # what references.bib calls it md: hpc-cluster:/projects/…/refs/10.1021__acs.jced.3c00123.md
The DOI is the only identifier common to Zotero, the
references.bib Overleaf compiles, the converted full text on the
compute host, and a node's refs:. bibkey and
md are what make that join usable: one records what the citation
is called, the other where the readable text lives. Full text never enters the
repo.
Don't hand-write these — cairn add_paper <doi> fetches
CrossRef and refuses to write a page it couldn't verify. A DOI recalled
rather than resolved is worse than no DOI.
Paper pages are validated only for having a doi: — they're a
lookup table, not part of the DAG. The renderer builds backlinks both ways, so
a paper shows every node citing it, and a node shows what else cites the same
paper.
What the validator checks #
It runs on every commit via a pre-commit hook. Silent schema drift is how this rots, so errors block the commit.
Errors
idmatches the filename stem and theYYYY-MM-DD-slugformat; no duplicatestypeis known;statusis valid for that type- required fields present; dates parse;
updated >= created historynon-empty, chronological, and its last entry agrees withstatusparentsall resolve, nothing is its own parent, no cyclesrelatesentries have a knowntypeand a resolving, non-selfto— cycles are legal here- history entries carry only
date/status/note— an extra key means an unquoted comma split the note and turned its tail into a bogus key refsare bare DOIs;commitis a SHA;artifactsarehost:/path
That comma check exists because it actually happened:
note: past 1,000 systems silently became note: past 1
plus a junk key 000 systems, across 17 of the first 55 backfilled
nodes.
Warnings — worth reading, but they don't block
- an unknown frontmatter field — schema drift; add it properly or drop it
- a
relatesentry with nonoteexplaining why - a
refwith no page inpapers/ - an open task with no
due— it will never surface in a digest, which makes it invisible rather than pending - an
iddate prefix disagreeing withcreated