Simple, scoped, sharable memory for AI agent swarms.
A small hosted store that many agents, on many hosts, can read and write at
once without overwriting each other or seeing each other's scopes. Entries are
plain <scope>/<entry>.md files β terse pointers, gotchas, work history β so
what an agent wrote stays readable by a human in a text editor, and what a human
wrote is readable by an agent with no ingestion step.
It is a store, not a retrieval engine: no embeddings, no index build, no ranking model. A scope is a directory, a memory is a bullet in a file, and recall is that file rendered. If you want semantic search over a corpus, cairn is the wrong tool. If you want a shared notebook several agents can be trusted to write to concurrently, this is what it does.
| piece | what it is | documented in |
|---|---|---|
the store β cairn-server |
scoped markdown entries over HTTP: per-token scope allowlists, attributed appends, If-Match replaces, and a snapshot route each host syncs a read-through cache from |
The server; server/README.md |
the client β cairn |
one Go binary: sync, recall, search, attributed writes, diagnostics. A Python client (#cairn) still ships as the oracle the Go one is diffed against |
The client |
| scope memos | short notices an agent sends to a scope, which every agent session on that scope sees once, fenced as untrusted data | Scope memos |
the browser surface β cairn-ui |
browse and search entries, sign in (credential form, or GitHub through the operator's GoTrue), share scopes, team links, the memos tab, sessions and arcs | The browser surface; internal/ui/README.md |
transcripts β cairn-capture |
opt-in, redacted capture of agent session transcripts, uploaded to cairn-ui and read there or with cairn transcript; unarmed until the operator arms it |
Transcripts, capture and plugins |
| the plugin API | out-of-process programs that read transcripts and write back derived records (two examples ship in plugins/) |
internal/plugins/README.md; plugins/README.md |
the Helm chart β charts/cairn |
the store and the UI, with the known manifest mistakes refused at render time | charts/cairn/README.md |
β "One binary" is true per role, not for the project. The flake packages six Go programs
β cairn, cairn-server, cairn-ui, cairn-capture, and the two example plugins
cairn-summary and cairn-clickup β plus the Python oracle client and pod. An agent host
needs only cairn (and cairn-capture if it uploads transcripts).
Four properties you would otherwise have to build yourself on top of a shared bucket, a git repo or an NFS mount:
-
Scoped. A token carries a scope allowlist, and it gates every route except
/healthz. A scope outside it answers exactly what a scope that never existed answers, so an agent cannot enumerate the store by probing for errors. Give each agent, or each task, its own token and its own slice. β One thing is still shared, and it is the reason this is not tenant isolation:X-Store-Snapshotand the freshness line that opens every body carry a store-wideentry-files=count, over scopes the caller cannot name. Two parties on one pod can each watch the other's count move. Stated inserver/server.py, and repeated here rather than left to be found. -
Attributed β a record of who wrote what, not a security boundary.
cairn appendrequires--session(no default, no env fallback) and renders- YYYY-MM-DD: <text> [cairn: <identity>/<session>], so a swarm's memory carries which agent believed a thing and on which run. Read at its real width:- the
<identity>is taken from the token onPOST /bullets, and a body-suppliedactoris never read β so on that route it cannot be forged; - the
<session>is supplied by the caller. It is correlation data, not an identity claim: the server validates its shape and never its ownership, so one agent can name another's session β and a session id is printed in the recall of the entry it wrote to, readable by anyone who can read the scope (--ref <entry>, or--limit <n>for the whole scope; the default digest prints one body out of N, so it is not the way to enumerate them), so they are not secrets either; putandcreatewrite your bytes verbatim, trailer included, and the server does not check it. Enforcing that was considered and declined (server/server.py).
π΄ And there is no append-only credential to close the gap with. The verb set is closed at three β
read,write,adminβ withappenddeliberately absent, because all three write verbs mutate a file through one path and a fourth verb would be spelled rather than structural (internal/control). Any token that can append can alsoPUT. So treat a trailer as a cooperative record for debugging and recall, never as evidence of authorship. - the
-
Concurrency-safe, and safe to retry.
PUTrequiresIf-Match(428without it;*refused), andcreatelands through a hard link, soEEXISTis decided by the kernel rather than by a check-then-write. Two agents racing a create cannot both win β the loser gets exit9(it already exists, do not retry), which is deliberately not the8a lostputupdate gets (re-sync, re-derive, re-apply): the server answers the same412for both and the remedies are opposites, so conflating them retries forever. An append is recognised by content hash, so a re-POST after a timeout is idempotent and reportsduplicaterather than writing twice β the failure mode a retrying agent actually has. -
Honest about staleness. Each host keeps a stamped read-through replica, so recall does not depend on the network. A read names which of four states produced it β
live,cached(with age and revision),scope-empty, or π΄store-unreachable, no cache, which exits3and must never be read as the third:scope-emptyand an unreachable store both "print no entries" and one of them is a lie. π΄ The exit code, not the output, is what says whether the report is complete β exit3also covers a cache that exists but cannot be fully read, and what is printed beside it then depends on the verb (Β§ Exit codes). Orthogonally a read names the scope's status, so acachedread can still reportscope-absent(no such scope here β also what a scope your token cannot see looks like) orscope-unreadable. An agent that cannot tell "nothing is there" from "I could not look" will act on the difference.
# Each agent host: point at the pod and its own token.
mkdir -p ~/.config/subsystem-store
cat > ~/.config/subsystem-store/env <<'EOF'
CAIRN_URL=https://store.example.invalid
CAIRN_TOKEN=<this agent's token>
EOF
# Agent A records a finding, attributed to its own session.
cairn append --scope alpha-index --ref widget-cfg \
--text 'the retry budget is per-connection, not per-request' \
--session "$AGENT_SESSION_ID"
# β cairn: appended instance=personal scope=alpha-index ref=widget-cfg revision=<sha>
# (`duplicate` instead of `appended` if that exact bullet was already there)
# Agent B, on another host, picks it up.
cairn sync # refresh the local replica
cairn recall --scope alpha-index # the scope's digest, with A's bullet and its session id
cairn search 'retry budget' # or find the hunk by text
# A third agent, whose token does not name alpha-index:
cairn recall --scope alpha-index # `scope-absent`, exit 0 β byte-identical
# to a scope that was never createdcairn doctor answers pod reachability, credential, cache stamp, counts, scope
visibility and which store this host resolves β in one call, with --json for a
supervising process.
nix run github:ZacxDev/cairn -- doctor # the default client β the GO one β uninstalled
nix build github:ZacxDev/cairn#cairn-go # the Go client, by name
nix build github:ZacxDev/cairn#cairn # the PYTHON client β the oracle, not the default
nix build github:ZacxDev/cairn#server-image # the PYTHON pod image β built, published, NOT deployed
nix build github:ZacxDev/cairn#server-image-go # the GO pod image β this is what the cluster runs
nix build github:ZacxDev/cairn#cairn-ui # the BROWSER surface β published, and DEPLOYEDConsumers pin this flake as an input. The version is the git revision β
never written down by hand β so a built cairn cannot disagree with the code
in it. Moving a pin forward? Upgrading from an older pin
holds every migration notice, and CHANGELOG.md indexes each contract
change by PR and sha:
- the default client became the Go one β Β§ The default client is now the Go one;
- the environment variables were renamed to
CAIRN_*, and both spellings work β Β§ The environment variables are nowCAIRN_*; tags:stopped being an ignored front-matter key β Β§tags:is no longer an ignored key.
A host is configured by ~/.config/subsystem-store/env (or $CAIRN_CONFIG), one
KEY=value per line; an environment variable of the same name wins for the default
instance. The keys:
| key | what it is |
|---|---|
CAIRN_URL |
the pod's base URL |
CAIRN_TOKEN |
this host's bearer token for the pod |
CAIRN_UI_URL |
cairn-ui's client listener (-client-api-addr) β needed only by the memo verbs and cairn transcript |
CAIRN_UI_TOKEN |
this agent's own control-journal credential for that listener (issued by cairn-server -issue-credential, usually narrowed) |
Other variables the client reads, each documented where it is used: CAIRN_ROUTES
(below), CAIRN_REF_BASE_<SYSTEM> (Β§ Refs), CAIRN_AUDIT_HOSTS and
CAIRN_AUDIT_GITHUB_API (Β§ cairn audit-refs).
The client's full read set is a ledger, EnvReads in internal/client/envreads.go;
the pod's and cairn-ui's variables are in cairn-server -h, cairn-ui -h and
server/README.md.
π΄ NEW-BEATS-OLD IS A RULE WITHIN ONE SOURCE, AND THE SOURCES COMPOSE THE OTHER WAY ROUND. For the default instance the whole environment is consulted first, and only if it yields nothing is the file read β so an old name exported beats a new name in the file:
~/.config/subsystem-store/env: CAIRN_URL=https://new.example.invalid
environment: SUBSYSTEM_STORE_URL=https://old.example.invalid
β the client uses old.example.invalid
Migrating the file alone therefore changes nothing while the old variable is still
exported, and the deprecation line β which describes only its own source β will not tell
you so. unset SUBSYSTEM_STORE_URL (and SUBSYSTEM_STORE_TOKEN) in the same change, or
export the new names too. β For a non-default instance the environment is not
consulted at all, so there the file is the only thing that decides.
The file above is the instance called personal. A second instance is an additive
file at ~/.config/subsystem-store/instances/<alias>.env with its own cache root
(~/.cache/subsystem-store-<alias>) and its own sync stamp; the default instance's cache
root does not move. Which instance a scope belongs to is decided by a table you
supply β ~/.config/subsystem-store/routes.json, or $CAIRN_ROUTES, a flat JSON object
of "scope": "alias" β because this program knows how to route and nothing about who
routes where.
π΄ With more than one instance, an unregistered scope refuses at exit 11, naming
the scope. It never falls back: a write that lands in a store nobody reads is
found days later, if at all, and a refusal costs one error message.
cairn routes --check grades the table in both directions β a scope with no
entry, and an entry naming an alias that does not exist. β It does not fail on
a table entry naming a scope that holds no entries: that prints as a note,
because a snapshot ships entry files rather than directories, so a stale entry
and one pre-registered before its first write look identical from here. Do not
gate CI on --check expecting it to catch that case. With one instance
configured, every read prints what it always did. Two exceptions at any instance
count: the write verbs name their instance unconditionally β "where did that
bullet go" is a question about a durable record, asked later, by someone who no
longer has the terminal β and a table entry naming an alias this host has no config
for still refuses. β If you parse cairn: appended scope=β¦ ref=β¦ positionally,
that field is new β it sits between the status and scope=. The binding rules for
anyone editing a routing path are in lib/README.md.
cairn -h prints this list from the dispatcher, and cairn -verbs prints it as a
<verb> reads|writes ledger; read it there rather than trusting a copy. The
Python oracle (#cairn) has the first ten rows; every verb marked Go exists only on
the Go client.
| verb | what it does | |
|---|---|---|
sync |
refresh the local cache from the pod | |
recall --scope X |
the scope's digest (--ref R, --list, --limit N, --page N, --tag, --ref-to) |
|
search 'query' |
find a hunk by text (--all-scopes, --tag, --ref-to) |
|
validate |
the post-write check: parse, dropped lines, marker reachability | |
ls-entries |
what the cache actually holds β the ENTRY files, never a scope's README.md |
|
doctor |
one call of diagnostics (--json, --no-sync) |
|
routes |
the scopeβinstance table, graded (--check) |
|
append --scope S --ref R --text 'β¦' --session ID |
append one dated, attributed bullet | writes |
put --scope S --ref R --file F |
replace a whole entry behind If-Match |
writes |
create --scope S --ref R --file F |
create a new entry (refuses to overwrite) | writes |
sessions --scope X |
which sessions wrote attributed bullets, with coverage | Go |
arcs --scope X |
which registered arcs touched a scope, declared or inferred; --check is the orphan check |
Go |
arc-show --slug S |
one registered arc: status, coverage, members; --outline, --section/--from/--limit read it in pages |
Go |
arc-register --slug S --from F |
register or update an arc from a JSON payload | Go, writes |
sources --scope X |
which code β repositories and branch β a scope declares it describes | Go |
audit-refs --scope X |
check entries' paths, symbols, commits and PRs against the declared sources | Go |
memo-send, memo-check, memo-read, memo-retract |
scope memos β Β§ Scope memos | Go |
transcript skeleton|records|tool |
read a stored session transcript | Go |
Every verb that takes --scope except sync also accepts --repo P instead, deriving the
scope from the repository (sync --repo is a usage error, exit 2; routes and doctor take
neither). Global options: --cache PATH, --timeout SECONDS.
cairn -exit-codes prints the table both clients are built from. Read outcomes and
write outcomes are disjoint, so a supervisor cannot read silence as success:
| code | meaning |
|---|---|
0 |
content was served (live, cached, or a genuinely empty scope) |
2 |
usage: the command line must change |
3 |
nothing was read β the store was unreachable with no cache, or the cache is there and could not be fully read (a mode-000 scope directory or entry file) |
4 |
sync did not refresh, though a usable cache survived |
5 |
the store's archive was refused (link, traversal, duplicate, miscount); the memo and transcript verbs use it for an answer they cannot read |
6 |
the store refused the write β change the request |
7 |
the write did not happen, and a retry is the right response |
8 |
precondition failed: re-sync, re-derive, re-apply |
9 |
create only: the entry already exists β do not retry, and do not conflate it with 8 |
11 |
no instance could be decided for this scope |
β Exit 3 from an unreadable cache is a permissions problem on this host, not an outage
to retry: fix the mode, or cairn sync to replace the cache. cairn validate over that
state exits 3 itself with 0 bytes on stdout (measured on both clients), so it diagnoses
nothing the failing read did not already say.
π΄ What an unreadable cache prints beside its 3 depends on the verb, so the exit code β not
the output β is what says whether a report is complete. The read names the failure
(index entry unreadable: under <root> (β¦) β the store was not fully read); measured on BOTH
clients, byte-identical, over a mode-000 scope directory and a mode-000 entry file, each run
naming one --scope:
| verb | exit | stdout | the cached banner |
|---|---|---|---|
recall |
3 |
0 bytes | not printed, on either stream |
search |
3 |
0 bytes | not printed, on either stream |
validate |
3 |
0 bytes for the single scope those runs named β see below | on stderr |
π΄ The validate stdout cell is not a general claim. validate prints its
per-scope line inside its loop, so with no --scope every scope processed before
the unreadable one has already printed: measured over a readable 9-entry scope plus
an unreadable one, the unreadable scope sorting after gave exit 3 with 2,030
bytes of stdout, sorting before gave exit 3 with 0. At exit 3 stdout is a
PARTIAL report whose length depends on how far the walk got. β Unmeasured, and
not asserted either way: search --all-scopes over a partially-unreadable store
(that flag refuses --cache).
The banner column's cause is ordering rather than policy β the reads print their
banner after the reader returns, so the raise pre-empts it, while validate
prints its banner first. For a read, the ABSENCE of the banner is the signal.
doctor prints its own set on every run β 0 every check measured and fine, 9 a
problem, 10 no problem measured, but at least one check could not look, which is not a
clean bill of health. arcs --check and audit-refs reuse it. The {0, 9} overlap with
create's 9 is deliberate: no call site can see both.
Writes never degrade. There is no cache to answer from and no such thing as a
stale write, so an unreachable store is a refused write at 7, never a queued or
local one.
GET /api/v1/snapshot carries an ETag β "sha256:<64 hex>" over the
uncompressed tar β and both clients store it beside the cache in .sync-etag
and offer it back as If-None-Match on the next sync. A pod that has nothing new
answers 304 with no body, and the client keeps the cache it already has:
$ cairn sync # first run
cairn: live β fetched from https://store.example.invalid just now β 7 entries, snapshot seeded=β¦
$ cairn sync # nothing changed since
cairn: live β already current at https://store.example.invalid β not modified, snapshot seeded=β¦
Read that line as its own thing. It is still the live state β the pod was
reached and answered β and it is neither fetched β¦ just now (bytes arrived) nor
β β¦ SERVED FROM CACHE (the pod could not be reached). β What it buys, exactly:
the client's download and its extraction. The server still walks the store and builds the
archive to compute the digest, so this is bandwidth and client CPU, not pod CPU. Nothing is
written on a 304, so a later cached banner still dates the last download β it
under-states freshness rather than over-stating it.
cairn sessions --scope X (and the pod's GET /api/v1/sessions/X) lists every session id that
signed a surviving ## Nuance / work-history bullet with a [cairn: <actor>/<session>] trailer,
per session: the actor(s) as written, the bullet count and the date span. Both surfaces call ONE
renderer, so the report bytes are identical; only the line above them differs (the CLI's cache
banner, the pod's snapshot stamp). Every answer prints its coverage, because most bullets carry no
trailer: attributed: K of N (the NβK are NOT listed), what was scanned or rejected, that reads
are not recorded, and that trailers are self-reported (put/create write bytes verbatim,
and the session id is the writer's own word). scope-absent, scope-empty,
no-attributed-writes and scope-unreadable are four different statements and render
differently; a scope you may not read answers exactly like one that does not exist. Exit codes are
the read set β 0 for every answer, 3 when nothing in the scope could be scanned.
A scope declares its code sources in the browser (git:<host>/<repo-path>[//<subpath>]@<branch>;
cairn sources prints them). cairn audit-refs reads them from the pod, reads the scope's entries
from the synced cache, fetches each source into a full mirror under $XDG_CACHE_HOME/cairn/mirrors
(git fetch --prune --no-tags of refs/heads/* and refs/pull/*/head), and checks every
CHECKABLE claim at the fetched commit: a ## Pointers path (repo-root-relative, then
subpath-relative), a structural path#Symbol row, a RESOLVED <sha>: marker (reachable from the
declared branch, from another fetched ref, or not at all β never by object presence, never through a
tag) and a github:<owner>/<repo>#N ref (GitHub's API; GH_TOKEN/GITHUB_TOKEN if set). It prints
sources=β¦, checked=N stale=S unchecked=U, then one line per finding quoting the commit.
- Exit 9: a claim was measured stale (a missing path, a symbol absent from its file, a PR that
does not exist). A path or symbol is only ever stale when EVERY declared source was read: beside a
source that could not be fetched, or whose
gitfailed on that claim, it isunchecked: not in the sources read, because the unread source may hold it. Exit 10: something could not be looked at β an undeclared scope, an unconfigured or unreadable sources journal, an unmapped or unreachable source, an ambiguous or unreachable sha, a rate-limited PR check, or a run that checked nothing. An interrupt (Ctrl-C) kills the runninggitcall's whole process group beforecairnexits. Exit 0 only whenchecked > 0and neither of those. A SHA reachable only off-branch, an open/merged/closed PR and a ref to a repository the scope does not declare are informational. - Only hosts the auditing host maps are ever contacted:
CAIRN_AUDIT_HOSTS=host=base[,β¦], defaultgithub.com=https://github.com; bases arehttps://orfile://(no SSH in v1) and may not carry credentials (use a credential helper).CAIRN_AUDIT_GITHUB_APIoverrides the API base (https://, orhttp://to a loopback host only). A PR isnot-foundonly when the repository itself is visible to the credential; a 404 from an invisible (private) repository is could-not-look. - The mirrors are isolated from the caller's repository: every
gitcall takes--git-dirand runs with git's repository-local variables (GIT_DIR,GIT_WORK_TREE, β¦) stripped, so running it from a hook cannot redirect a--prunefetch into the operator's own repository. - What it does not judge: whether a file still says what the entry claims, whether a commit
implements it, whether an
OPEN:item is still open. Those stay out of the exit code.
An arc is one effort tracked by one handoff doc in one repo, keyed by (home scope, slug).
Arcs are REGISTERED β pushed by the operator tooling at each handoff β never derived, and they live
in an append-only journal the pod keeps outside the store tree: cairn-server -arc-journal <file> (or $CAIRN_ARC_JOURNAL), with no default. Unset, every arc route answers
registrations-unconfigured, the designed off state; and the pod refuses to start if the
symlink-resolved path is inside -store, because every directory at the store root is enumerated
as a scope. Registrations are kept forever; the latest valid record per arc is what is shown, and a
torn final line or an unreadable one is skipped without poisoning the rest.
cairn arc-register --repo . --slug my-topic --from arc.json # PUT /api/v1/arc/<home>/my-topic
cairn arc-show --repo . --slug my-topic # GET /api/v1/arc/<home>/my-topic
cairn arc-show --repo . --slug my-topic --outline # β¦?view=outline (see "Reading a large arc")
cairn arcs --scope alpha-notes # GET /api/v1/arcs/alpha-notesThe payload (schema: 1) carries status (open | closed; omit it and it is unknown, which
is never shown or counted as open), closing_kind, declared_scopes (the home is always
declared), writers_measured/readers_measured, commits_total/commits_unstamped,
reported_at and members (session, role β originated | earliest-stamped | wrote | resumed,
first_seen). Unknown fields are refused; a member whose session id could never match a write
trailer is not stored and is counted back as unjoinable=N. The pod stamps registered_by (the
credential's identity) and registered_at (its clock). A push that did not measure a leg keeps the
members an earlier push measured for it, marked carried. Registering needs the write verb on
the home scope and on every declared scope (a scope you may not write answers like one that does
not exist); the bare legacy row may read arcs and may not register one.
Visibility: an arc exists for you only when its HOME scope is readable to you β or, for an arc
that opted in (schema 2, below), when any of its declared scopes is; its other scopes are listed
only if readable too, and hidden ones are omitted, not counted. arcs --scope X lists an
arc as declared when it names X, or inferred when one of its member sessions wrote an
attributed bullet in X without the arc declaring it β a join over self-reported trailers, labelled
as such. All three verbs print the pod's body verbatim (registrations are not in the cache) and use
the existing exit codes: reads 0 for every answer, 3 if the pod did not answer; arc-register 0
registered or unchanged, 6 refused (including a pod with no journal), 7 did not happen.
cairn arcs --check asks the pod (GET /api/v1/arcs/<scope>?check=1) whether the registry
holds anything it should not β a flag on arcs, not a doctor check, because doctor is about
this host and this credential:
cairn arcs --check --repo . # arcs HOMED in this repo's scope
cairn arcs --check --all-scopes --scope alpha-notes # every arc visible on that scope's podFindings (exit 9): a journal-damaged journal (a complete record that did not parse, or a
torn tail β an arc registered only by that record is invisible everywhere); an arc whose
home-scope-absent (its home is readable to you by grant and does not exist in the store); and a
declared-scope-absent (the same for a declared scope). Not findings, printed as coverage: a
member session with no attributed write in any scope you can read, and a writing session in no arc β
turning either into a finding would hold the check non-zero forever. Exit 10: no -arc-journal
on the pod, a journal it cannot read, a pod that did not answer, or a pod too old to know
?check=1 (it answers a listing, which the client refuses to read as a pass). Only arcs whose HOME
you can read are checked β plus any that opted in through a declared scope you can read (schema 2,
below), whose home is then judged only if you can read it. β One pod per run: the scope picks the instance, and a multi-instance
host is not fanned out. tests/arcs/e2e.sh is the end-to-end check over both real binaries
(--self-test proves it goes RED on a sabotaged build).
A summary can hold 50 ranked steps and 24 KiB, and a member list many rows, so arc-show can be
read in bounded pieces. With no view parameter the answer β body AND headers β is byte-for-byte
the answer above (view=full is the same body, plus X-Arc-View: full); the views are opt-in.
cairn arc-show --repo . --slug my-topic --outline # ?view=outline
TOKEN=da687b9f5fb1bfc0 # the outline's `token:` line
cairn arc-show --repo . --slug my-topic --section next_steps --from 40 --at "$TOKEN"
# ?view=page§ion=next_steps&from=40&at=$TOKENThe tail of an outline, from a synthetic arc with 12 state lines, 46 ranked steps and 7 members:
state_now: 12 items, 648 B
next_steps: 46 items, 3855 B
members: 7
token: da687b9f5fb1bfc0
page a section: ?view=page§ion=<state_now|next_steps|members>&from=0&at=da687b9f5fb1bfc0 (limit default 20, at most 200; max_bytes default 8192, counting item lines only) β or cairn arc-show --section <name> --from 0 --at da687b9f5fb1bfc0
view=outlineβ the head lines, the summary's provenance line, its stated closing condition, task refs and aliases, then one size line per section (<section>: N items, B B, B being the items' own bytes;members: N), atoken:line, and a one-line paging hint carrying it. No item text.view=page§ion=state_now|next_steps|membersβ items fromfrom(default 0), each line tagged with its STORED index,[next_steps#40] <text>; members are indexed in the order the full answer prints them (by session id). A page stops atlimititems (default 20, 1β200) ormax_bytesbytes of item lines (default 8192, 1β65536), whichever comes first β the page's header lines and the pod's freshness paragraph are extra (roughly 1β1.5 KiB) β but always carries at least one item while any remain, and ends withmore: from=<next>orend(fromat or past the count: no item,end).at=<token>β the outline'stoken:, carried from page to page. The token is a short digest of everything a page can serve, as YOU see it: the summary's provenance line and every item of every section, members included (with only the scopes you can read). If any of that changed since the outline β a re-registration at the same commit, a member inserted, a step re-ranked β the page addsarc changed since <at>; re-read the outline, because the stored indices may have shifted. Themember writes:coverage counts are not in it, so unrelated writes do not invalidate a pager. Readers with different visibility may see different tokens.- A malformed parameter is
400 bad-requestwith its sentence (limit0 or 201, an unknownsection,from=-1, anatthat is not a 16-hex token, any view parameter given EMPTY, a page parameter besideview=outline, β¦) β the same answer for an arc you can see, one you cannot and one that does not exist. Visibility is unchanged: an arc you may not see isarc-unregistered, byte-identical to an absent one, in every view. X-Arc-View: full|outline|pageis sent only on anarc-foundanswer to a request that named a view (view=present,fullincluded) β never on a plain request, and never on any other status. A pod that predates the views ignores the parameters and answers the full arc without it, soarc-showwith a view flag says so on stderr (and still prints the body, exit 0). The client sends every flag you GIVE, as given β an empty value included β and checks nothing itself: the pod is the one validator, and its 400 β a page flag without--section,--outlinebeside a page flag, an empty or out-of-range value β is exit 2 with the pod's sentence.
A schema: 2 payload may add two OPTIONAL fields; everything else is as above.
summaryβ the registering tool's own account of where the arc stands, so a reader can ask the store "how far along is this" instead of the handoff document's repository:closing_condition(string, optional),state_now(list),next_steps(list, ranked β order is kept),source(required:repo_pathrelative to the repo,commitas a full 40- or 64-hex id,committed_atRFC 3339),task_refs(list, no whitespace) andaliases(list, folded like a slug, deduplicated, sorted).arc-showprints it before the member list (after themember writes:line, so a reader that keeps only a prefix of the body reaches it first), provenance line first (summary: the registering tool's own word, read from <repo_path> at commit <commit> (committed <committed_at>)); a listing row gainsΒ· aliases a, b.state_nowandnext_stepsitems are printed VERBATIM, in the order sent, one per line understate now: N/next steps (ranked): N, each indented two spaces. The pod adds no number and no bullet: the producer owns any numbering (send3. drainfor a rank with a gap, and it prints3. drain).visible_to_declared: trueβ the arc's opt-in: it is also shown, onarc-show,arcs,arcs --checkand the browser's arcs and session pages, to readers of any declared scope, whether or not they can read its home. Its body then carriesvisibility exception (visible_to_declared: true): β¦and its listing rowΒ· declared-visible, so the answer says why it is there. Unset orfalseis exactly the home-only rule.
What a consumer may rely on in a listing row (pinned by tests):
- the row is
Β·-separated, and schema 2 adds at most two segments at its END, in this order:Β· declared-visible(only for an opted-in arc), thenΒ· aliases a, b(only when it has aliases). When present, the aliases segment is always the LAST segment, so it can be read by splitting on the lastΒ·; - an alias is stored in the slug charset (
[a-z0-9.-]), folded exactly as a slug is: lower case, and every other character β/, a space,Β·, any non-ASCII letter β becomes-, runs collapse, ends are trimmed. SoHTTP/3is stored ashttp-3, an alias can never contain theΒ·separator, and one that folds to nothing is refused. Match aliases by folding the query the same way.
The opt-in is withdrawn by the next push without it. Every registration REPLACES the summary
and the opt-in β nothing carries them forward. A schema-2 push without visible_to_declared (or
with it false) makes an opted-in arc home-only again, and a schema-1 push clears both and is
stored as schema 1. β So a host whose tooling still sends schema 1 drops an arc's summary and
opt-in on its next push; upgrade every host that registers the same arc together.
What opting in exposes to a reader of a declared scope who cannot read the home β measured, not
inferred. On the API (arc-show, pinned by TestWhatAnOptInExposesToADeclaredScopeReader): the
home scope's name and the slug; the status and closing kind; registered_by (the
registrant's display name), registered_at and the tool's reported_at; the tooling coverage
counts (commits, unstamped commits, which legs were measured); every member's session id, role
and first-seen time, including carried members and their count; the declared scopes that
reader can read; and the whole summary. A listing row adds aliases and Β· declared-visible;
arcs --check counts the arc and can report one of its readable declared scopes absent. In the
browser (read from internal/ui/arcs.go's arcRow and internal/ui/arcsindex.go's
arcsIndexRow): the scope page lists the arc with its member session ids as chips, its
status, provenance, closing kind and registration time; the arcs page lists it with its status,
member count and last-updated time; a session page lists it (home, slug, status, role) for a
member session the reader can open. Not exposed: what members wrote in scopes that reader
cannot read (wrote in: names readable scopes only), a declared scope it cannot read, the home's
X-Store-Revision (identical to an absent arc's β pinned), and other people's live-pane badges β
the presence badge on the arcs page answers only for a session the VIEWER owns
(internal/presence/presence.go's owner predicate visible), so opting in shows nobody else's.
Who may opt in: whoever may register the arc at all β the write verb on the home and on every declared scope, unchanged. That is what makes the opt-in safe to honour: an arc is put in front of a scope's readers only by somebody who may write that scope. β The check runs at registration; a registrant whose write is revoked later leaves the arc visible there until the next push.
A summary never refuses the registration. The arc's schema-1 data β status, members, declared scopes, the opt-in β always lands; a problem in the summary is adjusted and named, never refused:
| field | bound | when it is exceeded or broken |
|---|---|---|
closing_condition |
1000 bytes | clipped |
state_now, next_steps |
50 items, each β€ 1500 bytes | an item over 1500 bytes is clipped; items past the 50th are dropped |
task_refs |
32 items, each β€ 200 bytes, no whitespace | clipped; past the 32nd dropped; one containing whitespace (after sanitizing) dropped |
aliases |
16 items as sent, each β€ 64 bytes as sent, folded to a non-empty slug | clipped before folding; past the 16th dropped; one folding to nothing dropped |
source |
required whenever summary is sent |
an uncitable source drops the summary (the arc still lands) |
source.repo_path |
β€ 256 bytes; /-separated segments of [A-Za-z0-9._-] only β so no leading or trailing /, no empty segment, no space or backslash β and no segment that is exactly . or .. |
summary dropped, reason=source.repo_path |
source.commit |
a full lower-case hex commit id, exactly 40 or 64 characters | summary dropped, reason=source.commit |
source.committed_at |
β€ 35 bytes (nanoseconds and a numeric offset fit) and RFC 3339 (Go's time.RFC3339 parse; fractional seconds accepted) |
summary dropped, reason=source.committed_at |
| the whole stored summary | 24 KiB, measured as its JSON encoding as stored (aliases folded, absent lists as []) |
items are dropped from the end of the lists β the longer of state_now/next_steps first (state_now on a tie), then task_refs, then aliases β until it fits |
β These bounds were raised from 20 items of 500 bytes and a 16 KiB total, which clipped about
one item in six of real handoff documents mid-sentence (the measurement is beside the constants in
internal/arcs/summary.go). What follows for an operator:
- A stored summary is not re-read by the new bounds. An arc registered under the old ones keeps its clipped or shortened summary until its tooling pushes it again; re-registering it (the same push that registered it) stores the whole text.
arc-shownow prints the summary before the member list, where it used to end the body. A consumer that located the summary by position (for example, everything after the last member line) must find it by itssummary:provenance line instead; a schema-1 arc's body is unchanged.- An older build SKIPS a record stored over its bounds β and with it that record's MEMBER LIST,
not just its summary. The read side re-checks the item COUNTS and the TOTAL (not an item's
bytes), so a build with the older bounds treats a record with more than 20 items in a list or over
16 KiB as damage and folds the arc to its previous record (or to nothing). Three readers see that:
arc-show,arcsand the browser show the previous record's status, summary and members.- Plugin consent in
cairn-uireads an arc's members to decide which sessions itsoffveto covers. A session that only the skipped record names falls out of the arc, so a plugin the arc vetoed can run on that session while thatcairn-uiis the older build. - The next push to an older
cairn-servermerges against the previous VISIBLE record. A push that does not measure a leg (readers_measured: falseorwriters_measured: false) carries that leg's members from the record it can see, so members that only the skipped record held are dropped from the new record. They stay gone after rolling forward, until a host that measures that leg pushes the arc again; re-registering from a host that does not measure it does not bring them back.
- So roll
cairn-uino later thancairn-server(or both together). On the way forward that avoids all of the above: no older reader is left by the time a record over the old bounds exists. - Rolling back past this version is the remaining exposure. Once any arc has a record with more than 20 items in a list or over 16 KiB, prefer rolling forward to rolling back. If you must roll back, then after rolling forward again have each host that registers the affected arcs re-push them β including any host that measures readers β and expect an arc whose members were dropped to stay short until that host does.
Every text field β closing_condition (when sent), every state_now, next_steps, task_refs
and aliases item β goes through four steps, in this order:
- sanitized to one line: an invalid UTF-8 sequence becomes U+FFFD, and every character Go's
unicode.IsControlreports (tab and newline included), U+2028 and U+2029 becomes a space β the answer is lines, and an embedded newline could print a line that reads as the pod's own; - dropped if nothing but whitespace is left;
- redacted: the text as sent is scanned for a confident secret β the same rule subset
memos refuse on β and on any hit stored as
[redacted: <rule>]([redacted: github-token]). The scan reads the item up to its cap plus 1 KiB, so a credential straddling the cut is seen whole. Nothing explains a hit away: a placeholder in a credential position is redacted by design βpostgresql://app:${PGPASSWORD}@db/app,postgresql://app:$PGPASSWORD@db/appandhttps://user:***@hostare stored as[redacted: url-userinfo-password]. A hit costs that one item's text and never the registration, which is why no placeholder logic is worth its leaks. Text no confident rule matches as sent is stored as written:$PATHin prose,***bold***,Authorization: Bearer <token>,x-api-key: <your-key>. β That includes a real value a rule's character class cannot see through: the bearer and authorization rules do not match across$,{,}or*, soBearer $X+ a value orx-api-key: ***+ a value is stored as written; - clipped to its byte cap at a UTF-8 rune boundary, ending in
β¦(U+2026, whose 3 bytes count within the cap).
repo_path, commit and committed_at are checked by the shape rules above, not the secret scan.
Empty lists are allowed, and a summary whose every item was dropped is kept, empty, with its source.
If the secret scanner cannot be built the summary is dropped (reason=secret-scan-unavailable) and
the next push tries again.
What still refuses, because it is not summary content: a payload that is not the schema β an
unknown key at any depth, or a wrong JSON type (state_now as a string, a non-boolean
visible_to_declared) β a schema-1 payload carrying summary or visible_to_declared (need schema 2), a credential that may not write the home or a declared scope, and a pod with no arc
journal. A refusal names the field, never the value.
The response names every adjustment β the contract a registering tool parses. After the usual outcome line, one line per adjustment, in the order made, and no extra line when no item was clipped, sanitized, redacted or dropped. β That is narrower than "stored as sent": aliases are always folded and deduplicated (documented above) without a line. (The lines below are drawn from several pushes, to show each shape.)
arc-registered: alpha-notes/gadget-move Β· members=1 Β· unjoinable=0
summary-adjusted: clipped summary.next_steps[0] bytes=1503 max=1500
summary-adjusted: redacted summary.next_steps[1] rule=authorization
summary-adjusted: sanitized summary.next_steps[7] replaced=1
summary-adjusted: dropped summary.next_steps[8] reason=blank
summary-adjusted: dropped summary.state_now[50] reason=over-count max=50
summary-adjusted: dropped summary.state_now[49] reason=over-total max=24576
summary-adjusted: dropped summary reason=source.commit
Each line is summary-adjusted: <action> <field> <detail>:
<action>is one ofclipped,redacted,sanitized,dropped.<field>is the field as sent:summary.<list>[<index in the payload>],summary.closing_condition, orsummaryfor the whole summary.<detail>is space-separatedkey=valuepairs:bytes=<n> max=<cap>forclipped, where<n>is the item's length as the pod received it (after JSON decoding β the JSON decoder itself turns an invalid UTF-8 byte into the 3-byte U+FFFD, so for invalid input this exceeds the raw byte count). When sanitizing then changed the length,sanitized_bytes=<m>follows: the length that was compared with the cap;rule=<rule>forredacted;replaced=<n>forsanitized;reason=<reason>(plusmax=<n>for the two caps) fordropped. The reasons areblank,whitespace,normalizes-to-nothing,redacted(an alias, after itsredactedline β a redacted alias is dropped rather than stored as a junk name),over-count,over-total,source.repo_path,source.commit,source.committed_atandsecret-scan-unavailable.
An item can carry more than one line (sanitized, then clipped). The response never echoes a
changed value. cairn arc-register prints this body verbatim on stdout, exit 0.
Compatibility β what was measured against the previous release (old = a build without schema 2):
| direction | what happens |
|---|---|
| an old client β a new pod | unchanged. Schema-1 registrations are accepted, and both read verbs print the pod's body verbatim, so an old client shows a summary and the opt-in lines as a new one does. Every arc answer about schema-1 arcs is byte-identical to the old pod's, headers included |
| a new client β an old pod | the old pod refuses any schema-2 payload with a 400 (bad request: body must be one JSON object of the registration schema (json: unknown field "β¦"), or bad request: schema must be 1 for a schema-2 payload using neither field). arc-register reports that refusal as a refusal: the pod's line on stderr, exit 6, nothing registered. It does not downgrade and re-send β falling back to schema 1 is the registering tool's decision, made with the refusal in hand |
| a journal written by a new pod, read by an old one | a record that uses neither new field is written as schema 1, byte for byte, so it still reads. A record that uses one is skipped by the old pod and counted as damage (β the registration journal holds record(s) that could not be read, and an operator-log line); the arc shows its last schema-1 state, or is not shown at all if it never had one. Nothing is deleted: rolling forward again shows it |
β So roll every binary that reads the arc journal before any tooling sends schema 2 β
cairn-server and cairn-ui both do.
β Not on the browser's arc page yet. The arc page prints no summary, and it addresses an arc by
its home scope's id among the scopes you can read, so an opted-in arc whose home you cannot read is
listed on the arcs and scope pages without a link to it. cairn arc-show and
GET /api/v1/arc/<home>/<slug> serve both.
A memo is a short notice an agent (or a person, from the scope page's memos tab) sends to a
scope. Every agent session working on that scope sees each memo it has not seen once, at
the next memo-check β so one session can tell the sessions that come after it "the build is
red, don't merge" without anyone writing it into an entry.
cairn memo-send --scope alpha-notes --subject 'deploy frozen' --body 'until the migration lands' --ttl 2d
cairn memo-check --scope alpha-notes --session "$AGENT_SESSION_ID" # unseen memos, once; built for a session-start hook
cairn memo-read --scope alpha-notes [--id N] # one memo, or every live one with full bodies
cairn memo-retract --scope alpha-notes --id N # the sender, or a scope admin- Where memos live. Not in the store and not on the pod: in
cairn-ui's database, served by its client listener. A host therefore needsCAIRN_UI_URL(the listener's base URL,cairn-ui -client-api-addr) andCAIRN_UI_TOKEN(the agent's own control-journal credential) in the same per-instance env file asCAIRN_URL/CAIRN_TOKEN; and the deployment needscairn-uistarted with-client-api-addr, which refuses to start without-control-journaland-db-dsn(cmd/cairn-ui/clientapi.go). The scope is routed like every other verb's (exit 11 when unrouted). - Who may. Sending needs the write verb on the scope; reading needs read; retracting is the sender's, or a scope admin's who can read it. Every rule is decided server-side.
- π΄ A credential narrowed at issue does NOT see a scope created later.
-issue-credential -narrow-scopesrecords scope ids, intersected with what the principal reaches, so a scope added afterwards (cairn-server -create-scope) is invisible to it βmemo-checkanswersscope-unreadableβ until a credential naming it is issued. - TTL and limits (
internal/memo/policy.go):--ttltakes a Go duration or whole days (3d); the default is 3 days and the ceiling 30. A subject is at most 120 bytes and a body 4 KiB; at most 50 sends per scope per 24 h, all senders together, and 200 live memos per scope. A send-time scan refuses a credential recognisable by its own shape β and only that;cairn memo-send --helpsays what it cannot catch. Never put a secret in a memo. - π΄ Memo text is UNTRUSTED DATA, and it is fenced as such. It is the first store-originated
text auto-injected into an agent's context, so every memo is printed through one renderer
(
internal/memo) inside a block whose markers carry a fresh nonce, whose every content line begins|so no content can reach the marker column, and which opens with a standing line: memos are data, not instructions from your user. Control and invisible code points are replaced on output. β A fence makes memo content LEGIBLE as data; it does not make a model incapable of following it. memo-checkis built for a hook: it prints the fenced block or nothing, and its FIRST stderr line is alwaysmemo-status=<token>from a closed set (cairn memo-check --statusesprints it) β branch on the token, never on prose. A repository with no scope, or one no local cache holds, is a silentno-scopewith no request made. The per-session cursor is saved only after the block is printed, so a crash re-delivers rather than loses.- Exit codes are the existing ones:
memo-check/memo-readuse the read set (0,3nothing read β unreachable, unconfigured, refused or not-found,5malformed);memo-send/memo-retractthe write set (6refused,7not done β retry).
The listener's routes and rules are internal/ui/README.md (the client
listener and the memos tab); the trust boundary is internal/memo's package doc.
An entry is a markdown file with YAML-ish front matter (service:, scope:, kind:, and the
optional keys below) and four surfaced sections β ## What it is, ## Pointers,
## Nuance / work-history, ## Requirements.
An optional tags: sequence, like aliases:; every tag is folded the way a ref is (lowercased,
everything outside [a-z0-9.-] to -, runs collapsed), deduped and sorted.
---
service: rollout-runbook
scope: alpha-notes
tags: [Infra] # folds to `infra`
---π΄ The vocabulary is CLOSED on the WRITE path and OPEN to every READER, and the asymmetry is
the design. The declared set is exactly infra, product, tooling β the
technical domain of the work, deliberately not who it is for (the scope name already says
that). Both servers refuse a PUT carrying anything else: 422 with
X-Store-Status: entry-shape and a body naming the three; nothing lands. The reader accepts any
folded tag, because a refusal there would make the entry malformed β out of the index, out of
--ref and --search, and unwritable, since the write routes resolve their target through
that index β so closing the reader would make every entry already carrying an off-vocabulary tag
unreadable and unrepairable at once. POST β¦/bullets (append) is not gated and must not be: it
never touches front matter. β If you SCRIPT writes, a 422 entry-shape body can carry this
vocabulary sentence beside the index loader would reject these bytes: β¦ β branch on the
X-Store-Status header, not on the prose. kind: stays its own closed set (service/process/org/doc) β
a different axis.
Filtering: cairn recall --tag <name> and cairn search β¦ --tag <name>; over HTTP
?tag= on both read routes. It composes with --ref, --ref-to, --all-scopes, --mode and
--page.
- π΄ It takes ONE tag, last-wins like every other flag and parameter here:
--tag a --tag band?tag=a&tag=bboth meanb. A repeatable AND form was built and dropped because nobody had named the semantics; ask with the semantics you want. - An operand that folds to nothing is a 400 (exit 2), not an empty result β a tag that folds away names no category.
tag-absentis a status, answered 200 at exit 0: nothing carries the tag, or--refnamed an entry that does not. The renderedtag:line carriesN of M entries β¦ carry it, so a filtered index cannot be mistaken for a whole one.- The query folds like the file does, so
--tag Infrafindstags: [infra]. - The recall report gains a
tags: a, bline under an entry that carries any, and atag: β¦header line only when the filter was sent.
On the browse pages an entry's tags link to /scopes?tag=<name>, which lists every visible
entry carrying it across scopes and reports how many entries it looked at. ?q= and ?tag=
compose there into ONE card β a search over the tagged entries only, through the same
report.Search call the pod's ?q=β¦&tag=β¦ makes.
Changing the vocabulary is a six-file change: Β§ Changing the tag vocabulary.
The front-matter key is refs: β repos, PRs, docs, dashboards, work items, as
<system>:<id>. tasks: and task: stay accepted spellings that warn about nothing; where
both appear, refs: is read.
- Reverse lookup:
cairn recall --ref-to <system>:<id>andcairn search β¦ --ref-to β¦; over HTTP?ref-to=. Spelledref-tobecause?ref=already addresses one entry. A malformed operand is a 400. β#must be percent-encoded as%23in a URL. ref-to-absentis a status, answered 200, reached the same two waystag-absentis.- A ref becomes a link with no network call.
github:andclickup:resolve from built-in public hosts; any other system throughCAIRN_REF_BASE_<SYSTEM>(the system upper-cased,-to_), sorefs: [tracker:1234]links only whenCAIRN_REF_BASE_TRACKER=https://tracker.example.invalid/tis set, givingβ¦/t/1234. π΄ It is read by whatever process RENDERS β for the browse pages that iscairn-ui, not your shell β and a base whose scheme is nothttp(s)has its link withheld while the ref still renders as text (internal/store/refurl.go). - A printed body carries
refs: β¦and an index rowπ N ref(s). The--jsonpayload's key is still"tasks", deliberately: renaming a machine-read key breaks a presence check silently.
An index row ends in badges. An entry with more than 30 ## Nuance / work-history bullets
also gets β OVER 30 nuance β prune or split, appended after π N refs:
example-entry 151 nuance internal π΄ 3 OPEN β OVER 30 nuance β prune or split
A digest prints the index plus one featured body in full, so one oversized entry sets the
cost of the whole read; the badge names the bar. It is advisory β nothing refuses, no exit code
moved β and an entry at or under the ceiling renders exactly as before. The threshold is one
constant per renderer (NuanceBulletCeiling / NUANCE_BULLET_CEILING), pinned equal by
tests/test_nuance_bullet_ceiling.py. Where 30 comes from β the measured cost curve and firing
rate β is NuanceBulletCeiling's doc comment in internal/report/text.go.
Inside a printed body, each line that opens a top-level bullet ends in [cb:xxxxxxxx] β
8 lowercase hex β in all four surfaced sections:
## Pointers
- apps/example/values.yaml β the chart values [cb:710f5a96]
## Nuance / work-history
- 2000-01-02: OPEN: the retry budget is still unbounded. [cb:3cc70d82]
a continuation line, which belongs to the bullet above it.
Both ids are the real values for those bytes. The id is the first 8 hex of sha256 over the
bullet's own lines joined with \n β so the second bullet's continuation line is part of it. It
is not scoped (identical bullets anywhere share an id) and it moves with any byte of the
bullet, the write trailer included; line terminators are normalised. It exists so "was this
recalled bullet used?" can be a match rather than a guess β the why, and how that rationale decays,
are CitationID's doc comment in internal/store/journal.go. The measured cost (about 1β3% of a
read's bytes; --list is unchanged) has no home beside the code, so it stays the
CHANGELOG.md row for #175.
β If you PARSE a printed body:
- Not every bullet-ish line carries an id. Prose, blanks, an indented dash (a continuation), a dash inside a code fence and text before a section's first bullet print in full with no token β a missing id is never evidence that a bullet was not printed.
- Echoing a recalled bullet VERBATIM into a write is safe: both pods strip a TRAILING token
before hashing, so it still answers
duplicate. The token counts against the 2,000-character cap. - π΄ A token quoted INSIDE new prose is PERMANENT, and a line can then carry two:
So take the LAST
READ 1 - 2000-01-02: the retry budget is still unbounded. [cb:7a54575e] SUBMIT building on [cb:7a54575e], the budget is now capped at 5 retries. READ 2 - 2000-01-05: building on [cb:7a54575e], β¦ [cairn: β¦] [cb:58ff27d9][cb:β¦]on a line, never the first β and never read a token's PRESENCE as evidence that the line opens a bullet: a quoted token can end any of the unannotated shapes above, where even a last-match read attributes a different bullet's id (measured: one misattributed line per unannotated shape). π΄ The real advice is not to scrape at all:CitationIDsByStartLine/citation_ids_by_start_lineanswerNonefor every unannotated shape by construction. - And "a token exists nowhere else in a corpus" β the feature's founding rationale β stops holding after the first such quotation, by an amount that grows with use; nothing bounds it.
Three pieces, each opt-in and each documented beside its code; this section only says what they are and where to read.
- Capture β
cairn-capture. A host-side agent run from a user timer (--run): it reads Claude Code and opencode session transcripts, redacts them (internal/redact), routes each session to the instances whose scopes it touched, and uploads it to that instance'scairn-uiworker listener.--dry-runwrites nothing;--self-testscores the redactor on a synthetic corpus. π΄ Capture is UNARMED until the operator startscairn-ui -arm-transcript-capture, and a green self-test is not a reason to arm it.internal/capture/README.md. - Reading β
cairn-uiandcairn transcript. A stored session is visible to its owner and to a viewer who reads EVERY scope in its visibility set β the scopes it wrote to or read, a set that only grows; an unknown scope makes it owner-only (transcript.Visible). The session page shows it collapsed, and "My sessions" lists your own. Agents read the same answers withcairn transcript skeleton|records|tool --session ID, through the client listener andCAIRN_UI_TOKENlike the memo verbs; a served read makes the reading session owner-only.internal/transcript/README.md. - Plugins. Out-of-process programs that speak plain HTTP+JSON to
cairn-ui's worker listener with aplugintoken, read transcripts through per-scope, per-arc and per-session toggles, and write back derived records the browser renders as labelled claims. Nothing in this repository runs a plugin or holds its credential. The API's decisions:internal/plugins/README.md; the two example plugins (session summaries, ticket matching), a nested stdlib-only module:plugins/README.md.
Both clients also write a read ledger when they run inside an agent session β one JSON line
per scope a served call touched, under $XDG_STATE_HOME/cairn/read-ledger/ β which is what a
transcript's visibility is folded from. Every write error there is swallowed; it cannot change a
read's output or exit code.
| route | what |
|---|---|
GET /healthz |
unauthenticated; body is exactly ok\n |
GET /api/v1/recall/{scope} |
rendered digest (?mode=&ref=&limit=&page=&tag=&ref-to=) |
GET /api/v1/search/{scope}?q=β¦ |
search (?threshold=&max_hits=&context=&all_scopes=&tag=&ref-to=) |
GET /api/v1/snapshot[?scope=] |
gzipped tar of the entry files β the sync payload, with an ETag |
GET /api/v1/sessions/{scope} |
which sessions wrote attributed bullets, with coverage β Go pod only |
GET /api/v1/arcs/{scope} |
registered arcs that touched the scope; ?check=1 (&all_scopes=1) is the orphan check, X-Store-Exit on doctor's 0/9/10 β Go pod only |
GET /api/v1/arc/{home}/{slug} |
one registered arc β Go pod only; arc-unregistered when it is not visible to you. Opt-in read views (?view=outline, ?view=page§ion=&from=&limit=&max_bytes=&at=<token>); a request naming a view gets X-Arc-View β Β§ Reading a large arc |
PUT /api/v1/arc/{home}/{slug} |
register or update one arc (write verb on every declared scope; 409 registrations-unconfigured with no -arc-journal) β Go pod only |
GET /api/v1/sources/{scope} |
the scope's declared code sources, read from $CAIRN_SOURCE_JOURNAL β an environment variable with no flag, a file OUTSIDE the store root, read-only to the pod; sources-unconfigured when unset β Go pod only |
GET /api/v1/whoami |
the caller's OWN authority, as JSON (X-Store-Status: whoami): {"principal", "status", "groups": {"mapped", "unmapped"}, "scopes"}. status is mapped / groups-unmapped / no-groups for a group-federated caller and not-federated for any other; groups partitions only the groups the caller's OWN token claimed, scopes is the scope names it may read, every list sorted and never null. A self-view, never an enumeration of the group map; an unauthenticated caller gets the uniform 401 β Go pod only, and a pod without it answers 404 no-route |
POST /api/v1/entry/{scope}/{ref}/bullets |
append ONE attributed bullet (the actor comes from the token, never the body) |
PUT /api/v1/entry/{scope}/{ref} |
whole-file replace via If-Match, or create via If-None-Match: * |
Every /api/v1 GET also answers HEAD. cairn-server -routes prints the ledger the dispatch tables are
built from, and it β not this table β is what the conformance suite checks.
Auth is a token set β one row per line, <token> for legacy or
<token> <identity> <scope>,<scope> for a mapped row β rotated by overlap and
hot-reloadable with SIGHUP, so onboarding an agent does not restart the pod. The
control journal β an append-only file of users, projects, memberships, grants and issued
credentials β is the browser surface's authority, and the pod's too when it is given a session
backend (server/README.md Β§ The control journal). The full contract, plus the
operating runbook (seeding, byte-identity verification, rotation, rate limiting), is
server/README.md.
cairn-server is also the control journal's operator tool. Each writing mode below appends to the
file $CAIRN_CONTROL_JOURNAL names and exits; cairn-server -h is the authority on their flags.
This is the ONE list of the modes; server/README.md links here rather than keeping a second.
| mode | what it does | documented in |
|---|---|---|
-create-user |
a user, a new project they own, and that project's scopes | server/README.md Β§ The control journal; internal/control/README.md Β§ The user-creation path |
-set-member |
put a user in a project at a role, or change their role | internal/control/member_set.go (SetMember, refuseOrphaning) |
-rename-user |
set a user's operator-written display name | server/README.md Β§ The control journal; internal/control/user_display.go |
-create-scope |
add ONE scope to an existing project | server/README.md Β§ The control journal; internal/control/README.md Β§ Adding a scope to an existing project |
-remove-member |
take ONE user out of ONE project β revokes nothing, and lists what still reaches | server/README.md Β§ The control journal; internal/control/README.md Β§ Taking a user out of a project |
-map-group / -unmap-group |
map ONE group an issuer asserts to ONE scope or project (or remove that mapping) β read only by group federation, an opt-in mode of the API pod | internal/identity/README.md Β§ Group federation; server/README.md Β§ The control journal |
-issue-credential |
mint a bearer token for a principal, optionally narrowed by scope id and verb; emitted once | internal/control/README.md Β§ The credential-issuing path and Β§ Verb narrowing |
-revoke-credential |
revoke one issued credential | internal/control/README.md Β§ The credential-issuing path |
-routes |
print the route ledger (writes nothing) | internal/api (DeclaredRoutes) |
π΄ Run each where $CAIRN_CONTROL_JOURNAL names the file the running surfaces read. Where
the store pod mounts the journal, that is kubectl exec into the store pod; where only
cairn-ui mounts it, kubectl exec reaches no journal β the store pod has none and the UI image
has no cairn-server β and the answer is a one-off pod on the store image mounting the UI's
volume: server/README.md Β§ Running an operator mode against a deployed journal.
β A new credential is honoured only after the listener's ~30 s journal refresh, and an early
try counts toward the per-client lockout. The model the modes write β users, projects, roles,
grants, narrowing β is internal/control/README.md.
It carries the hub and scope pages (browse, search, tags), a browser sign-in with server-side
revocable sessions, GitHub sign-in through the operator's GoTrue, the share flow, team
links, the memos tab, the sessions and arcs pages, and transcripts and plugin outputs. Every
decision behind it, with its RED proof and what it still cannot see, is
internal/ui/README.md.
nix build github:ZacxDev/cairn#cairn-ui
./result/bin/cairn-ui -store <store root> -token-file <token file> -port 8103- β Route and flag counts are deliberately not written here β a number in prose beside a
ledger the program derives only goes stale.
cairn-uiprints its route count at startup,ui.DeclaredRoutes()is the list, andcairn-ui -hlists the flags (single-dash, Go's stdlibflag; every default is env-resolved, so what-hprints depends on your environment). It reads the store from disk rather than over HTTP, and authenticates against the same token file as the pod. The browser surface has no route that creates, replaces or appends to an entry. - β A credential is required, and off-cluster you must say where it is.
-token-filedefaults to the pod's secret mount (/run/secrets/subsystem-store/token), so on a machine without one the binary exits 78. Supply-token-file <path>,CAIRN_TOKEN_FILE=<path>, or-token-file=plusCAIRN_TOKEN=<row>. - π΄ It is a SINGLE-REPLICA surface, for two reasons. Sessions are per replica unless
-db-dsnmoves them, and the control-plane cache is per replica regardless: a share recorded on replica A is not served by B until B's cache refreshes, which is what the replica-honesty notice on every share page says. Multi-replica is a later arc, not a configuration. - π΄ The share flow needs
-control-journal <path>, and not only to write. Without one the authority is the token file, which confersadminon nobody β no scope is administrable and a scope's share page answers 404 to every caller; the page says so on every load. ACAIRN_UI_CONTROL_JOURNALthat is only whitespace is refused at 78 rather than read as unset (the-control-journalflag meets astatinstead, which is the lenient side β measured, and left open on purpose; the agreement test's docstring says which values it covers). - π΄
-db-dsnmoves the session table as well as arming invitations, team links and memos, and the first start with one signs every open browser out once. It is a PostgreSQL connection string, connected, pinged and migrated at startup under a 10 s bound; a failure is exit 78. PreferCAIRN_UI_DB_DSNto the flag β a flag value is inargv. - "Who has access to this" is computed from the authority, never from the list of shares.
Access arrives two ways β a share, or membership of the project that owns the scope β so a
page listing only shares would under-report every project member. Only shares are revocable,
and the page says who keeps access through membership. You can share only with people and
projects you already share a project with; anybody else needs a team link (
GET /team, opened at a publicGET /join), which exists only with-db-dsn. β A team link cannot conferownerβcairn-server -set-memberis the path left β and it is listed to and revocable by only whoever created it. - Every share page carries a notice β one replica's answer from a cached authority; revoking stops future syncs without recalling entries already copied β pinned whole by a test so it cannot be reworded into a stronger promise.
- Backends. The cookie session is tried after every header-borne credential, so a bearer
token beats a stale cookie. π΄ The trusted-header backend is refused on principle: a
browser surface exists to be publicly reachable, and that backend lets anyone who can open a
socket to it be any user.
AuthBackendshas no parameter for it, andTestTheUIChainHasNoTrustedHeaderMemberpins the exclusion. - It is the only binary here that links a third-party module (
gomponents, for HTML); the serving path is still stdlib-only, and what measures that isinternal/depspolicy.
charts/cairn deploys the store pod and cairn-ui and refuses, at helm template time, the
manifest mistakes that have already cost real debugging: no existing claim, a mutable image tag,
more than one replica of either, an empty or too-wide trustedProxies. Five values are required
(storage.existingClaim, tokenFile.secretName, trustedProxies, image.store, image.ui).
β It configures no control journal and no database, so the share flow, memos and
transcripts are off in what it renders; it creates no PVC; it is unreleased, and CI does not run
its test.sh. charts/cairn/README.md.
| path | what |
|---|---|
cairn |
the Python client CLI, and the ORACLE the Go one is measured against |
lib/ |
the Python reader: cache resolution, recall rendering, scope/ref resolution, doctor |
server/ |
the ORACLE pod: server.py, Dockerfile, seed.sh, verify-byte-identity.sh β no longer what runs |
cmd/cairn-server, internal/api |
the Go port of the server β passes the corpus, and the deployed pod |
cmd/cairn, internal/client |
the Go port of the CLIENT, and the default β diffed against the Python one by tests/parity/ |
internal/report, internal/store |
the ONE renderer and the store loader, shared by the pod and the CLI |
internal/control, internal/identity |
the control plane's model and authz predicate; the one Authenticator |
cmd/cairn-ui, internal/ui |
the BROWSER surface β published, and DEPLOYED |
internal/memo, internal/clientapi |
scope memos' trust boundary and cairn-ui's client listener |
cmd/cairn-capture, internal/{capture,transcript,worker,redact,plugins} |
transcripts: capture, redaction, storage, the worker listener, the plugin API |
plugins/ |
the example plugins β a nested, stdlib-only module |
charts/cairn |
the Helm chart |
internal/depspolicy |
the allowlist and import ban that keep the serving path stdlib-only |
tests/ |
the suites, plus leakscan.py, the HTTP conformance corpus, the server dual-run gate and the client parity gate |
flake.nix |
both clients (default is the Go one, #cairn the Python one), the pod images, the UI, and the checks |
server/server.py is the oracle; cmd/cairn-server is a stdlib-only Go port of
it, and it is what the cluster runs. Rewriting the server alone would have left two
renderers in two languages that must agree byte-for-byte forever, with drift arriving as "a
different order that reads as a stale cache" β no error, no missing entry. So both land on
the same internal/report: one renderer, two consumers β the pod and the CLI. β The
browser surface is not one of them. internal/ui reads internal/store and renders HTML
through its own code, so the page is a second renderer producing a different medium, and
nothing compares the two. β And one-renderer becomes a property only when the Python
renderer is deleted at P8; until then it is a discipline, and these three instruments
enforce it:
| instrument | what it compares | read it |
|---|---|---|
tests/conformance/ |
the served HTTP contract, as golden fixtures generated from the oracle, replayed against either server | the corpus and its declared blind spots |
tests/dualrun/ |
both servers over one store β every route, scope, entry and principal, and the snapshot's uncompressed tar byte for byte | why gzip identity is unattainable, and the PAX-header divergences a tree comparison cannot see |
tests/parity/ |
both clients, one pod, one cache root, identical argv, across every verb they share | the declared residuals, the P8 retirement ledger, and which rows compare only the exit code |
β Counts are deliberately not quoted here. Every one of them went stale in this
file at least once while the gates moved; the numbers live beside the gates that produce
them, and the CI floors in .github/workflows/ci.yml.
β And a green gate is not evidence until its controls have been watched to work. The
parity gate's first full run reported 72 PASS / 0 FAIL while every request was refused β
two clients failing identically compare equal. Both harnesses now refuse to vouch (exit
2, which is not "failed") unless a pre-flight succeeds, and both ship a --self-test
that proves the differ can go red.
pytest tests/ # the Python suite
go vet ./... && go test ./... # the Go port's own guards
tests/conformance/run_go.sh # the corpus against the Go server
python3 tests/conformance/suite.py run # β¦and against the oracle: must stay at 0 failures
python3 tests/parity/harness.py # both CLIENTS over one cache root: the P2 gate
python3 tests/parity/harness.py --self-test # prove the differ can go RED
python3 tests/reader_fixtures.py generate # re-record the renderer's bytes FROM the oracle
python3 tests/leakscan.py # the leak gate β runs in CI on every commit
python3 tests/leakscan.py --self-test # prove the gate is an instrumentThis repository is public and was extracted from a private one: never commit
hostnames, private IPs, real project/scope names, dated incident narration, or
captured text β fixtures are synthetic. tests/leakscan.py runs in CI on every
commit. The full rules agents work under live in AGENTS.md.
Several sessions and agents routinely work this repository at once, through
linked worktrees of one clone, so changes are made in a worktree and never in
the base clone β a commit onto the branch a peer left checked out is the silent
failure, because git log afterwards shows exactly what you expect.
claudedocs/working-in-parallel.md is the
recipe and the reason behind each rule; .claude/hooks/base-clone-write-guard.py
refuses the write rather than relying on anyone having read it. That hook fires
only when the clone actually has linked worktrees, so a fresh clone never sees
it, and it fails open by design β it is a guard against a routine mistake, not a
security boundary.
Widening or narrowing the set is a change in SIX files β two declarations and four expectations β and the count is measured rather than counted by eye, by adding a fourth term to both declarations and reading what went red:
| # | file | what is in it | which gate reds |
|---|---|---|---|
| 1 | internal/write/tagvocab.go |
tagVocabulary β the deployed Go pod's declaration |
β (this is the change) |
| 2 | lib/entry_shape.py |
TAG_VOCABULARY β the Python oracle's |
β (this is the change) |
| 3 | internal/write/tagvocab_test.go |
two literals: the refusal sentence and the term list | go test ./internal/write/ |
| 4 | internal/api/tagvocab_test.go |
the 422 body as served, including its unprocessable: prefix |
go test ./internal/api/ |
| 5 | tests/test_tag_vocabulary.py |
two literals: DECLARED and DECLARED_REFUSAL |
pytest |
| 6 | tests/conformance/golden/put-*-tag-*-the-vocabulary.json |
the recorded refusal bytes β regenerate with python3 tests/conformance/suite.py generate, never hand-edit |
tests/conformance/run_go.sh, python3 tests/conformance/suite.py run, and pytest tests/test_conformance_suite.py |
Every one is a literal on purpose β a test deriving its expectation from the declaration would
assert x == x and stay green through exactly this edit. β Two more files carry the terms as
PROSE, which no gate covers: this README's Β§ Tags and CHANGELOG.md's #160 row.
Edit them in the same commit. β An earlier draft of this count said "four places" β wrong in the
direction that makes the change look cheap, the same mistake lib/entry_shape.py records about
its own sentence. Re-derive rather than trust: add a term to both declarations, then run
go test ./... ; python3 -m pytest tests -q.
The project is cairn. Identifiers that are not environment variables still read
subsystem_store β the lib/ module names, ~/.config/subsystem-store/,
/run/secrets/subsystem-store/, the subsystem-recall CLI alias. Those are accepted
aliases, kept so existing deployments don't need a coordinated cutover. The
environment variables have been renamed; see Β§ The environment variables are now
CAIRN_*.
Every migration notice lives here, in one place, because a consumer who pinned an older
revision is the reader who needs them and a reader setting up fresh does not. Each says what can
break and what to do; CHANGELOG.md is the index β one row per contract change,
anchored to a PR and a sha, so git log <your-rev>..<target-rev> tells you which apply.
packages.default and apps.default were the Python client; since
#50 they are the Go one, so
nix run github:ZacxDev/cairn and nix profile install github:ZacxDev/cairn execute
cmd/cairn.
Two behaviours change: cairn -verbs and cairn -exit-codes now answer β exit 0 with
a table on stdout, where the Python client exits 2 with argparse's usage: β a single-dash
token succeeding where the default previously refused; and argparse's exact wording is
gone from --help and usage errors, so anything parsing that prose needs re-reading. The
exit codes themselves are identical and gated. β Whether -verbs/-exit-codes become
documented public surface or move behind a gate is a P8 decision that has not been taken.
Nothing was deleted, and cairn is the opt-out β in BOTH consumption modes. From the CLI it
is a #fragment (nix run github:ZacxDev/cairn#cairn -- doctor); a consumer that pins this
flake as an input reaches an attribute instead:
{
inputs.cairn.url = "github:ZacxDev/cairn"; # or pin a rev/ref
outputs = { self, nixpkgs, cairn, ... }:
let system = "x86_64-linux"; in {
# `cairn.packages.${system}.default` is now the GO client.
# Take the PYTHON client by name instead:
packages.${system}.my-cairn = cairn.packages.${system}.cairn;
# β¦and its runnable form, if you re-export an app:
apps.${system}.my-cairn = cairn.apps.${system}.cairn;
};
}Both spellings are gated: checks.default-is-the-go-client pins the flip and both opt-outs at
evaluation time, and its description beside the code in flake.nix is the one worth trusting.
Why the flip was taken on an operator decision rather than on a green gate is
tests/parity/README.md residual 7, and nowhere else on purpose.
Every SUBSYSTEM_STORE_* variable has a CAIRN_* name, and the pod-side JWT verifier's
CAIRN_SUPABASE_* settings are CAIRN_OIDC_*. Both spellings work. Within one source the
new name wins: the old one is read only when the new one is unset or blank there. An old name
that is present β including when the new one shadows it β prints one line per process on
stderr naming its replacement. The window closes when packages.cairn is retired (P8) β a
milestone, not a date: flake.nix sets version = self.shortRev, so there is no semver to hang
one on.
| set this | instead of | what it is |
|---|---|---|
CAIRN_URL |
SUBSYSTEM_STORE_URL |
client: the pod's base URL |
CAIRN_TOKEN |
SUBSYSTEM_STORE_TOKEN |
client: the bearer token Β· pod: the token-SET fallback when no token file is readable |
CAIRN_CONFIG |
SUBSYSTEM_STORE_CONFIG |
client: where the config file lives |
CAIRN_TOKEN_FILE |
SUBSYSTEM_STORE_TOKEN_FILE |
pod and cairn-ui: the token file |
CAIRN_STORE_ROOT |
SUBSYSTEM_STORE_ROOT |
pod and cairn-ui: the store root |
CAIRN_LISTEN_HOST |
SUBSYSTEM_STORE_HOST |
pod: the listen address |
CAIRN_PORT |
SUBSYSTEM_STORE_PORT |
pod: the listen port |
CAIRN_TRUSTED_PROXIES |
SUBSYSTEM_STORE_TRUSTED_PROXIES |
pod: the peer allowlist that makes CF-Connecting-IP readable |
CAIRN_MAX_FAILURES |
SUBSYSTEM_STORE_MAX_FAILURES |
pod: failed auths before a lockout |
CAIRN_FAILURE_WINDOW_S |
SUBSYSTEM_STORE_FAILURE_WINDOW_S |
pod: the window they must fall inside |
CAIRN_LOCKOUT_S |
SUBSYSTEM_STORE_LOCKOUT_S |
pod: the lockout duration |
CAIRN_OIDC_JWKS_URL, _ISSUER, _AUDIENCE, _PROVIDER, _REQUIRE_ROLE, _LEEWAY, _MAX_AGE |
the same suffixes on CAIRN_SUPABASE_ |
pod: the JWT verifier β any OIDC provider; internal/identity/README.md Β§ Wiring any OIDC provider |
β Two rows are not the mechanical prefix swap, and copying the pattern instead of the table
will break a pod. CAIRN_HOST was already taken β it is the human-readable machine label
in rendered output β so the listen address is CAIRN_LISTEN_HOST; and the pod's store root is
CAIRN_STORE_ROOT, because CAIRN_ROOT would read as a sibling of the client-side
CAIRN_MIRROR_ROOT. Read the ledger, never infer a pair: internal/envalias's package doc.
β Deliberately NOT renamed: CAIRN_SUPABASE_REDIRECT_URL (it configures cairn-ui's GoTrue
PKCE sign-in and is honestly named), and the retired CAIRN_SUPABASE_JWT_SECRET/_FILE,
which are a startup refusal, not an alias. The table is the RENAME ledger, not every variable
this project reads β names with no older spelling (CAIRN_UI_*, CAIRN_REF_BASE_<SYSTEM>, β¦)
are not in it.
- The config file's KEYS count too.
~/.config/subsystem-store/envandinstances/<alias>.envaccept either spelling with the same precedence; a deprecated key there warns naming the file. π΄ But an old name EXPORTED beats a new name in the FILE β Β§ Configuring a host β so migrate the export in the same change. - π΄ The pod images set NO store variable, in either spelling. They used to bake
SUBSYSTEM_STORE_ROOT=/data,SUBSYSTEM_STORE_PORT=8102andSUBSYSTEM_STORE_TOKEN_FILE=/run/secrets/subsystem-store/tokenβ values identical to the code defaults, which configured nothing and tripped the deprecation sweep at every start. So: either spelling works in a Deployment and neither is shadowed; setting neither resolves those same three values; and migrating toCAIRN_*now silences the warnings. βdocker inspectno longer shows them: the startup linelistening on <host>:<port> store=<root> token-ids=β¦replaces the port and store root, and the token PATH appears only incairn-server -h's default.tests/test_flake_image_matches_dockerfile.pypins the three against both implementations' code defaults. - β One
CAIRN_OIDC_*behaviour differs: a whitespace-only new value beside a real old one is refused at 78 rather than falling through (the CHANGELOG row for #176).
Before #146 the loaders named no tags: at all, so
any value there loaded and was discarded. It is read now, and the parser refuses some shapes a
store may already hold β measured on both implementations over real files, agreeing sentence for
sentence:
| what you may already have written | what it does now |
|---|---|
tags: marketing β a bare scalar |
REFUSED: `tags:` must be a list, not a bare string β write `tags: [<name>]` |
tags: ["!!!"] β a flow item that folds to nothing |
REFUSED: tag '!!!' normalizes to the empty string |
tags: [" "] β a blank flow item |
REFUSED, same sentence: whitespace folds away too |
tags: {a: b} or tags: {} β inline braces |
REFUSED by the first row's sentence: front matter holds only strings and lists of strings, so {a: b} parses as a bare scalar |
tags: with an indented a: b under it β a block mapping |
NOT refused: it parses as the empty string, an ABSENT key |
tags: with a blank - item β the block list |
NOT refused: the scanner drops an empty item, so a blank - then - alpha loads carrying alpha |
The fourth refusal, `tags:` must be a list, got <type>, is unreachable from a file; it
guards the programmatic loader (from_mapping / EntryFromMapping), which is also the writer's
validate pass. A refused entry is MALFORMED: it leaves the index β named in the report's
malformed block rather than vanishing β and becomes unwritable (append/put answer 404
ref-unknown), while /snapshot still ships its bytes. The check is
grep -rl '^tags:' <your cache root>; the remedy is to make the value a list. How likely that is,
measured and not a guarantee: across three stores on one machine β two live client caches and
one frozen pre-cutover mirror β 0 of 591 entries carried a tags: or tag: key at #146, with a
positive control in the same run (the same reader found 5 front-matter keys on a sample entry).
That says nothing about your store. And since
#160 a PUT that re-sends an entry carrying an
off-vocabulary tag is refused (422) β edit the tag in the same body; append is unaffected.
Every other contract change β a new verb, route, status value, badge, or a changed answer on
the browse pages β is a row in CHANGELOG.md, with what you may have to do.