Introduction
gonk is one binary that holds a durable RDF store and serves a work ledger from it: items, comments, labels, links, claims, and a ranked answer to what should I do next. Around that it can serve a repository browser, explanations of what it browses from a model it mounts, a review queue that a person drains, and three doors to all of it: HTTP on loopback with a browser face, an owner-only Unix socket, and QUIC for other machines.
None of that is gonk’s own code in the sense that matters. The ledger is
ikigai-ledger, the store is
ikigai-store, the browser is
ikigai-browse, and the doors are ikigai’s own
transports. gonk is the host that composes them, which is why this book can show each one
twice: as modules in a kernel you build, and as the server.
The ways to use it on its own
Each part is one way gonk is used standalone, and each builds on the one before it.
- A ledger of your own. File, comment, link, claim, close and ask
next— in this page, in a kernel of your own, and against a gonk you start. Then the same ledger in a browser. - An agent’s ledger. The ledger reached from another process over the socket, the way an
agent’s tool calls reach it; and over MCP, where
ikigai mcpprojects the ledger’s actions as an agent’s tools, through a door that names the agent. - The repository browser. A repository’s tree and files as resources, and an explanation of each derived once per content version, from a model gonk mounts rather than links.
- Review without a reviewer. One-off review, what a finding is, and the audit protocol that replaced per-commit review — with the measurement that retired it.
- A spec in the repository. OpenSpec keeps
requirements, scenarios, changes and tasks as Markdown in an
openspec/tree. Lifted to RDF with anikigai-markdownmapping, a change’s tasks become ledger items andnextranks them. - Bridges. Filing roborev findings as ledger items; importing a kata ledger; checking out the repositories gonk browses and keeping them current; and all three in order on a fresh machine.
- Several machines. The three doors, the grant each runs under, and a QUIC client.
- Scripts and sentences. A query saved at a name and run by a person and a worker; a query drafted from a sentence by a machine and published by a person; and what each door may do with either.
- Several teams. Two teams’ ledgers in one gonk, an agent for each and a manager over both: one query answering each caller by its grants, a join across the teams and the code review, a report one team publishes that the other cannot run, what a signed-out caller and a deleted item show, and each identity’s tool list.
- Ways of working. Processes rather than features: one person and an agent on one machine; a dispatcher and remote workers; review on every commit; hub and spoke; spec first; and periodic unled audits. Each chapter answers the same questions in the same order (who holds which role, what runs where, the loop step by step, what holds it, and what it costs), and links back to the chapters that teach its machinery rather than teaching it again. The last, harness engineering, is about the controls around any of them: the rules and checks as a graph, a log of every time a check fires, and the questions that say which rules nothing checks and which checks have never fired.
- Embedding. The ledger, the store and browse in a kernel of your own, with no gonk at all.
Three kinds of example, and how each is checked
A tutorial’s examples rot silently, and the first reader to hit a stale one assumes they are the problem. So every example in this book is one of three kinds, and each kind has a check that fails when the example stops telling the truth.
| kind | looks like | checked by |
|---|---|---|
| Runnable in the page | a cell with a Run button | the page’s own kernel (crates/gonk-book-wasm: the ledger over an in-memory store, compiled to wasm). A test runs every cell natively, in page order, and compares its output with the output the page shows |
| A Rust example | a rust block, or a listing included from crates/ | mdbook test compiles and runs every one against the crates under crates/ |
| A shell transcript | a console block of $ lines and their output | scripts/test-transcripts.sh installs gonk at the exact git revision this book pins, runs each page’s commands in a scratch home, and compares what they print with what the page says |
The third kind is new in this repository. A transcript names the check it is under in a
comment above it, and one that cannot be run (opening a browser, say) says so and why — a
transcript nobody runs fails the check rather than passing it. Where an answer contains
something minted at run time, an item’s id or a timestamp, the page prints … in its place,
and the check reads … as “anything here”.
Which gonk
gonk is not on crates.io yet. This book pins it to one git revision, held in one place
(books/gonk/gonk.rev) that the book, the transcript check and CI all read, and installs it
from there:
$ cargo install --locked --git https://github.com/ikigai-rs/ikigai-gonk --rev 3edf8304a1a201f83e6bce13f60741d8764de5aa
The modules it links are published crates: ikigai-core 0.1.99, ikigai-ledger 0.11.1,
ikigai-store 0.2.21, ikigai-browse 0.18.8, ikigai-script 0.3.3 and ikigai-nl 0.4.0. The
book’s own crates (the ones its Rust listings and in-page cells run) take the newest releases, one
step ahead of that: ikigai-core 0.1.103, ikigai-ledger 0.12.0, ikigai-store 0.2.23,
ikigai-browse 0.18.9 and ikigai-script 0.3.6. So a console block is answered by gonk’s
releases and a listing or a cell by the newer ones. No page prints differently because of it (the
newer releases add a ledger item’s children to its JSON face, refuse a query whose GRAPH names
a graph outside its set, and trace the kernel’s own operations). Only one page here meets any of
those through gonk: Two teams, one gonk sends gonk a query
whose GRAPH names a graph outside its set, and says what gonk’s store, one release short,
answers instead. It serves scripts and drafting at urn:script:* and urn:nl:*.
What each move of these pins changed for a reader, and the pages that show it, is on the What’s new page.
Where a chapter needs a model, its kernel binds a stub at urn:llm: that answers the same thing
every time: no example in this book calls a real model or the network. From Your first saved
query on, a page also starts that stub as a process of its own, model-stub,
for gonk to mount. It is this book’s own crate (crates/model-stub), not published anywhere, so it
is installed from this repository:
$ cargo install --locked --git https://github.com/ikigai-rs/ikigai-tutorial model-stub
From Over the socket on, a page also runs ikigai, the command-line host,
as the other process that reaches gonk. It IS on crates.io, so it is pinned to one exact
version, in one file (books/gonk/ikigai-cli.version) that the check and CI read the same
way. It is 0.1.61, the release whose transports the pinned gonk was built with, so it speaks the
same wire, and its own ikigai-core (0.1.99, as gonk’s) words a refusal at a family scope the way
gonk does, which Over the socket prints. (0.1.62 and 0.1.63 are newer and
change no wire; the rule takes what gonk links, and the book’s crates take 0.1.63.) The QUIC keep-alive a client
sends since 0.1.56 is answered by this gonk’s door, so a call that is silent for a while (a real
model drafting or explaining) is not lost over quic://.
$ cargo install --locked ikigai-cli --version 0.1.61 --features quic
--features quic is for the pages where ikigai reaches gonk from “another machine” over its QUIC
door, the first of them Over MCP; without it the command line refuses a quic://
address, and everything before that chapter works either way.
Each Ways of working chapter ends with the gonk
starter, a template repository whose
tools/new.py generates a repository set up for that process, and Harness
engineering uses the harness it generates. The starter is pinned the same way,
to one git revision in one file (books/gonk/starter.rev), and those pages clone it and check that
revision out themselves. It needs Python 3.11 or later and nothing else:
$ git clone https://github.com/ikigai-rs/ikigai-gonk-starter
$ git -C ikigai-gonk-starter checkout c7a501a29e93b1160f67007b080df9f2dbbe48d3
How to read this book
The published copy is at https://ikigai-rs.github.io/ikigai-tutorial/gonk/, rebuilt from
main on every merge, beside The ikigai Book,
which teaches the kernel this one stands on. You do not need to have read it; where a chapter
leans on an idea it explains, it links there.
The book changes often. What’s new lists each change, most recent first, with the pages to read for it.
# read it (mdbook serve never builds the in-page kernel, so Run is disabled there)
mdbook serve books/gonk --open
# check that it still tells the truth
./scripts/test-books.sh # every Rust example, and the accessibility gates
./scripts/test-transcripts.sh # every shell transcript, against the pinned gonk
⚠ Nothing in this book touches a gonk you already run. Every transcript runs a scratch gonk
on port 1070 whose config and data homes are inside the page’s own directory (--config-home,
--data-home), and the check refuses any command that names 1062, a real gonk’s port, before it
runs (or 1060, gonk’s own until 2026-10-12).
A gonk per chapter
Each chapter that runs commands runs them in a new empty directory of its own, the way the
check runs each page, and the gonk it starts keeps both its homes there. Every one of those gonks
listens on port 1070, so a chapter that starts one first asks you to stop the one an earlier chapter
left running (Ctrl-C in its terminal, which stops the tee beside it too), and the same for a
model-stub. Stopping loses nothing: the earlier chapter’s directory keeps that gonk’s store, and
the same command started there again picks it up where it was. Starting from nothing a chapter did
not make itself is also what lets you open the book at any chapter.
What’s new
What has changed in The gonk Book, most recent first: the date, what was added or changed, and the pages to read for it. Smaller upkeep (moving to a newer release when nothing a reader does changes, a corrected link, a typo) is not listed.
2026-10
-
2026-10-15: A new section in the introduction, A gonk per chapter, says that each chapter runs in a new empty directory with its own gonk on port 1070, and every chapter that starts one now says to stop the one an earlier chapter left running, naming that chapter. The introduction also says how to install the
model-stubthe later chapters start, and that Over MCP is the first page to need thequicfeature; a few cross-references now point at the right chapter.Pages: Introduction, The ledger in a browser
-
2026-10-15: A new page, What’s new, lists what has changed in the book, most recent first, with the pages to read for each change.
Pages: Introduction
-
2026-10-15: A new part, Two teams, one gonk, runs two named ledgers in one gonk with an agent per team and a manager over both: each team’s next, one query giving four answers to four identities, a cross-team join, a report one team publishes, and what a signed-out caller sees.
Pages: Two teams, one gonk, Introduction
-
2026-10-14: Review every commit now files published findings as ledger items with
ikigai-gonk findings file, the browser chapter shows that requests refused at the edge are logged too, and a leading~in a mount line now means your home.Pages: Review every commit, The ledger in a browser, Over the socket, Introduction
-
2026-10-13: A new Ways of working chapter, Harness engineering, presents guides and sensors and the steering loop, with the gonk starter’s harness as the worked example: a rule made to fire, its log line, and the questions the harness answers. In the other Ways of working chapters, With the starter now shows the harness too:
tools/harness.pyand the harness files among what is generated, and aharnessrow in whatinitreports.Pages: Harness engineering, Solo on one machine, Remote workers, Review every commit, Hub and spoke, Spec first, Periodic unled audits, Introduction
-
2026-10-13: Over the socket gains Asking in SPARQL, querying the ledger as root, sealed to one ledger, and refused outside the seal; Changes and tasks as ledger items shows that a re-sync that files nothing leaves a cached next cached.
Pages: Over the socket, Changes and tasks as ledger items, and next over them
-
2026-10-13: Each Ways of working chapter now ends with a With the starter section: the same process generated by the gonk starter, what the new repository holds, and what its setup and status commands report against the page’s gonk.
Pages: Solo on one machine, Remote workers, Review every commit, Hub and spoke, Spec first, Periodic unled audits, Introduction
-
2026-10-13: Hub and spoke no longer claims that
#starts a comment in the command grammar; it does not.Pages: Hub and spoke
-
2026-10-13: Ways of working gains three more processes: Hub and spoke built from gonk’s own primitives, Spec first, and Periodic unled audits run against a small repository in the page.
Pages: Hub and spoke, Spec first, Periodic unled audits
-
2026-10-13: A new part, Ways of working, opens with three processes, each told the same way (who, what runs where, the loop step by step, what holds it, what it costs): Solo on one machine, Remote workers and Review every commit.
Pages: Solo on one machine, Remote workers, Review every commit, Introduction
-
2026-10-12: An openspec tree as a graph now explains the time budget a lift’s queries run under and when a host passes its own.
Pages: An openspec/ tree as a graph
-
2026-10-12: Refusals at a family of grants now also say what would satisfy them, a draft whose repair repeats itself says drafting stopped, and an unreachable mounted model says how many dials failed and when the next is allowed.
Pages: A ledger of your own, Over the socket, A QUIC client, and the grant it runs under, Scripts and sentences in gonk, Explain, with a mounted model, Introduction
-
2026-10-12: Pages that name gonk’s default port now give 1062.
Pages: Introduction, The three doors, and who is asking, A QUIC client, and the grant it runs under, Over MCP
-
2026-10-11: Scripts and sentences in gonk is replayed against a newer gonk: drafts take
focus=and read only the parts of the grounding they need, an empty preview is sent back to the model and marked if it stays empty, a draft’s author is told it is a draft, and the owner’s check takes the draft’s query on a pipe, whichikigainow reads into asourcewhose one required argument is unfilled.Pages: Scripts and sentences in gonk, Your first saved query, Introduction
-
2026-10-11: A gentle opener, Your first saved query, publishes a count of open items and watches it rise, then asks gonk the same in a sentence and publishes the draft.
Pages: Your first saved query, Scripts and sentences in gonk
-
2026-10-11: A new part, Scripts and sentences in gonk: grants for scripts and drafting, a saved query run by a person and by a worker, the anonymous run budget, drafting a query from a sentence, and how a draft becomes a published script.
-
2026-10-11: With a newer gonk, the count question can be asked of gonk itself, the agent gains a count tool, and a client with no grant is pointed at the new worker authority, which may claim and comment but never close. A close now also releases the item’s claim; no page closes a claimed item.
Pages: Introduction, Changes and tasks as ledger items, and next over them, Over MCP, A QUIC client, and the grant it runs under
-
2026-10-11: Changes and tasks as ledger items now asks the ledger for a count of matching items instead of reading the whole listing, and says what count is.
Pages: Changes and tasks as ledger items, and next over them, Introduction
-
2026-10-10: QUIC clients now carry a host-scoped name, and The three doors explains when the older spelling still appears and why the door treats both as one actor.
Pages: The three doors, and who is asking, A QUIC client, and the grant it runs under, Over MCP
-
2026-10-10: The pages follow a newer gonk: browse roots no longer list the .git directory, a foreign host is answered 421, a signed-in passkey is named on reads as well as writes, and the agent’s ledger tools gain an item’s about; the introduction lists what changed and where.
Pages: Introduction, Browse roots: a repository as resources, The ledger in a browser, The three doors, and who is asking, Over MCP
-
2026-10-09: The ledger in a browser now explains how gonk answers another page that links to it but refuses one that loads it as an image or script, and the anonymous SPARQL time budget; Over MCP lists the ledger’s new state, assertion and doctor tools, and Checkout takes the server’s home flags.
Pages: The ledger in a browser, Over MCP, Checkout, A fresh machine, end to end
-
2026-10-09: A new chapter, Over MCP, gives an agent the ledger through its own QUIC identity: the tool list, a keyed append answering that the item already exists on a retry, what the server logged, and three refusals.
Pages: Over MCP, Over the socket, Introduction
-
2026-10-09: Every chapter now runs the newer command line: scratch servers are named with
--config-homeand--data-home, mounted reads arrive uncacheable and the socket chapter explains why, a new section shows what a sealed session still cannot be kept from, and the QUIC chapters name each client on every request and cover listing, removing and rotating clients.Pages: Over the socket, A QUIC client, and the grant it runs under, The three doors, and who is asking, The ledger in a browser, Browse roots: a repository as resources, A ledger of your own
-
2026-10-06: The kata import and roborev filing now use the keyed append, so a rerun or two hooks racing on one finding file it once; the kata dry run asks the ledger and reports what is already there, and the sync section explains that it recognizes a task by its key, not by an item filed about it by hand.
Pages: Importing a kata ledger, Filing roborev findings, Changes and tasks as ledger items, and next over them, Over the socket
-
2026-10-06: Changes and tasks as ledger items now files each task with the task’s own name as a key, so two syncs started together file each task once, and the chapter shows the race the key closes.
Pages: Changes and tasks as ledger items, and next over them, An openspec/ tree as a graph, Introduction
-
2026-10-06: A new chapter, The ledger, the store and browse in your own kernel, composes all three in one dataset with no gonk at all and joins ledger items to review findings.
Pages: The ledger, the store and browse in your own kernel, Introduction
-
2026-10-06: A new part, Several machines: The three doors compares HTTP, the socket and QUIC by who is asking, and A QUIC client sets up a second machine with a minted identity, a grant built from roles, and refusals for everything outside it.
Pages: The three doors, and who is asking, A QUIC client, and the grant it runs under, Introduction
-
2026-10-06: A new part, A spec in the repository: OpenSpec in one chapter, an openspec tree lifted to a graph by a mapping, and a change’s tasks filed as ledger items with next ranking them.
Pages: OpenSpec in one chapter, An openspec/ tree as a graph, Changes and tasks as ledger items, and next over them
-
2026-10-06: Review without a reviewer arrives: One-off review explains what a finding is and how only a person’s decision turns it into an annotation, and The audit protocol presents the measurements that retired per-commit review.
Pages: One-off review, and what a finding is, The audit protocol, and why per-commit review is off
-
2026-10-06: A new chapter, Explain, with a mounted model, derives an explanation of each file once per content version from a model gonk mounts, with the cost line and a deterministic stub so no example calls a real model.
-
2026-10-06: Browse roots shows a repository served as resources, and a new Bridges part covers filing roborev findings as ledger items, importing a kata ledger, keeping checkouts current, and all of it in order on a fresh machine.
Pages: Browse roots: a repository as resources, Filing roborev findings, Importing a kata ledger, Checkout, A fresh machine, end to end
-
2026-10-06: Two new chapters: The ledger in a browser walks gonk’s pages, forms, passkey invitations and access log, and Over the socket reaches the same ledger from another process with the
ikigaicommand line, the way an agent does.Pages: The ledger in a browser, Over the socket, Introduction
-
2026-10-06: The gonk Book opens: an introduction to gonk as one binary that holds a durable store and serves a work ledger, and a first chapter, A ledger of your own, that files, claims, closes and asks what to do next three ways: in the page, in a kernel of your own, and against a gonk you start.
A ledger of your own
A ledger here is a list of work: items you file, comment on, link, claim and close, and one question it answers as a resource rather than as a sort order — what should I do next? It is the smallest useful thing gonk serves, and everything later in this book files into it, so it comes first.
You will use the same ledger three times in this chapter, and they are three views of one thing rather than three things:
- in this page, where a kernel compiled to wasm holds it and every Run is real;
- in a kernel of your own, a few lines of Rust over two published modules; and
- served by gonk, a binary you start, reached with
curl.
In the page
Each cell below sends its line to this page’s kernel: ikigai-ledger over an in-memory
ikigai-store, the same composition the Rust half of this chapter builds. The cells share one
ledger, so run them in order; reload the page for an empty one. Where the ledger mints
something — an item’s id, a time — the expected output says ….
File three items. The first line of a value is the title; priority and labels are named
arguments.
#1 urn:iki:ledger:default:item:… [uncacheable]
#2 urn:iki:ledger:default:item:… [uncacheable]
#3 urn:iki:ledger:default:item:… [uncacheable]
An item has two names. #2 is a label the ledger gives it for people, numbered in filing
order; urn:iki:ledger:default:item:… is its name, minted once and never reused, and it is
what a link, a comment or another graph refers to. A write is [uncacheable]: it changes the
ledger, so there is nothing to keep.
List them:
#3 open p1 Check every transcript #2 open p0 Pin gonk to a git rev [book] #1 open p- Write the gonk book 3 item(s) [computed]
p- is no priority at all, which is different from a low one. The list is the most recently
updated first; it is a listing, not a recommendation. A comment and a link make the next answer
more interesting: #2 must happen before #1 can.
commented on #2 (urn:iki:ledger:default:comment:…) [uncacheable]
linked #2 blocks #1 [uncacheable]
Now ask:
1. #2 open p0 Pin gonk to a git rev [book]
p0 — priority 0; last updated …
2. #3 open p1 Check every transcript
p1 — priority 1; last updated …
policy: priority-recency (weighs priority, recency, number)
ready: 2 excluded: 1
not #1: blocked by #2
[computed]
That answer is three things a sort order cannot give you. It is a ready set: #1 is not
ranked low, it is excluded, and the reason is printed. Each ranked item says why it is
where it is. And the policy is named — priority-recency is one of the ranking policies
the host registered, and urn:iki:ledger:policy:priority-recency describes what it weighs.
next is a view over the ledger, not a column in it.
Claim #3, and close #2. A claim needs a holder, because an unattributed claim is
indistinguishable from none; a close takes its reason as the value, recorded as a comment.
#3 claimed by you [uncacheable]
closed #2 (done) [uncacheable]
1. #1 open p- Write the gonk book
unprioritized — no priority set, so it ranks below every item that has one; last updated …
policy: priority-recency (weighs priority, recency, number)
ready: 1 excluded: 1
not #3: claimed by you
[computed]
Closing #2 unblocked #1 without anybody touching #1, and #3 left the ready set because
somebody has it. Nothing told next that anything changed; it was derived again, because
what it was derived from was written. That is a golden thread, the kernel’s dependency
tracking — the ikigai Book’s chapter on it is where it is taught.
In a kernel of your own
That page’s kernel is a crate in this repository,
crates/ledger-host,
and it is short because it writes no endpoint. The ledger owns no bytes: every read it makes
is a SPARQL query at the store’s narrow door for one graph, and every write is a SPARQL update
at another. So a host’s whole job is to put the two side by side:
/// The ledger and the store it is written in, as one space.
///
/// Order matters only for names both would claim, and they claim none in common: the store
/// owns `urn:iki:store:*`, the ledger `urn:iki:ledger:*`. What matters is that both are
/// HERE — the ledger owns no bytes, so a kernel with the ledger and no store refuses every
/// ledger name, naming `urn:iki:store:*` as what to bind.
pub fn space() -> Fallback {
let store = DurableStore::in_memory().expect("an in-memory store opens");
Fallback::new(vec![
Arc::new(ikigai_store::space(store)) as Arc<dyn Space>,
Arc::new(ikigai_ledger::space()) as Arc<dyn Space>,
])
}
and to give the kernel a clock, because every item, comment and claim is stamped and the ledger refuses a write it cannot stamp:
/// The host: [`space`], a Meta renderer (so the ledger can describe itself), and a clock.
///
/// ⚠ The clock is not optional. Every item, comment, claim and close is stamped, and the
/// ledger refuses a write it cannot stamp rather than making an unstamped one.
pub fn kernel() -> Kernel {
kernel_with_clock(Arc::new(StoryClock::default()))
}
/// [`kernel`], with a clock of the caller's choosing — a host's real clock, say.
pub fn kernel_with_clock(clock: Arc<dyn Clock>) -> Kernel {
Kernel::with_meta_renderer(Arc::new(space()), Arc::new(ikigai_vocab::TurtleRenderer))
.with_clock(clock)
}
The story clock starts at 2026-10-06T09:00:00Z and advances a second each time it is read, so
two items are never filed “at the same instant” and the times a run prints are the times the
next run prints. A real host passes its own clock to kernel_with_clock.
Using a resource from Rust is one request, issued: a verb, a name, named arguments. The body of
a Sink is the argument called content — the name the REPL and gonk’s HTTP door both put a
value or a request body under.
/// Issue one request and read the answer as text: the whole of "using a resource" from Rust.
///
/// Every argument goes inline, by name; a Sink's body is the argument called `content`, the
/// name the REPL and gonk's HTTP door both put a piped value or a request body under.
pub fn ask(kernel: &Kernel, verb: Verb, iri: &str, args: &[(&str, &str)]) -> Result<String> {
ask_as(kernel, &Capability::root(), verb, iri, args)
}
/// [`ask`], under a capability other than root — what a caller holding a grant can do.
pub fn ask_as(
kernel: &Kernel,
capability: &Capability,
verb: Verb,
iri: &str,
args: &[(&str, &str)],
) -> Result<String> {
let iri = Iri::parse(iri).map_err(|e| Error::Endpoint(format!("not an IRI: {e}")))?;
let request = args
.iter()
.fold(Request::new(verb, iri), |request, (name, value)| {
request.with_arg(*name, ArgRef::Inline(value.as_bytes().to_vec()))
});
let answer = block_on(kernel.issue(request, capability))?;
Ok(String::from_utf8_lossy(&answer.bytes).into_owned())
}
Here is the page’s walk again, compiled and run by mdbook test:
#![allow(unused)]
fn main() {
extern crate ikigai_core;
extern crate ledger_host;
use ikigai_core::Verb;
use ledger_host::{ask, kernel};
let ledger = kernel();
let file = |text: &str, args: &[(&str, &str)]| {
let mut all = vec![("content", text)];
all.extend_from_slice(args);
ask(&ledger, Verb::Sink, "urn:iki:ledger:append", &all).unwrap()
};
assert!(file("Write the gonk book", &[]).starts_with("#1 urn:iki:ledger:default:item:"));
file("Pin gonk to a git rev", &[("priority", "0"), ("labels", "book")]);
file("Check every transcript", &[("priority", "1")]);
ask(&ledger, Verb::Sink, "urn:iki:ledger:link",
&[("item", "2"), ("type", "blocks"), ("content", "1")]).unwrap();
let next = ask(&ledger, Verb::Source, "urn:iki:ledger:next", &[]).unwrap();
assert!(next.contains("not #1: blocked by #2"), "{next}");
ask(&ledger, Verb::Sink, "urn:iki:ledger:claim",
&[("item", "3"), ("content", "you")]).unwrap();
ask(&ledger, Verb::Sink, "urn:iki:ledger:close",
&[("item", "2"), ("content", "Pinned.")]).unwrap();
let next = ask(&ledger, Verb::Source, "urn:iki:ledger:next", &[]).unwrap();
assert!(next.contains("ready: 1"), "{next}");
assert!(next.contains("not #3: claimed by you"), "{next}");
}
Whose ledger
Every request above ran as root, which a host may do for itself. A caller is given less. The ledger gates each action by the ledger’s name, and — because it asks the store under the caller’s capability, never its own — the caller also needs the store’s grant for that ledger’s graph. Four tokens make a writer of one ledger:
/// What a caller needs to read and write ONE ledger, and nothing else.
///
/// Two halves, and the ledger checks both: its own grant for the ledger by name, and the
/// store's grant for that ledger's graph (the ledger asks the store under the CALLER's
/// capability, never its own). This is the list `ikigai-gonk grants <ledger> write` prints.
pub fn write_grant(ledger: &str) -> Capability {
let graph = format!("urn:iki:ledger:graph:{ledger}");
Capability::scoped([
format!("urn:cap:ledger:read:{ledger}"),
format!("urn:cap:ledger:write:{ledger}"),
format!("urn:cap:store:read:graph:{graph}"),
format!("urn:cap:store:write:graph:{graph}"),
])
}
and those four reach that ledger and no other. A ledger is named in the resource’s IRI
(urn:iki:ledger:book:append), not passed as an argument, precisely so that a capability can
bind to it:
#![allow(unused)]
fn main() {
extern crate ikigai_core;
extern crate ledger_host;
use ikigai_core::{Error, Verb};
use ledger_host::{ask_as, kernel, write_grant};
let ledger = kernel();
let book = write_grant("book");
let filed = ask_as(&ledger, &book, Verb::Sink, "urn:iki:ledger:book:append",
&[("content", "Mine")]).unwrap();
assert!(filed.starts_with("book#1 "), "{filed}");
let refused = ask_as(&ledger, &book, Verb::Sink, "urn:iki:ledger:append",
&[("content", "Not mine")]);
assert!(matches!(refused, Err(Error::Denied(_))));
}
A named ledger spells its labels with its name (book#1), so two ledgers’ #1s cannot be
confused in one sentence. The ledger called default is the one whose name may be left out.
Two teams, one gonk gives two teams a named ledger each, in one gonk,
and shows what each team’s grant does and does not reach.
Served
gonk is that same composition in a process that keeps it: a durable store on disk instead of an in-memory one, and doors for other processes to reach it by. Install it at the revision this book pins (see Which gonk), then start one of your own in an empty directory, with its two homes inside that directory:
$ ikigai-gonk --config-home "$PWD/.config/ikigai" --data-home "$PWD/.ikigai" --port 1070 --no-quic --no-backup
ikigai-gonk 0.1.0 — holding the store at …/.ikigai/store
http http://localhost:1070/ — loopback (127.0.0.1:1070); anonymous read+write: default; 0 passkey(s); anonymous SPARQL budget 1000 ms
…
socket …/.ikigai/gonk.sock — owner only
quic off (--no-quic)
…
⚠ The two homes are the point of that line. gonk keeps its files in two places. The
config home (~/.config/ikigai unless told otherwise) holds config.toml and gonk’s
gonk/ directory of clients, grants and certificates; the data home (~/.ikigai) holds the
store, the socket, backups and the review queue. --config-home and --data-home move them,
so this gonk keeps everything in the directory you started it in. Started without them, it
would open — or, if one is running, be refused by — the store a real gonk on your machine holds.
(--store names the dataset’s directory outright, and --socket the socket, when only one of
them should move.) The paths are written with $PWD so they mean the same directory in every
command on the page. gonk makes a relative home absolute from the directory it started in (since
gonk PR 106, ledger #918; before it, a relative home reached the banner’s mount line
relative), but it leaves a --socket as given, because a Unix socket’s path must fit in 104
bytes.
Port 1070 keeps it off a real gonk’s 1062; --no-quic and --no-backup keep it from opening a
network door or writing archives this chapter does not need.
The banner is the server describing itself, a line per door and per family it did or did not compose. Leave it running and use another terminal. Every ledger resource is reachable over HTTP by a mechanical mapping: the path’s segments become the name, the method the verb, query parameters the arguments, and the body the content.
$ curl -s -X POST --data-binary 'Write the gonk book' http://127.0.0.1:1070/iki/ledger/append
#1 urn:iki:ledger:default:item:…
$ curl -s -X POST --data-binary 'Pin gonk to a git rev' 'http://127.0.0.1:1070/iki/ledger/append?priority=0&labels=book'
#2 urn:iki:ledger:default:item:…
$ curl -s -X POST --data-binary 'Check every transcript' 'http://127.0.0.1:1070/iki/ledger/append?priority=1'
#3 urn:iki:ledger:default:item:…
$ curl -s http://127.0.0.1:1070/iki/ledger/items
#3 open p1 Check every transcript
#2 open p0 Pin gonk to a git rev [book]
#1 open p- Write the gonk book
3 item(s)
The same answers as the page’s, from a different host. Link, claim, close, and ask:
$ curl -s -X POST --data-binary '1' 'http://127.0.0.1:1070/iki/ledger/link?item=2&type=blocks'
linked #2 blocks #1
$ curl -s -X POST --data-binary 'you' 'http://127.0.0.1:1070/iki/ledger/claim?item=3'
#3 claimed by you
$ curl -s -X POST --data-binary 'Pinned.' 'http://127.0.0.1:1070/iki/ledger/close?item=2'
closed #2 (done)
$ curl -s http://127.0.0.1:1070/iki/ledger/next
1. #1 open p- Write the gonk book
unprioritized — no priority set, so it ranks below every item that has one; last updated …
policy: priority-recency (weighs priority, recency, number)
ready: 1 excluded: 1
not #3: claimed by you
What the HTTP door will not do is part of the lesson. An anonymous caller on loopback holds
the read and write grants of the ledgers gonk is configured to serve that way — default,
unless told otherwise — and nothing else. Deleting is a separate grant:
$ curl -s -X DELETE http://127.0.0.1:1070/iki/ledger/item/1
denied: capability does not grant `urn:cap:ledger:delete:*` (declared by `urn:iki:ledger:item:1`; any grant under `urn:cap:ledger:delete:` satisfies it)
$ ikigai-gonk grants default delete
[
"urn:cap:ledger:read:default",
"urn:cap:store:read:graph:urn:iki:ledger:graph:default",
"urn:cap:ledger:write:default",
"urn:cap:store:write:graph:urn:iki:ledger:graph:default",
"urn:cap:ledger:delete:default",
"urn:cap:store:write:graph:urn:iki:ledger:graph:default:deleted"
]
grants prints the token list for one ledger at one authority, for writing a grant by hand;
the next chapter gives one to a person, with a passkey. Read it as the four-token writer from
the Rust half plus two: the ledger’s delete grant, and a write grant for a SECOND graph,
…:default:deleted. A delete is a move into that ledger’s graveyard, recoverable by hand, so
it is a write to two graphs, and the caller must be able to make both. The refusal names the token it wanted,
because a capability that is declared is a capability that is enforced, and the other way
round. It is gonk’s refusal, from the ikigai-core the pinned revision links, and since core
0.1.97 a refusal at a family scope like this one also says what satisfies it: any grant under
urn:cap:ledger:delete:, which is exactly what delete in the list above adds.
$ open http://localhost:1070/
opens the same ledger as a page: the three items, next, a form to file another. That page is
the next chapter.
What you built
| the page | your kernel | gonk | |
|---|---|---|---|
| store | in memory, per page load | in memory, per process | RocksDB, in the data home (~/.ikigai/store by default) |
| clock | the story clock | the story clock, or yours | the system’s |
| caller | root | root, or a grant | per door: loopback HTTP holds default’s read and write |
| reached by | Run | ask | HTTP, the socket, QUIC |
In resource terms: the sets are the ledgers and each one’s items; the only atoms that
hold state are one named graph per ledger, which the store holds; every action is a
composition over that graph; items, item:{id} and next are views. None of that is
code in ledger-host, which is a composition of two published spaces and a clock.
The ledger in a browser
The last chapter reached gonk with curl, through paths that are a mechanical mapping of the
ledger’s own resources. This one opens the same server the way a person does: pages, forms, a
sign-in. Everything a browser does here is still a request to a resource, so every step but
one can be checked from a shell, and this chapter does check them. The one that cannot is the
passkey ceremony, which needs a browser and an authenticator; it is marked, and described.
Start one, and keep what it says
Start a scratch gonk as before, with both its homes in a new empty directory, and this time keep what it prints in a file as well as on the screen. If the one from A ledger of your own is still running, stop it first (Ctrl-C in its terminal): this one takes the same port, and the page’s answers count from an empty ledger (A gonk per chapter).
$ ikigai-gonk --config-home "$PWD/.config/ikigai" --data-home "$PWD/.ikigai" --port 1070 --no-quic --no-backup 2>&1 | tee gonk.log
ikigai-gonk 0.1.0 — holding the store at …/.ikigai/store
http http://localhost:1070/ — loopback (127.0.0.1:1070); anonymous read+write: default; 0 passkey(s); anonymous SPARQL budget 1000 ms
…
log one `gonk:Access` line per request at each door, on stderr; the Queue badge's poll only when it fails, takes a second, or is the 60th (`gonk.log.access = false` turns it off)
…
gonk writes its banner and its log to standard error, so 2>&1 | tee gonk.log is how a
terminal keeps both: the screen shows them, and gonk.log is the record you will read at the
end of this chapter. (Run as a service, the same lines go wherever the service manager sends
standard error; gonk’s README uses /tmp/ikigai-gonk.log.)
File something, so the pages have an item to show:
$ curl -s -X POST --data-binary 'Write the gonk book' http://127.0.0.1:1070/iki/ledger/append
#1 urn:iki:ledger:default:item:…
The pages
Open http://localhost:1070/ in a browser. Use localhost, not 127.0.0.1: a passkey is
bound to a host name, and a browser refuses an IP address as one, so a page opened by address
can never sign in.
$ open http://localhost:1070/
The front page is the first ledger you may read, open items first, with filters for closed and all, a title search, and a form that files an item. A page is HTML, and HTML is something a shell can read as well as a browser. These are the lines that matter in it:
$ curl -s http://localhost:1070/ | grep -o "<title>[^<]*</title>"
<title>Ledger · gonk</title>
$ curl -s http://localhost:1070/ | grep -o "<span class='how-many'>[^<]*</span>"
<span class='how-many'>1 open items</span>
$ curl -s http://localhost:1070/l/default/item/1 | grep -o "<h1 class='item-title'>[^<]*</h1>"
<h1 class='item-title'>Write the gonk book</h1>
| path | what it shows |
|---|---|
/ | the first ledger you may read |
/l/{ledger} | one ledger, with the status filters and the search |
/l/{ledger}/item/{n} | one item: body, metadata, labels, links, comments, and the forms that work on it |
/sparql | a query over one ledger’s graph, rendered as a table; asked for a results format, the SPARQL 1.1 Protocol |
Every one of those pages is a transform of a graph face. The ledger page is
urn:iki:ledger:default:items asked for as Turtle and rendered by one XSLT stylesheet,
server-side, so the page cannot show anything the graph does not say. The graph is right
there:
$ curl -s -H 'Accept: text/turtle' http://127.0.0.1:1070/iki/ledger/items | grep dcterms:title
dcterms:title "Write the gonk book" ;
and so is a query over it. Asked for a results format rather than HTML, CSV among them,
/sparql answers a program instead of a person: it is the SPARQL 1.1 Protocol, run under your
grant. An anonymous caller’s query also runs under a time budget, the banner’s anonymous SPARQL budget 1000 ms: it runs for at most a second, so one expensive query cannot hold the server
(gonk PR 105, ledger #964). gonk’s own queries, the ones behind its pages, never run
under it.
$ curl -s -G -H 'Accept: text/csv' --data-urlencode 'query=PREFIX dcterms: <http://purl.org/dc/terms/> SELECT ?title WHERE { ?item dcterms:title ?title }' http://127.0.0.1:1070/sparql
title
Write the gonk book
Naming no ledger, the query’s dataset is the union of every graph your grant may read. For an
anonymous caller that is the ledgers the HTTP door grants, here default’s graph alone:
$ curl -s -G -H 'Accept: text/csv' --data-urlencode 'query=SELECT ?g (COUNT(*) AS ?n) WHERE { GRAPH ?g { ?s ?p ?o } } GROUP BY ?g' http://127.0.0.1:1070/sparql
g,n
urn:iki:ledger:graph:default,…
ledger= narrows the dataset to one ledger’s graph and graph= to the graphs named. A graph
the grant holds no token for refuses the whole query rather than answering over the part it
can see, and FROM is refused rather than quietly ignored, so a grant naming one ledger cannot
read another through this door:
$ curl -s -w '\n%{http_code}\n' -G -H 'Accept: text/csv' --data-urlencode 'ledger=book' --data-urlencode 'query=SELECT ?s WHERE { ?s ?p ?o }' http://127.0.0.1:1070/sparql
denied: this capability does not hold `urn:cap:store:read:graph:urn:iki:ledger:graph:book`, so it cannot query `urn:iki:ledger:graph:book`
403
The HTML page is the same resource for a person: an editor over one ledger’s graph, with the answer rendered as a table.
A form is a ledger action
The form on the front page does not write anything gonk invented. It posts to /act, which
names an action the ledger already declares (_action=append), checks every field against
that action’s contract, and issues it under your grant. This is the request your browser
sends when you file an item there:
$ curl -s -d _action=append -d _ledger=default -d _then=items --data-urlencode 'content=Read it in a browser' http://localhost:1070/act | grep -o "<span class='how-many'>[^<]*</span>"
<span class='how-many'>2 open items</span>
The answer is the fragment of the page that changed (_then=items), which the page swaps in
place with htmx. There is no single-page application and no build step:
the server renders, the browser shows.
A door that lets anything on this machine write the ledger also lets any web page open on
this machine try to: a page on any site can post a form to localhost. So the browser’s own
signals are a refusal. A write whose Origin (or Sec-Fetch-Site) names another site is
answered 403 at the edge, before it reaches anything it names:
$ curl -s -w '\n%{http_code}\n' -H 'Origin: https://elsewhere.example' -d _action=append -d _ledger=default -d _then=items --data-urlencode 'content=Not from here' http://localhost:1070/act
a write from another site is refused: its Origin or Sec-Fetch-Site names a page that is not this server's own
403
curl sends no Origin unless told to, and neither does a script, which is why the requests
before it were unaffected. A request whose Host is anything but localhost, 127.0.0.1 or
[::1] (with this port) is refused too, reads included, and the front page is no
exception; that is the defense against DNS rebinding, where a page on another site reaches a
loopback server under its own name:
$ curl -s -w '\n%{http_code}\n' -H 'Host: elsewhere.example' http://127.0.0.1:1070/
this server answers only to its own loopback name (localhost, 127.0.0.1 or [::1], with its port); a request under another Host is refused — it is how a DNS rebinding page reaches a loopback server
421
The status is 421 Misdirected Request (since gonk e45e5f9, 403 before): the request named a
server that is not this one, which is a different refusal from this server declining a request
made to it, and the cross-site write above stays 403. Refused, rather than served with an empty grant: an empty grant is still offered everything
that needs no grant (the pages, the passkey ceremonies, a resource’s description), and gonk’s
audit showed another site filling the passkey ceremony’s table that way. A refused request
learns nothing, not even which methods a path accepts.
A read from another page is the subtler case, because a link to an item from somewhere else
is ordinary and ought to work. The browser labels every request with where it came from
(Sec-Fetch-Site) and what it is for (Sec-Fetch-Mode, Sec-Fetch-Dest), and gonk draws
its line at the page’s origin, port included. The port matters: a site is a scheme and a
domain, so a page on http://localhost:8090 is the same site as gonk, and the browser sends
gonk’s sign-in cookie with everything that page asks for. Another page that navigates
here (a link, a typed address) is answered, under only the read half of whoever is signed in;
another page that loads something from here, as an image, a script, a frame or a
fetch, is refused at the edge. curl can send the labels a browser would:
$ curl -s -w '\n%{http_code}\n' -H 'Sec-Fetch-Site: same-site' -H 'Sec-Fetch-Mode: no-cors' -H 'Sec-Fetch-Dest: image' http://localhost:1070/l/default/item/1
a page that is not this server's own may only NAVIGATE here (a link, or a typed address); it may not load this server's resources as an image, script, frame or fetch
403
$ curl -s -o /dev/null -w '%{http_code}\n' -H 'Sec-Fetch-Site: same-site' -H 'Sec-Fetch-Mode: navigate' -H 'Sec-Fetch-Dest: document' http://localhost:1070/l/default/item/1
200
Nothing gonk serves is meant to be embedded in another page, and a load is how a page spends
someone’s authority without anyone clicking anything: an <img> pointing at a resource that
asks a model for an explanation or a review (the browse family’s)
would run the model and archive its answer under the signed-in identity’s grant. Through gonk
ed43af3 both kinds of read were answered under the whole grant, which is what that
<img> did (gonk PR 100, ledger #880). The read half, for a navigation, is the same caution
one step milder: following a link from elsewhere shows what the identity may read, and does
not spend on its behalf.
Who you are, and what that grants
An anonymous caller on this machine reads and writes the ledgers in gonk.http.ledger
(default, unless configured), and nothing else. A ledger outside that list is closed to it,
and the refusal says so in words before it says it in tokens:
$ curl -s http://localhost:1070/l/book
denied: Sign in to read the ledger "book".
Signed in already? Your passkey's grant doesn't include the ledger "book".
The grant it needs: urn:cap:ledger:read:book (to read the ledger "book")
That is the notice a signed-out visitor reads, here and on every page that needs more than anonymous authority, with a Sign in with passkey button beside it in a browser. Anything more than anonymous, such as delete, another ledger, or reading the repositories gonk browses, belongs to an identity, and an identity is given by invitation:
$ ikigai-gonk passkey invite ada --ledger book=write --browse read --port 1070 --config-home "$PWD/.config/ikigai"
passkey invite for `ada` — grant `ada` (6 scopes) in …/.config/ikigai/gonk/grants.json
valid for 30 minutes, once
open http://localhost:1070/#invite=…
in a browser on this machine (localhost, not 127.0.0.1); the passkey is enrolled in …/.config/ikigai/gonk/clients.json
The same --config-home again, so the invite is written into this gonk’s config home rather
than a real one; and --port 1070, so the link it prints points at this gonk. Each flag is a
role, and the command writes the tokens the role means:
$ cat .config/ikigai/gonk/grants.json
{
"ada": [
"urn:cap:ledger:read:book",
"urn:cap:store:read:graph:urn:iki:ledger:graph:book",
"urn:cap:ledger:write:book",
"urn:cap:store:write:graph:urn:iki:ledger:graph:book",
"urn:cap:browse:read:*",
"urn:cap:store:read:graph:urn:iki:browse:graph:default"
]
}
--ledger book=write is the four-token writer of one ledger from
the last chapter. --browse read is the repositories as a role:
read every browse root (--root <name> names one instead), and the browse graph’s quads. Its
stronger sibling, --browse derive, adds minting annotations and spending a mounted model’s
inference; it is one word on purpose, and it is refused on a server with no model to spend.
The repositories arrive in Browse roots.
Two rules sit behind the command. An identity must be strictly stronger than anonymous, so
passkey invite ada --ledger default=write is refused: signing in would change nothing. And
a grant name is shared by every passkey and certificate enrolled under it, so writing
different scopes under an existing name is refused unless --force, naming each scope the
rewrite would remove and add.
One role is deliberately missing from that grant. ada may write book through the ledger,
and the grant holds the store’s write token for book’s graph because the ledger makes its own
writes under the caller’s capability. That token is not authority to write the graph’s
triples directly: gonk’s HTTP and QUIC doors refuse a raw store write to a ledger graph, because
that is how an author naming somebody else would get in. An identity that really must write
raw (a repair tool, say) is given it by name, with --ledger-graph book, which the
QUIC chapter shows.
The ceremony
$ open "http://localhost:1070/#invite=…"
Open the link the invite printed, in a browser on the machine gonk runs on:
- A panel, Create a passkey for this server, asks for a label (
laptop Touch ID, say). - Create passkey brings up the browser’s own passkey sheet. Approve it. gonk enrolls the
credential’s public key under the grant
ada, and the page says so and asks you to sign in. - Sign in with passkey, in the header, and approve a second time. The page reloads signed in, and that browser holds the grant until it signs out, its session’s twelve hours end, or the server restarts.
The second click is deliberate: when the creation sheet closes it still holds the window’s
focus, and a browser refuses a sign-in prompt from a page without it. The invite is
single-use and expires; gonk stores only its hash, and it is the whole trust anchor of the
registration, so no attestation is parsed. After signing in, /l/book is a ledger page
rather than a refusal, and every write ada makes carries her passkey as its author.
What it wrote down
Every door writes one line per request, and gonk.log has caught them all. The grammar is
ikigai-log’s: when the request started, a class, the resource, then the same columns on
every line.
$ grep ' urn:iki:gonk:act ' gonk.log
… gonk:Access urn:iki:gonk:act door=http verb=sink outcome=ok bytes=… dur=… principal=anon q=-
… gonk:Access urn:iki:gonk:act door=http verb=sink outcome=denied bytes=- dur=0 principal=anon q=-
$ grep ' urn:iki:gonk:page:item:default:1 ' gonk.log
… gonk:Access urn:iki:gonk:page:item:default:1 door=http verb=source outcome=ok bytes=… dur=… principal=- q=-
… gonk:Access urn:iki:gonk:page:item:default:1 door=http verb=source outcome=denied bytes=- dur=0 principal=- q=-
… gonk:Access urn:iki:gonk:page:item:default:1 door=http verb=source outcome=ok bytes=… dur=… principal=- q=-
Read them with three things in mind:
- The subject is the resource, not the URL.
/l/default/item/1(asked three times: bycurlas itself, as an image another page loads, and as another page’s link) wasurn:iki:gonk:page:item:default:1; the form wasurn:iki:gonk:act. principalis who the door could say was asking. The form’s line saysanon, an anonymous write; a page read by nobody signed in says-. Signed in, every line names the passkey’s IRI, a read as well as a write: the door mints it into the request’s capability (core’s principal scope), which is where the log reads it. Before gonk PR 122 the HTTP door knew who a write was from and never a read, so a signed-in read said-too.outcomeis the kernel’s, not HTTP’s. A refusal the resource makes (the/l/bookpage, below) is a line withoutcome=denied. A refusal at the edge, before the kernel (the cross-site form, the foreignHost, the loaded image), is a line too, written by the edge itself:outcome=denied bytes=- dur=0, under the resource the path would have reached, so the secondactline is the cross-site form and the middle item line is the image. Probing shows up in the log. (Before gonk PR 151 an edge refusal wrote no line at all.)
$ grep ' urn:iki:gonk:page:ledger:book ' gonk.log
… gonk:Access urn:iki:gonk:page:ledger:book door=http verb=source outcome=denied bytes=- dur=… principal=- q=-
dur is milliseconds, so grep ' dur=[0-9]\{4,\} ' gonk.log lists every request that took a
second or more. The first page this gonk rendered is usually one of them: it compiled the
stylesheet.
What you have now
| anonymous, on loopback | signed in as ada | |
|---|---|---|
default | read, write | read, write (anonymous’s grant, plus her own) |
book | the sign-in notice | read, write |
| delete, purge | refused | refused: not in her grant |
| the repositories | none | read, once a root is configured |
| a write’s author | none | her passkey |
The pages added no authority and no state of their own. The ledger is the graph it was in the
last chapter; a page is a view of it, a form is one of its actions, and an identity is a name
for a list of tokens in grants.json. The next part reaches the same ledger from another
process, the way an agent does. (What a signed-out caller sees of a gonk that two teams share is in
Two teams, one gonk.)
Over the socket
An agent does its work in a process, and a person reads its work in a browser. The browser came through gonk’s HTTP door. The agent comes through the socket: a Unix socket beside the store, owner-only, which another ikigai process on the same machine mounts as part of its own namespace. Nothing on the other side of it is an HTTP route; it is the kernel’s own wire, so the agent’s tool calls are the ledger’s resources, by their own names, with their own contracts.
This chapter uses ikigai, the command-line host from
ikigai-cli, as that other process. Install it at
the version this book pins, beside gonk (Which gonk):
$ ikigai --version
ikigai 0.1.61
Start a gonk, and find its socket
In a new empty directory, as in every chapter: if the gonk from The ledger in a browser is still running, stop it first, since this one takes the same port.
$ ikigai-gonk --config-home "$PWD/.config/ikigai" --data-home "$PWD/.ikigai" --port 1070 --no-quic --no-backup 2>&1 | tee gonk.log
ikigai-gonk 0.1.0 — holding the store at …/.ikigai/store
…
socket …/.ikigai/gonk.sock — owner only
…
mount mount = "prefer urn:iki:ledger:=…/.ikigai/gonk.sock" (and the same for urn:iki:store:)
Two lines of the banner are for this chapter. socket says where the door is and who may use
it: the file is created owner-only, so the operating system decides who can connect, and
whoever can connect is the owner. mount is the line to give another ikigai process so that it
reaches the ledger through that door. Hold on to both; first, the shortest way in.
A mount on the command line
--mount <prefix>=<target> grafts a remote kernel into this one’s namespace under a prefix,
as an alias: a name <prefix>rest is sent to the remote as urn:rest. So with gonk’s
socket mounted at urn:gk:, the ledger’s urn:iki:ledger:append is urn:gk:iki:ledger:append
here:
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:append priority=1 Filed over the socket'
#1 urn:iki:ledger:default:item:…
[uncacheable]
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:iki:ledger:items'
#1 open p1 Filed over the socket
1 item(s)
[uncacheable]
A --mount on the command line is the whole topology of that command: ikigai composes it
INSTEAD of the mount lines in your own config home, so nothing you have configured is involved.
The client’s status line says [uncacheable] for a read as well as a write, and that is gonk’s
choice, not the ledger’s. An answer gonk caches hangs from golden threads that live in gonk’s
kernel, and a thread does not cross a wire; an answer that left the socket cacheable would arrive
with nothing that could ever invalidate it, and a long-lived client would go on serving it. So
every answer crosses the socket (and the QUIC door) uncacheable, and gonk keeps its own cache
behind it: a mounted read costs one round trip to gonk’s cache, never a recompute.
priority=1 was routed by name because the cli asked the remote for the resource’s contract
first, over the same socket, and the contract declares priority. That contract is the
agent’s tool description, and it is the ledger’s own:
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'describe urn:gk:iki:ledger:append' | grep -E 'ik:title|ik:inputName'
[computed]
ik:title "File a ledger item" ;
<urn:ikigai:contract:ledger-append:sink:b3:…:input:ledger> ik:inputName "ledger" ;
<urn:ikigai:contract:ledger-append:sink:b3:…:input:content> ik:inputName "content" ;
<urn:ikigai:contract:ledger-append:sink:b3:…:input:kind> ik:inputName "kind" ;
<urn:ikigai:contract:ledger-append:sink:b3:…:input:priority> ik:inputName "priority" ;
<urn:ikigai:contract:ledger-append:sink:b3:…:input:labels> ik:inputName "labels" ;
<urn:ikigai:contract:ledger-append:sink:b3:…:input:about> ik:inputName "about" ;
<urn:ikigai:contract:ledger-append:sink:b3:…:input:revision> ik:inputName "revision" ;
<urn:ikigai:contract:ledger-append:sink:b3:…:input:author> ik:inputName "author" ;
<urn:ikigai:contract:ledger-append:sink:b3:…:input:key> ik:inputName "key" ;
<urn:ikigai:contract:ledger-append:sink:b3:…:input:as> ik:inputName "as" ;
([computed] comes first because the cli writes its status line to standard error, which does
not go through grep.) Each input hangs from the action’s contract, whose IRI ends in a
digest of everything the action declares (b3: and its BLAKE3 hash, since ikigai-core 0.1.91),
so a ledger that changed what append accepts, down to a summary’s wording, would answer a
different IRI; the page writes … where the digest goes. Each input is declared with a summary, whether it is required, and its
type; content is the one a value or a pipe lands in. key is the caller’s own name for the
item: an append whose key is already taken files nothing and answers the item that holds it,
which is how part 5’s sync and the bridges file
each thing once. as=application/json asks for the answer in the ledger’s JSON face. An agent’s tool schema is this graph,
read from the ledger itself, so it cannot drift from what the ledger accepts.
The same names, from the config home
An alias is right for one command. For a host that works in the ledger all day, the banner’s
mount line goes into its config home instead, and then the ledger’s names need no prefix at
all: prefer serves urn:iki:ledger:* from the socket under the names it already has, and
falls back to a local binding only when the socket is unreachable.
$ mkdir -p .config/ikigai
$ printf 'mount = "prefer urn:iki:ledger:=%s/.ikigai/gonk.sock"\n' "$PWD" >> .config/ikigai/config.toml
$ env XDG_CONFIG_HOME="$PWD/.config" ikigai -c 'sink urn:iki:ledger:append Filed by its own name' -c 'source urn:iki:ledger:next'
#2 urn:iki:ledger:default:item:…
[uncacheable]
1. #1 open p1 Filed over the socket
p1 — priority 1; last updated …
2. #2 open p- Filed by its own name
unprioritized — no priority set, so it ranks below every item that has one; last updated …
policy: priority-recency (weighs priority, recency, number)
ready: 2 excluded: 0
[uncacheable]
— batch: 2 commands · 2 uncacheable
Here the config home IS the lesson, so the page names its own: XDG_CONFIG_HOME is where
ikigai looks for it. Since ikigai-cli 0.1.43 the flag --config-home <dir> says it too,
and wins over the variable; the page keeps the variable, which every release reads. Without
one or the other the command reads yours.
A leading ~ in the mount line is your home. Since ikigai-cli 0.1.58 the line
mount = "prefer urn:iki:ledger:=~/.ikigai/gonk.sock" dials the socket under the home of whoever
runs ikigai, by the same rule as every other path in the config home (~name is refused by
name); before it the ~ was dialed as written and failed with No such file or directory. The
line above is written with printf and $PWD only because this page’s gonk is not in a home:
its socket is in the page’s scratch directory.
A mount claims one prefix, so a host that also wants the store’s resources adds the same line
for urn:iki:store:, as the banner says. The two are one line each because a mount is a
string prefix, and nothing about urn:iki:ledger: covers urn:iki:store:.
Whose authority
The socket’s caller is the owner, and the owner holds everything gonk serves. Delete included:
$ env XDG_CONFIG_HOME="$PWD/.config" ikigai -c 'delete urn:iki:ledger:item:2'
deleted #2 urn:iki:ledger:default:item:…
6 quad(s) moved to <urn:iki:ledger:graph:default:deleted> and recoverable
tombstone: urn:iki:ledger:default:tombstone:…
[uncacheable]
That is the right authority for you, at your own terminal, and the wrong one to hand an agent.
An agent should hold what its work needs and nothing more, and a capability can only be
narrowed as it travels: the host narrows its own before it acts, and what crosses the
socket is the narrowed one. ikigai-gonk grants prints the tokens for one ledger at one
authority, which is exactly the list to narrow to:
$ ikigai-gonk grants default write
[
"urn:cap:ledger:read:default",
"urn:cap:store:read:graph:urn:iki:ledger:graph:default",
"urn:cap:ledger:write:default",
"urn:cap:store:write:graph:urn:iki:ledger:graph:default"
]
cap seal narrows the session to those scopes and makes that the floor, which nothing later in
the session can rise above. Then the agent’s work, and two things it was not given (what the
seal does and does not keep it from beyond these is below):
$ env XDG_CONFIG_HOME="$PWD/.config" ikigai -c 'cap seal urn:cap:ledger:read:default urn:cap:store:read:graph:urn:iki:ledger:graph:default urn:cap:ledger:write:default urn:cap:store:write:graph:urn:iki:ledger:graph:default' -c 'sink urn:iki:ledger:append author=agent-7 Filed under a narrowed grant' -c 'delete urn:iki:ledger:item:1' -c 'source urn:iki:ledger:book:items'
sealed — capability: urn:cap:ledger:read:default, urn:cap:ledger:write:default, urn:cap:store:read:graph:urn:iki:ledger:graph:default, urn:cap:store:write:graph:urn:iki:ledger:graph:default · sealed (this is the floor: `cap reset`, `logout` and `login` cannot rise above it)
#3 urn:iki:ledger:default:item:…
[uncacheable]
error: denied: capability does not grant `urn:cap:ledger:delete:*` (declared by `urn:iki:ledger:item:1`; any grant under `urn:cap:ledger:delete:` satisfies it)
error: denied: this capability does not hold `urn:cap:ledger:read:book`. A ledger grant names exactly one ledger — holding a grant over another ledger satisfies the declared family `urn:cap:ledger:read:*` but not this resource, which is the point of naming them
— batch: 4 commands · uncacheable
The filing worked; the delete and the other ledger did not, with the token each one needed
named in the refusal. The first is refused at the family, and says what satisfies it: any grant
under urn:cap:ledger:delete:, none of which the seal kept. author=agent-7 is the ledger’s own
argument for who filed an item. It is a claim the caller makes, not an identity the socket proves,
which is the next section.
What the door saw
The socket door writes the same access lines as the HTTP door, and they say what the narrowing did not change:
$ grep 'door=socket verb=sink' gonk.log
… gonk:Access urn:iki:ledger:append door=socket verb=sink outcome=ok bytes=… dur=… principal=owner q=-
… gonk:Access urn:iki:ledger:append door=socket verb=sink outcome=ok bytes=… dur=… principal=owner q=-
… gonk:Access urn:iki:ledger:append door=socket verb=sink outcome=ok bytes=… dur=… principal=owner q=-
$ grep ' urn:iki:ledger:book:items ' gonk.log
… gonk:Access urn:iki:ledger:book:items door=socket verb=source outcome=denied bytes=- dur=… principal=owner q=-
- Every request over the socket is
principal=owner. The socket knows that whoever connected could open an owner-only file, and nothing more: it cannot tell your terminal from an agent you started. An agent that should be attributed as itself in the log needs an identity of its own, which is a certificate on the QUIC door (the next chapter, and in full the Several machines part) rather than this socket. - The read of
bookreached gonk and was refused there, so it has a line. The delete has none: the declared familyurn:cap:ledger:delete:*was not held, so it was refused before it was dispatched to the ledger at all.
What the seal does not bound
Look at the fourth token again: urn:cap:store:write:graph:urn:iki:ledger:graph:default. The
ledger needs it, because it writes every append and close as a store update under the
caller’s capability (Whose ledger). But it is the
store’s token, so on its own it would also let the holder write the ledger’s graph directly,
through the store, without the ledger seeing it, and that is how an item could be given an
author naming somebody else. So gonk refuses a raw store write to a ledger graph, at every
door, unless the caller is root or holds a separate raw grant for that graph. Mount the store’s
names as well, as the banner suggested, and try it from a sealed session; then once more at
root:
$ printf 'mount = "prefer urn:iki:store:=%s/.ikigai/gonk.sock"\n' "$PWD" >> .config/ikigai/config.toml
$ env XDG_CONFIG_HOME="$PWD/.config" ikigai -c 'cap seal urn:cap:ledger:read:default urn:cap:store:read:graph:urn:iki:ledger:graph:default urn:cap:ledger:write:default urn:cap:store:write:graph:urn:iki:ledger:graph:default' -c 'sink urn:iki:store:graph-update graph=urn:iki:ledger:graph:default INSERT DATA { GRAPH <urn:iki:ledger:graph:default> { <urn:example:note> <urn:example:says> "written raw" } }'
sealed — capability: urn:cap:ledger:read:default, urn:cap:ledger:write:default, urn:cap:store:read:graph:urn:iki:ledger:graph:default, urn:cap:store:write:graph:urn:iki:ledger:graph:default · sealed (this is the floor: `cap reset`, `logout` and `login` cannot rise above it)
error: denied: a raw store write to the ledger graph <urn:iki:ledger:graph:default> is refused at this door. A ledger grant carries that graph's store token for the ledger's OWN writes, which it issues itself; it is not authority to write the graph's triples directly — that is how an `author` naming someone else would get in. Write through the ledger's endpoints (`urn:iki:ledger:*`). Raw writes are the owner's, at root over the socket, or an identity's whose grant an operator minted with `--ledger-graph <ledger>` (`urn:cap:gonk:raw-write:graph:urn:iki:ledger:graph:default`)
$ env XDG_CONFIG_HOME="$PWD/.config" ikigai -c 'sink urn:iki:store:graph-update graph=urn:iki:ledger:graph:default INSERT DATA { GRAPH <urn:iki:ledger:graph:default> { <urn:example:note> <urn:example:says> "written raw" } }'
updated <urn:iki:ledger:graph:default>: +1 -0 quads
[uncacheable]
The owner at root still writes raw over the socket, which is where migrations and repairs run;
a session that sealed itself to a ledger grant is held to the same rule as a network client.
(That is new at the gonk this book pins: through gonk ed43af3 the socket checked nothing, and
the sealed write above succeeded. The QUIC chapter
shows how an operator gives a raw grant by name, --ledger-graph.)
What the seal does not bound is the author argument on the session’s own new items. The
network doors refuse a write whose author is shaped like a principal and is not the caller’s
own; but the socket names no principal (every request is owner), so it cannot run that rule,
and a sealed session may still file an item that claims to be from a passkey:
$ env XDG_CONFIG_HOME="$PWD/.config" ikigai -c 'cap seal urn:cap:ledger:read:default urn:cap:store:read:graph:urn:iki:ledger:graph:default urn:cap:ledger:write:default urn:cap:store:write:graph:urn:iki:ledger:graph:default' -c 'sink urn:iki:ledger:append author=urn:iki:gonk:passkey:ada Not really Ada'
sealed — capability: urn:cap:ledger:read:default, urn:cap:ledger:write:default, urn:cap:store:read:graph:urn:iki:ledger:graph:default, urn:cap:store:write:graph:urn:iki:ledger:graph:default · sealed (this is the floor: `cap reset`, `logout` and `login` cannot rise above it)
#4 urn:iki:ledger:default:item:…
[uncacheable]
— batch: 2 commands · uncacheable
It is the same kind of claim as author=agent-7, on an item the session filed itself, never a
change to someone else’s; but gonk’s item page renders a passkey-shaped author as that
passkey’s label, so it would read as Ada’s. gonk’s README states this residual rather than
hiding it. An agent that must not be able to claim a person needs an identity of its own on the
QUIC door, where the author rule runs (The three doors).
Asking in SPARQL
The browser’s /sparql has a twin on the socket. gonk binds
urn:sparql:select (and :ask, :construct and :describe) over the store it holds, so under
the alias it is urn:gk:sparql:select. Naming no graph, a query’s dataset is the union of
every named graph the caller may read. At root over the socket that is every graph gonk holds:
here the ledger’s, and the one the delete above moved item 2’s six quads into:
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:sparql:select as=text/csv query="SELECT ?g (COUNT(*) AS ?n) WHERE { GRAPH ?g { ?s ?p ?o } } GROUP BY ?g ORDER BY ?g"'
g,n
urn:iki:ledger:graph:default,…
urn:iki:ledger:graph:default:deleted,6
[uncacheable]
Sealed to a read of one ledger, the same query’s union is that ledger’s graph alone. graph=
narrows the dataset to the graphs it names, and a graph the seal holds no token for refuses the
whole query rather than answering over the part it can see. The ledger’s deleted graph and a
graph that does not exist are refused in the same words, so a refusal says nothing about whether
a graph is there:
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'cap seal urn:cap:ledger:read:default urn:cap:store:read:graph:urn:iki:ledger:graph:default' -c 'source urn:gk:sparql:select as=text/csv query="SELECT ?g (COUNT(*) AS ?n) WHERE { GRAPH ?g { ?s ?p ?o } } GROUP BY ?g ORDER BY ?g"' -c 'source urn:gk:sparql:select as=text/csv graph=urn:iki:ledger:graph:default:deleted query="SELECT * WHERE { ?s ?p ?o }"' -c 'source urn:gk:sparql:select as=text/csv graph=urn:iki:ledger:graph:nothing-here query="SELECT * WHERE { ?s ?p ?o }"'
sealed — capability: urn:cap:ledger:read:default, urn:cap:store:read:graph:urn:iki:ledger:graph:default · sealed (this is the floor: `cap reset`, `logout` and `login` cannot rise above it)
g,n
urn:iki:ledger:graph:default,…
[uncacheable]
error: denied: reading <urn:iki:ledger:graph:default:deleted> through `urn:iki:store:graph-select` needs the grant `urn:cap:store:read:graph:urn:iki:ledger:graph:default:deleted`, which this capability does not hold. Every graph a scoped read names must be granted: the read is refused rather than answered over the graphs that are, because a narrower dataset than the one asked for returns rows that look right and are not. A grant names exactly one graph; holding `urn:cap:store:read` does not imply it, and is instead the authority for `urn:iki:store:select` over the whole dataset.
error: denied: reading <urn:iki:ledger:graph:nothing-here> through `urn:iki:store:graph-select` needs the grant `urn:cap:store:read:graph:urn:iki:ledger:graph:nothing-here`, which this capability does not hold. Every graph a scoped read names must be granted: the read is refused rather than answered over the graphs that are, because a narrower dataset than the one asked for returns rows that look right and are not. A grant names exactly one graph; holding `urn:cap:store:read` does not imply it, and is instead the authority for `urn:iki:store:select` over the whole dataset.
— batch: 4 commands · uncacheable
Each form is one hop onto the store’s urn:iki:store:graph-select under the caller’s own
capability, which is why the refusal is the store’s: SPARQL over the socket is a mapping onto the
grant, not a second way around it. The same query text answered less under the seal, and that is
the point of a default dataset drawn from the grant: the query never has to know whose it is. A
host that wants the query face without a prefix adds one more line to its config
home, mount = "prefer urn:sparql:=…/gonk.sock", the same shape as the ledger’s.
Two teams, one gonk asks one query as two teams’
agents, a manager over both and a signed-out browser, and gets four answers.
Over MCP
An agent framework that speaks MCP rather than ikigai reaches the same ledger through
ikigai mcp, which projects a kernel’s capability-scoped manifold as MCP tools. It could do
that over this socket with a sealed grant like the one above, and the tool list would be the
ledger’s actions under that grant; but every request would still be the owner’s, with the
residual shown above. The next chapter gives the agent an identity of its own on the
QUIC door instead, so the author rule runs and the log names the agent.
What you have now
| how it reaches gonk | under what authority | who the log says | |
|---|---|---|---|
| a browser | HTTP, loopback | anonymous, or a passkey’s grant | anon, or the passkey |
curl, a hook | HTTP, loopback | anonymous | anon |
ikigai on this machine | the socket | the owner’s, narrowed with cap seal | owner |
The agent’s tool calls were the ledger’s resources by their own names: the same append,
next and delete that the page’s kernel ran in the first chapter,
with the same contracts, now crossing a process boundary. What changed was only where the
ledger lived and whose authority reached it.
Over MCP
An agent framework that speaks the Model Context Protocol
rather than ikigai reaches the same ledger through ikigai mcp. It projects a kernel’s
capability-scoped manifold as MCP tools: one tool per action the session may invoke, with
the arguments that action declares. Nothing is written for the agent. The tool list is the
ledger’s own Descriptions, filtered by a capability, so it cannot offer what the session
may not do.
The previous chapter ended on what the socket cannot bound: every request on it is
the owner’s, so a sealed session can still file an item whose author claims a passkey. An
agent that must not be able to claim a person needs an identity of its own, on the door where
the author rule runs. That is the QUIC door from A QUIC client,
and this chapter puts the two together: the agent gets a client certificate and a grant on the
server, and ikigai mcp reaches gonk through that door. Three things to see at the end, each
on a transcript below:
- the agent’s tool list is the ledger’s writer actions for one ledger, and nothing else;
- a retried filing files once, because the append is keyed;
- the server names the agent, in the item’s
authorand on every access line.
Every command here runs ikigai at the version this book pins, which speaks MCP over standard
input and output.
The server: an identity for the agent
In a new empty directory, as in every chapter: if the gonk from Over the socket is still running, stop it first, since this one takes the same port. This chapter’s gonk starts only after the agent’s certificate is minted, because it reads the certificates it trusts at startup.
As in A QUIC client, the server mints the agent a certificate and
enrolls it under a grant built from a role. The agent may write the default ledger:
$ mkdir -p .config/ikigai
$ ikigai-gonk client add agent --ledger default=write --port 1070 --config-home "$PWD/.config/ikigai"
client `agent` …/.config/ikigai/gonk/quic/clients/agent
fingerprint …
principal urn:iki:host:gonk:client:…
enrolled grant `agent` (4 scopes) in …/.config/ikigai/gonk/grants.json
restart ikigai-gonk: trusted certificates are read at startup
this bundle holds the client's PRIVATE key and the server never reads it — move the directory to the client, then from the client:
ikigai --connect quic://<gonk host>:1070 --cert-dir <the moved directory>
Four scopes: the ledger’s writer, read and write on default, and the store’s tokens for that
ledger’s graph, which the ledger uses for its own writes. Not delete, not purge, and no other
ledger. gonk enforces this list on every request from that certificate, whatever the client
asks for. Start the server, which reads the certificates it trusts at startup:
$ ikigai-gonk --config-home "$PWD/.config/ikigai" --data-home "$PWD/.ikigai" --port 1070 --no-backup 2>&1 | tee gonk.log
ikigai-gonk 0.1.0 — holding the store at …/.ikigai/store
…
quic udp 0.0.0.0:1070 — 1 trusted certificate(s), 1 enrolled
…
The agent’s side: a bundle and a ceiling
The agent runs on “another machine”, the directory agent/, with its own config home. It gets
the bundle (on a real second machine, moved there and deleted here):
$ mkdir -p agent/.config/ikigai
$ cp -R .config/ikigai/gonk/quic/clients/agent agent/gonk
and a grant of its own, in its own config home’s grants.json. This is the client-side
ceiling: ikigai mcp --grant agent serves the manifold under exactly these scopes, so the
tools of everything else the agent’s ikigai could reach (its files, its secrets, a model) are
filtered out of the list by capability. show narrows what is listed further, to the ledger’s
tools; it changes what the agent sees, not what it may call. The scopes are the server’s four,
which ikigai-gonk grants default write prints:
{
"agent": {
"scopes": [
"urn:cap:ledger:read:default",
"urn:cap:store:read:graph:urn:iki:ledger:graph:default",
"urn:cap:ledger:write:default",
"urn:cap:store:write:graph:urn:iki:ledger:graph:default"
],
"show": ["ledger-*"]
}
}
There are two ceilings, then, and they are independent. The server’s grant is the one that holds against a compromised agent, because the agent’s machine cannot change it. The client’s is the one that keeps the tool list honest, so an agent is not offered a tool that would only be refused.
The tool list
An MCP client starts the server as a child process and talks JSON-RPC to it, one message per line. Here the messages are a file, so the transcript is repeatable: the handshake, then the request for the tool list.
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"book","version":"1"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/list"}
The command is the one an agent framework is configured with. --override mounts gonk’s QUIC
door at urn:iki:ledger:, so every ledger name goes there and nowhere else; --cert-dir after
it is that mount’s identity; --grant is the ceiling above. The page points
XDG_CONFIG_HOME at the agent’s directory to give it a config home of its own
(--config-home agent/.config/ikigai says the same since ikigai-cli 0.1.43). The tool names are
picked out of the answer and sorted (byte order, so the list reads the same on every machine), because the full answer carries every argument’s
description:
$ env XDG_CONFIG_HOME="$PWD/agent/.config" ikigai mcp --override urn:iki:ledger:=quic://127.0.0.1:1070 --cert-dir agent/gonk --grant agent < agent/list.jsonl | grep -o '"name":"[^"]*__[^"]*"' | LC_ALL=C sort
ikigai mcp: serving the manifold under 4 scope(s)
ikigai mcp: tool visibility — 1 shown, 0 hidden pattern(s)
ikigai mcp: mount override urn:iki:ledger: -> quic://127.0.0.1:1070 [certs agent/gonk]
"name":"ledger-append__sink"
"name":"ledger-claim__delete"
"name":"ledger-claim__sink"
"name":"ledger-close__sink"
"name":"ledger-comment__sink"
"name":"ledger-count__source"
"name":"ledger-defer__delete"
"name":"ledger-defer__sink"
"name":"ledger-doctor__source"
"name":"ledger-item-about__delete"
"name":"ledger-item-about__sink"
"name":"ledger-item-closed__exists"
"name":"ledger-item-holder-is__exists"
"name":"ledger-item-state-is__exists"
"name":"ledger-item-state__sink"
"name":"ledger-item-state__source"
"name":"ledger-item__exists"
"name":"ledger-item__sink"
"name":"ledger-item__source"
"name":"ledger-items__source"
"name":"ledger-label__delete"
"name":"ledger-label__sink"
"name":"ledger-ledgers__source"
"name":"ledger-lifecycle__exists"
"name":"ledger-lifecycle__source"
"name":"ledger-link__delete"
"name":"ledger-link__sink"
"name":"ledger-next__source"
"name":"ledger-policy__exists"
"name":"ledger-policy__source"
"name":"ledger-reopen__sink"
Thirty-one tools, each an endpoint and a verb: ledger-append__sink is a sink to
urn:iki:ledger:append, ledger-next__source a source of urn:iki:ledger:next. Eight of
them arrived with ikigai-ledger 0.5.0 and are not used in this book yet: an item’s lifecycle
state (ledger-item-state__*, a compare-and-set, so of two agents moving one item out of the
same state exactly one wins), the assertions an agent can check before it acts
(ledger-item-state-is__exists, -holder-is, -closed), the lifecycles themselves as
resources, and the doctor, a read-only report of orphaned, abandoned and lease-expired work.
Two more arrived with 0.6.2: ledger-item-about__sink and __delete, which add and remove what an
item is about (a file, a PR, the session that delivered it) without touching its title or body.
And one with 0.9.0: ledger-count__source, the number of items a filter admits, without the
listing (the figure part 5 asks for).
A __delete tool is here only where the ledger’s writer may delete (a label, a claim, a link, a
deferral, an item’s about).
ledger-item__delete is not: deleting an item takes the ledger’s delete token, and purging
takes another, and this grant holds neither.
Every tool that names a ledger takes it as an argument, ledger, with the default default
(the ledger whose name the IRI may omit, as in Whose ledger).
An agent that leaves it out writes to default, which is the ledger it was granted. An agent
granted a named ledger passes ledger every time, and two such agents are offered the same
tools (Two teams, one gonk compares them).
Filing, once
The agent files a task with its own name for it, key=agent-task-1, the way a hook files a
finding by the finding’s id. Then it files the same thing again, as an agent does when it is
not sure the first call landed. Then it reads the item back by that key, and finally it tries
three things outside its grant: an author naming a person’s passkey, the item’s delete tool,
and another ledger:
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"book","version":"1"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"ledger-append__sink","arguments":{"content":"Read the notes","key":"agent-task-1"}}}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ledger-append__sink","arguments":{"content":"Read the notes","key":"agent-task-1"}}}
{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"ledger-item__source","arguments":{"id":"key:agent-task-1"}}}
{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"ledger-append__sink","arguments":{"content":"Not really Ada","author":"urn:iki:gonk:passkey:ada"}}}
{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"ledger-item__delete","arguments":{"id":"1"}}}
{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"ledger-append__sink","arguments":{"ledger":"acme","content":"Not mine"}}}
$ env XDG_CONFIG_HOME="$PWD/agent/.config" ikigai mcp --override urn:iki:ledger:=quic://127.0.0.1:1070 --cert-dir agent/gonk --grant agent < agent/calls.jsonl 2>/dev/null
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{"listChanged":true}},"serverInfo":{"name":"ikigai","version":"0.1.61"}}}
{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"#1 urn:iki:ledger:default:item:…\n"}],"isError":false}}
{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"#1 urn:iki:ledger:default:item:… existing open\n"}],"isError":false}}
{"jsonrpc":"2.0","id":4,"result":{"content":[{"type":"text","text":" #1 open p- Read the notes\n iri: urn:iki:ledger:default:item:…\n key: agent-task-1\n filed: … by urn:iki:host:gonk:client:…\n updated: …\n"}],"isError":false}}
{"jsonrpc":"2.0","id":5,"result":{"content":[{"type":"text","text":"denied: `author` names a principal (urn:iki:gonk:passkey:ada), and only the door names one: a write may name its own principal or a plain-text author, never another identity. This request is from urn:iki:host:gonk:client:…."}],"isError":true}}
{"jsonrpc":"2.0","id":6,"result":{"content":[{"type":"text","text":"tool `ledger-item__delete` is not available under this capability"}],"isError":true}}
{"jsonrpc":"2.0","id":7,"result":{"content":[{"type":"text","text":"denied: this capability does not hold `urn:cap:ledger:write:acme`. A ledger grant names exactly one ledger — holding a grant over another ledger satisfies the declared family `urn:cap:ledger:write:*` but not this resource, which is the point of naming them"}],"isError":true}}
Read the answers in order:
- 2 and 3: one item. The second append found the key taken and filed nothing; it answered
with the item that holds it, marked
existing. The check and the filing are one store update in the ledger, so this holds for concurrent retries as well as sequential ones. An agent that keys what it files can retry freely. - 4: the author is the agent. The item was filed with no
authorargument, and gonk filled it with the name of the identity that asked,urn:iki:host:gonk:client:<fingerprint>, from the certificate the connection was made with. - 5: and only the agent. On the QUIC door an
authorthat names a principal must be the caller’s own, so the agent cannot file as Ada. This is the residual the socket chapter ended on, closed by giving the agent its own door. - 6: never sent. The item’s delete is not in this session’s manifold, so
ikigai mcprefuses the call itself; gonk never sees it. - 7: sent, and refused by the ledger. The client’s ceiling holds the write family (for
default), so the call passes the local check and crosses the wire; the ledger refuses it, because a ledger grant names one ledger. A tool that takesledgeras an argument can be asked about any ledger; only a granted one answers.
What the server saw
$ grep -E 'quic client| door=quic ' gonk.log
ikigai-gonk: quic client … → grant "agent"
ikigai-gonk: quic client … → grant "agent"
… gonk:Access urn:iki:ledger:default:append door=quic verb=sink outcome=ok bytes=… dur=… principal=urn:iki:host:gonk:client:… q=-
… gonk:Access urn:iki:ledger:default:append door=quic verb=sink outcome=ok bytes=… dur=… principal=urn:iki:host:gonk:client:… q=-
… gonk:Access urn:iki:ledger:default:item:key:agent-task-1 door=quic verb=source outcome=ok bytes=… dur=… principal=urn:iki:host:gonk:client:… q=-
… gonk:Access urn:iki:ledger:default:append door=quic verb=sink outcome=denied bytes=… dur=… principal=urn:iki:host:gonk:client:… q=-
… gonk:Access urn:iki:ledger:acme:append door=quic verb=sink outcome=denied bytes=… dur=… principal=urn:iki:host:gonk:client:… q=-
One connection line per ikigai mcp process, naming the grant. One access line per request
that reached gonk, each naming the agent, refusals included. Two things in the names are worth
reading. Every append is logged as urn:iki:ledger:default:append: the tool call carried no
ledger, the argument’s default filled it, and the request went out under the ledger’s full
name. And the delete is not in the log at all, because it never left the agent’s machine.
Wiring it into an agent
An MCP client is configured with a command to start, and this is the command. What the configuration file looks like is the framework’s own; the part that is ikigai’s is the line below, with the server’s address and the bundle where the agent’s machine keeps it:
$ ikigai mcp --override urn:iki:ledger:=quic://server.local:1062 --cert-dir ~/gonk-agent --grant agent
Mount flags go after mcp, and they replace the mount lines of the agent’s own config home
for this process, so nothing else the agent’s machine has configured is composed in. A gonk on
the default port opens its QUIC door on UDP 1062.
What you have now
| how it reaches gonk | under what authority | who the log says | |
|---|---|---|---|
| a browser | HTTP, loopback | anonymous, or a passkey’s grant | anon, or the passkey |
curl, a hook | HTTP, loopback | anonymous | anon |
ikigai on this machine | the socket | the owner’s, narrowed with cap seal | owner |
| an agent over MCP | QUIC, with its own certificate | its client’s grant on the server, and its own ceiling on the client | the agent’s urn:iki:host:gonk:client:… |
The agent’s tool calls were the ledger’s resources by their own names, the same append, item
and next that the page’s kernel ran in the first chapter, with the same
contracts. MCP added a way to list them; what made the list safe to hand an agent was the
capability it was computed under, and what made the agent answerable was the door it came in by.
Browse roots: a repository as resources
A ledger item about a file is only as useful as the reader’s way to the file. gonk can serve
the repositories you work in beside the ledger, as resources under urn:repo:{root}:*: a
directory’s listing, a file’s bytes, the working tree’s state, a content hash. They come from
ikigai-browse, and gonk adds the one thing a
library cannot: it watches each repository, so the reads can be cached and still never be
stale.
This page makes a repository of its own, serves it, reads it, and changes it while gonk runs. In a new empty directory, as in every chapter: if the gonk from Over MCP is still running, stop it first, since this one takes the same port.
A repository, and a root
Any git working tree will do. This one is made here, with a commit identity set on the repository alone so that nothing outside it is touched:
$ git init -q -b main notes
$ printf '# Notes\n\nA repository this book creates.\n' > notes/README.md
$ git -C notes config user.name 'A Reader'
$ git -C notes config user.email reader@example.com
$ git -C notes add README.md
$ git -C notes commit -qm 'Start the notes'
A browse root gives a directory a name, and the name is the {root} in every resource
gonk serves for it. Roots live in the config home’s config.toml, one line each, as
name=path. A path may start with ~, which is the home directory of whoever runs gonk; this
page’s root is not in yours, so it writes the path out in full:
$ mkdir -p .config/ikigai
$ printf 'gonk.browse.root = "notes=%s/notes"\n' "$PWD" > .config/ikigai/config.toml
$ cat .config/ikigai/config.toml
gonk.browse.root = "notes=…/notes"
--browse-root notes=PATH on the command line does the same for one run. gonk reads its roots
when it starts, and refuses to start if one names a directory that is not there: a missing
root is the shape of a typo, not something to serve as empty.
$ ikigai-gonk --config-home "$PWD/.config/ikigai" --data-home "$PWD/.ikigai" --port 1070 --no-quic --no-backup
ikigai-gonk 0.1.0 — holding the store at …/.ikigai/store
http http://localhost:1070/ — loopback (127.0.0.1:1070); anonymous read+write: default; 0 passkey(s); anonymous SPARQL budget 1000 ms
browse urn:repo:{notes (watched)}:* — annotations and archive in <urn:iki:browse:graph:default>
…
The browse line names each root and whether its watcher started, and says where the browse
family keeps what it writes (annotations, archived explanations): one graph of the same store
the ledger lives in, so a SPARQL query can join a ledger item to the file it is about.
Reading it
The repository is reached the way the ledger was in Over the socket:
another ikigai process mounts gonk’s socket. With the alias at urn:gk:, the root’s listing
is urn:gk:repo:notes:tree:
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:repo:notes:tree'
README.md file 41
[uncacheable]
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:repo:notes:file:README.md'
# Notes
A repository this book creates.
[uncacheable]
A listing line is name, kind and size, tab-separated. The repository’s .git directory
is not in it, and a name under it answers NotFound whatever the grant: since ikigai-browse
0.18.2 (in gonk since gonk PR 123) a root never serves its own git internals, whose config can
carry a remote’s credentials. Before that the listing began with a .git line, and an ordinary
read grant could read .git/config. The file resource answers the
file’s own bytes, under the media type its extension maps to. Two more resources answer the
questions a reader of a repository keeps asking, is this what I think it is and has
anything changed:
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:repo:notes:hash:README.md'
sha256:6140d9c09fbe92774e6cf987758c63e9b4cba8638ee23b6c6c66999c6274de72
[uncacheable]
$ shasum -a 256 notes/README.md
6140d9c09fbe92774e6cf987758c63e9b4cba8638ee23b6c6c66999c6274de72 notes/README.md
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:repo:notes:state'
… clean
[uncacheable]
hash is the sha256 of the file’s bytes, so it is the same number shasum gives; on a
directory it is a Merkle hash over the entries, so one edit changes exactly the hashes on the
path from that file to the root. state is the commit HEAD names and whether the working
tree is clean or dirty:{n}. Each of these is a name you can put in a ledger item’s about,
which is how browse’s file page and a ledger item find each other.
Every listing has a graph face too, with stable IRIs and no blank nodes, so it can be queried or joined:
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:repo:notes:tree as=text/turtle'
@prefix ik: <https://ikigai-rs.dev/ns#> .
<urn:repo:notes:tree> a ik:Directory ;
ik:repo "notes" ;
ik:entry <urn:repo:notes:file:README.md> .
<urn:repo:notes:file:README.md> a ik:File ;
ik:fileName "README.md" ;
ik:path "README.md" ;
ik:byteSize 41 .
[uncacheable]
Changing it while gonk runs
Commit a change, without touching gonk:
$ printf 'Read through gonk, without a restart.\n' >> notes/README.md
$ git -C notes commit -qam 'Say how it is read'
$ sleep 1
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:repo:notes:file:README.md' -c 'source urn:gk:repo:notes:hash:README.md'
# Notes
A repository this book creates.
Read through gonk, without a restart.
[uncacheable]
sha256:…
[uncacheable]
— batch: 2 commands · 2 uncacheable
The new content, at once, from a server that was never restarted. Here is what made that both fast and right:
ikigai-browsedeclares its reads live and uncacheable. That is the only honest thing a library can say: caching is a promise that something will notice when the file changes, and a library cannot know whether its host is watching.- gonk is watching. It watches every root (FSEvents on macOS, inotify on Linux) and, when a
file changes, cuts the golden thread the root’s reads hang from. So for a watched root,
and only for one, it declares the plain reads cacheable: a read of an unchanged file is a
cache hit, and the first read after a change recomputes. That is what
(watched)on the banner promised. A root whose watcher cannot start is named as unwatched there, and its reads are served live, never cached and unwatched. - The cache is gonk’s, behind the socket. The
[uncacheable]this client prints is about its own cache: golden threads live in one kernel and do not cross a wire, so an answer that left gonk cacheable would arrive with nothing that could ever cut it, and a long-lived client (an agent’sikigai mcp, say) would go on serving the old file. So gonk sends every answer across the socket and the QUIC door uncacheable, and keeps the cache entry, and the thread it hangs from, on its own side: a repeated read is one round trip to gonk’s cache, never a recompute. - The
sleep 1is honest, not decoration. The notification arrives with the platform’s own latency, well under a second, so a read issued in the same instant as the write can still see the previous version once.
The watch ignores what git ignores: a change inside .git/ (apart from the files git status
reads) or inside a target/ directory cuts nothing, so a build does not invalidate a root a
thousand times a minute. The Turtle face above said [uncacheable] for a related reason: only
the plain read with no arguments is declared cacheable, because the other faces read state the
watch does not track.
In a browser, and from your own config
The browser’s way in is /browse, the roots the caller’s grant may read. An anonymous caller
may read none, because the anonymous grant is ledger tokens only, so what it gets is the
sign-in notice:
$ curl -s http://localhost:1070/browse | grep -o 'Sign in to browse the repositories.'
Sign in to browse the repositories.
A passkey invited with --browse read, as in the browser chapter,
reads every root; --browse read --root notes reads only this one. The pages are browse’s own
HTML faces (crumbs, listings, a highlighted file view with #L{n} anchors and the annotation
margin) inside gonk’s header.
For an ikigai host that should see the roots under their own names, as the ledger was in Over the
socket, the mount line is the same shape
with a different prefix (~ is the home of whoever runs ikigai, as there):
mount = "prefer urn:repo:=~/.ikigai/gonk.sock"
⚠ urn:repo: is one prefix over two families: the browse roots (urn:repo:{root}:…) and
ikigai-repo’s git and GitHub facades (urn:repo:status, urn:repo:log, urn:repo:pr:list),
which then run in gonk’s process. A prefix mount cannot say “the roots remote and the facades
local”.
What is next for the browser
A root is also the place an explanation is about: browse can derive one per file or directory from a model gonk mounts, archived once per content version, so the hash above is the key that says when one is stale. That needs a model, and is the next chapter, Explain, with a mounted model.
In resource terms: the atoms are the files on disk, which git and you change and gonk
does not; tree, file, hash and state are views of them; and the watcher is what
turns a change to an atom into a cut thread, so every view derived from it recomputes on its
next read and no sooner.
Explain, with a mounted model
The previous chapter served a repository as resources: listings, bytes, hashes,
state. Every one of them is computed from the files. This chapter adds the first resource a
model writes: urn:repo:{root}:explain:{path}, a few sentences saying what a file is for,
derived once per version of the file and kept.
Two things make that more than “call an LLM”. The answer is archived, keyed by the file’s
content hash and a version tag, so the second reader of the same bytes gets it from the store
with no model call. And gonk does not link a model at all: it mounts one, at urn:llm:,
from another process. To browse, a kernel that binds urn:llm: to anything that answers is a
kernel with a model, which is what lets this chapter show every rule of the archive with a stub
that costs nothing and says the same thing every time.
A model is two names
browse asks a model through a provider, urn:llm:{name}:ask, with the prompt as an
argument, and reads which model that is from urn:llm:{name}:model. So a stand-in is a space
binding those two names. This one, from
crates/browse-host,
answers an explanation or a review (the next chapter’s) depending on what it is asked, and
counts every ask:
/// The stub model's provider: browse asks `urn:llm:{name}:ask` and reads the model's identity
/// at `urn:llm:{name}:model`, so a model is two names, and this crate binds both.
pub const PROVIDER: &str = "urn:llm:stub:ask";
/// What the stub says its model is. It becomes the `@…` half of every version tag.
pub const MODEL: &str = "stub-1";
/// The stub's explanation, whatever it is asked to explain.
pub const EXPLANATION: &str = "Defines `initials`, which joins the first letter of each \
space-separated word of a name into one string.";
/// The stub's review, in the grammar browse parses: three lines per finding, and a note that
/// ends in a period (without one, the last finding reads as cut off and is dropped).
pub const REVIEW: &str = "\
QUOTE: name.split(' ').map(|word| &word[..1]).collect()
SEVERITY: major
NOTE: Slicing the first byte panics on an empty word, so a name with two spaces in a row, or an empty name, crashes the caller.
QUOTE: pub fn initials(name: &str) -> String {
SEVERITY: minor
NOTE: Returning a String allocates on every call; return a &str instead.
";
/// A model that is not a model: a space binding [`PROVIDER`] and its `:model` name, answering
/// [`REVIEW`] when browse asks for a review and [`EXPLANATION`] otherwise, and counting every
/// ask in `calls` — which is how a test can say "no model call" and mean it.
pub fn stub_model(calls: Arc<AtomicUsize>) -> EndpointSpace {
let ask = FnEndpoint::new("stub-model", move |inv| {
calls.fetch_add(1, Ordering::SeqCst);
// Browse's two kinds of ask differ in their system prompt; a review's says so.
let system = inv.inline_str("system").unwrap_or("");
let answer = if system.contains("reviewing") {
REVIEW
} else {
EXPLANATION
};
Ok(text(answer))
})
.with_description(
// The capability a real `urn:llm:` module declares: asking a model is network use.
Description::new("stub-model")
.verb(Verb::Source)
.requires("urn:cap:net:*")
.output("text/plain"),
);
let model = FnEndpoint::new("stub-model-id", |_| Ok(text(MODEL))).with_description(
Description::new("stub-model-id")
.verb(Verb::Source)
.output("text/plain"),
);
EndpointSpace::new()
.bind(Exact::new(PROVIDER), ask)
.bind(Exact::new("urn:llm:stub:model"), model)
}
It declares urn:cap:net:*, as the real urn:llm: module does: asking a model is network use,
and the capability is how a host decides who may spend it. The host puts browse beside it:
/// The host: browse over one root, `demo`, with its model-derived layers bound to the stub.
pub fn kernel(root: &Path, calls: Arc<AtomicUsize>) -> Kernel {
// Where browse keeps the archive, the findings and the annotations.
let store = Arc::new(Store::new().expect("an in-memory store opens"));
let config = ExplainConfig::new(store)
.file_provider(PROVIDER)
.dir_provider(PROVIDER)
.review_provider(PROVIDER)
// A critical or major finding is otherwise sent to a second model, the judge, for a
// verdict; this chapter's model is a stub, and a judge of a stub would judge nothing.
.no_judge();
let space = Mount::new([("demo".to_string(), root.to_path_buf())])
// No config home: built-in styles, no files read from this machine.
.config_home(None)
.explain(config)
.space();
let spaces: Vec<Arc<dyn Space>> = vec![Arc::new(space), Arc::new(stub_model(calls))];
Kernel::with_meta_renderer(
Arc::new(Fallback::new(spaces)),
Arc::new(ikigai_vocab::TurtleRenderer),
)
.with_clock(Arc::new(ledger_host::StoryClock::default()))
}
ExplainConfig names which provider writes which layer and holds the store the archive lives
in; Mount names the roots. config_home(None) keeps browse from reading this machine’s
configuration, so the run below is the same on every machine.
Explaining a file
The repository is a directory holding one file, src/lib.rs:
/// A name's initials: "Ada Lovelace" is "AL".
pub fn initials(name: &str) -> String {
name.split(' ').map(|word| &word[..1]).collect()
}
Before anything is explained, explain-status says what an explain would cost, without
calling the model. Its plain face is three tab-separated fields: the state, the version tag
the answer would carry, and the model.
#![allow(unused)]
fn main() {
extern crate browse_host;
extern crate ikigai_core;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use browse_host::{ask, kernel, Scratch, EXPLANATION};
use ikigai_core::Verb;
let repo = Scratch::new();
let calls = Arc::new(AtomicUsize::new(0));
let browse = kernel(repo.path(), calls.clone());
let source = |iri: &str, args: &[(&str, &str)]| ask(&browse, Verb::Source, iri, args).unwrap();
let status = source("urn:repo:demo:explain-status:src/lib.rs", &[]);
assert_eq!(status, "derive\tcode-v1@stub-1\tstub-1");
assert_eq!(calls.load(Ordering::SeqCst), 0);
let explanation = source("urn:repo:demo:explain:src/lib.rs", &[]);
assert_eq!(explanation.trim(), EXPLANATION);
assert_eq!(calls.load(Ordering::SeqCst), 1);
let status = source("urn:repo:demo:explain-status:src/lib.rs", &[]);
assert_eq!(status, "archived\tcode-v1@stub-1\tstub-1");
// Again: from the archive. The model is not asked.
let json = source("urn:repo:demo:explain:src/lib.rs", &[("as", "application/json")]);
assert!(json.contains(r#""derived":false"#), "{json}");
assert!(json.contains(r#""version_tag":"code-v1@stub-1""#), "{json}");
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
The state is derive until something has been archived for these bytes at this tag, and
archived after. explain-status has a third state, stub, for a binary file, which is
described in one line and never sent to a model.
The version tag is {prompt}@{model}. code-v1 is the prompt browse uses for source code
(a note, a skill file and plain text each have their own); stub-1 is what
urn:llm:stub:model said. Change either and the archived answer no longer applies, which is the
point: an explanation is evidence about these bytes, asked this way, of this model, and the
tag says which. The JSON face carries derived, true only on the read that called the model.
The archive is keyed by the bytes
Edit the file, and the next explain is a new derivation; put the old bytes back, and the old answer is still there:
#![allow(unused)]
fn main() {
extern crate browse_host;
extern crate ikigai_core;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use browse_host::{ask, kernel, Scratch, INITIALS_RS};
use ikigai_core::Verb;
let repo = Scratch::new();
let calls = Arc::new(AtomicUsize::new(0));
let browse = kernel(repo.path(), calls.clone());
let source = |iri: &str| ask(&browse, Verb::Source, iri, &[]).unwrap();
source("urn:repo:demo:explain:src/lib.rs");
repo.write("src/lib.rs", &format!("{INITIALS_RS}\n/// Shouts.\npub fn shout() {{}}\n"));
assert!(source("urn:repo:demo:explain-status:src/lib.rs").starts_with("derive\t"));
source("urn:repo:demo:explain:src/lib.rs");
assert_eq!(calls.load(Ordering::SeqCst), 2);
// Two versions archived, newest first: tag, content hash, model, when.
let versions = source("urn:repo:demo:explain-versions:src/lib.rs");
assert_eq!(versions.lines().count(), 2, "{versions}");
assert!(versions.lines().all(|v| v.starts_with("code-v1@stub-1\tsha256:")), "{versions}");
// Back to the first bytes: archived, and no third call.
repo.write("src/lib.rs", INITIALS_RS);
assert!(source("urn:repo:demo:explain-status:src/lib.rs").starts_with("archived\t"));
source("urn:repo:demo:explain:src/lib.rs");
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
Nothing was invalidated, and nothing needed to be. An archive keyed by content cannot go stale: an edit does not change the old entry, it asks for a different one. That is why gonk can keep explanations forever and why reverting a file, or checking out an old branch, costs nothing.
The cost line
A person deciding whether to click Explain wants to know what it will cost before it costs anything. The status resource’s HTML face is that sentence, and it is what gonk’s file page shows beside the button:
#![allow(unused)]
fn main() {
extern crate browse_host;
extern crate ikigai_core;
use std::sync::atomic::AtomicUsize;
use std::sync::Arc;
use browse_host::{ask, kernel, Scratch};
use ikigai_core::Verb;
let repo = Scratch::new();
let browse = kernel(repo.path(), Arc::new(AtomicUsize::new(0)));
let cost = || ask(&browse, Verb::Source, "urn:repo:demo:explain-status:src/lib.rs",
&[("as", "text/html")]).unwrap();
assert!(cost().contains("explain: not yet explained at this version — stub-1 will write it, \
which may take a while. After that it opens instantly."));
ask(&browse, Verb::Source, "urn:repo:demo:explain:src/lib.rs", &[]).unwrap();
assert!(cost().contains("explain: already explained at this version by stub-1 — \
opens instantly from the archive, no model call."));
}
Both sentences come from the same three facts the plain face prints; the page does not guess.
Served: gonk with a model mounted
gonk composes exactly this, with a real store and a real model. The model is named on the
command line (or by a gonk.mount line in config.toml) as a peer to mount at urn:llm: —
a Unix socket, or quic://host:port with a certificate directory. Make a repository to browse,
and start a gonk of your own with a mount whose socket nothing is listening on. (Later chapters, from
Your first saved query on, start a stub model process at a path like this one; here
nothing answers, on purpose.) In a new empty directory, as in every
chapter: if the gonk from Browse roots is still
running, stop it first, since this one takes the same port.
/// A name's initials: "Ada Lovelace" is "AL".
pub fn initials(name: &str) -> String {
name.split(' ').map(|word| &word[..1]).collect()
}
$ mkdir -p .config/ikigai
$ printf 'gonk.browse.root = "demo=%s/demo"\n' "$PWD" > .config/ikigai/config.toml
$ ikigai-gonk --config-home "$PWD/.config/ikigai" --data-home "$PWD/.ikigai" --port 1070 --no-quic --no-backup --mount "prefer urn:llm:=$PWD/model.sock"
ikigai-gonk 0.1.0 — holding the store at …/.ikigai/store
http http://localhost:1070/ — loopback (127.0.0.1:1070); anonymous read+write: default; 0 passkey(s); anonymous SPARQL budget 1000 ms
browse urn:repo:{demo (watched)}:* — annotations and archive in <urn:iki:browse:graph:default>
…
backup …
llm …/model.sock (prefer; dialed on first use) — explain/review bound; file urn:llm:coder:ask @400, dir urn:llm:ask @600, review urn:llm:coder:ask @800, pr urn:llm:coder:ask @600 tokens. Deriving needs urn:cap:net:localhost, which `--browse derive` mints and nothing else does
…
The llm line is the mount: where the model is, that it is dialed on first use rather than
at startup (so a model that is down does not stop a ledger from serving), and which provider
writes which layer, with each layer’s token ceiling. gonk’s defaults ask urn:llm:coder:ask for
files and reviews and urn:llm:ask for directories — names a peer running the ikigai-llm
module serves. The last sentence is the capability: deriving spends the model, so it needs a
network grant that only a --browse derive client is given.
Ask over the socket, as in the previous chapter:
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:repo:demo:explain-status:src/lib.rs' -c 'source urn:gk:repo:demo:explain:src/lib.rs'
derive code-v1@coder coder
[uncacheable]
error: unavailable: the mounted peer at …/model.sock is not reachable (the last 1 dial(s) failed; the next is held off for … more)
— batch: 2 commands · uncacheable
The status still answers: it reads the archive and never calls the model. With no peer to ask,
the model is named by the provider’s last segment, coder, instead of what urn:llm:coder:model
would have said. The explain itself is refused as unavailable, which is the honest answer: a
model that cannot be reached is not an explanation that is empty, and nothing is archived.
A real model
$ ikigai-gonk --mount "prefer urn:llm:=quic://models.local:4433 /path/to/model-client-certs"
A peer that serves ikigai-llm (an ikigai serve with the module bound, in front of Ollama or
another backend) is mounted the same way; everything above then behaves as it did with the stub,
except that the first explain of each version takes seconds instead of microseconds and the tag
names a real model. The archive is why that is affordable: the model is paid once per version of
each file, however many people read it.
What you built
In resource terms: the atoms are the files and browse’s store (the archive); the model is
an input nothing here can derive, bound at two names; explain is a composition of a file and
a model, written into the archive; explain-status and explain-versions are views of the
archive that never call the model. The one piece of code written to answer a request is the stub
model, and in gonk even that is not code but a mount.
One-off review, and what a finding is
The explain chapter had a model describe a file. A review asks it for something more dangerous: claims that the file is wrong. A claim like that is cheap to make and expensive to check, and a review system’s whole design is about who pays for the checking. In gonk the answer is one rule, enforced by capability rather than by habit:
a model’s claim is a finding, pending until a person decides; only a person’s decision turns it into an annotation that anyone else sees.
This chapter runs one review pass over the explain chapter’s repository, with the same stub model, and walks one finding to each end: declined with a reason, and published with the evidence that it is real.
A review pass
urn:repo:{root}:review:{path} sends the file to the review provider and parses what comes back.
The stub’s answer, in the grammar browse reads — three lines per finding:
/// The stub's review, in the grammar browse parses: three lines per finding, and a note that
/// ends in a period (without one, the last finding reads as cut off and is dropped).
pub const REVIEW: &str = "\
QUOTE: name.split(' ').map(|word| &word[..1]).collect()
SEVERITY: major
NOTE: Slicing the first byte panics on an empty word, so a name with two spaces in a row, or an empty name, crashes the caller.
QUOTE: pub fn initials(name: &str) -> String {
SEVERITY: minor
NOTE: Returning a String allocates on every call; return a &str instead.
";
One claim is real: &word[..1] slices the first byte of each word, and an empty word — two
spaces in a row, or an empty name — has none. The other is not: initials builds a string out of
letters taken from several words, which no &str borrowed from the input could hold.
#![allow(unused)]
fn main() {
extern crate browse_host;
extern crate ikigai_core;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use browse_host::{ask, kernel, Scratch};
use ikigai_core::Verb;
let repo = Scratch::new();
let calls = Arc::new(AtomicUsize::new(0));
let browse = kernel(repo.path(), calls.clone());
let source = |iri: &str, args: &[(&str, &str)]| ask(&browse, Verb::Source, iri, args).unwrap();
let pass = source("urn:repo:demo:review:src/lib.rs", &[]);
assert!(pass.starts_with(
"review by stub-1 · review-v5@stub-1 · 2 findings · reviewed the whole file (142 bytes)"));
let pending = source("urn:repo:demo:findings:src/lib.rs", &[("as", "text/plain")]);
assert!(pending.contains("Findings wait for a person."), "{pending}");
assert!(pending.contains(r#"src/lib.rs L3 [pending] major "name.split(' ')"#), "{pending}");
assert!(pending.contains(r#"src/lib.rs L2 [pending] minor "pub fn initials"#), "{pending}");
// Nothing was published.
assert_eq!(source("urn:repo:demo:annotations:src/lib.rs", &[]).trim(), "[]");
// Like an explanation, a pass is archived by content: asking again calls nothing.
assert_eq!(calls.load(Ordering::SeqCst), 1);
source("urn:repo:demo:review:src/lib.rs", &[]);
assert_eq!(calls.load(Ordering::SeqCst), 1);
assert!(source("urn:repo:demo:review-status:src/lib.rs", &[]).starts_with("archived\t"));
}
The first line of the pass is its statement: who reviewed, at which version tag
(review-v5@stub-1, the review prompt’s version and the model), how many findings, and how much
of the file was read. “Reviewed the whole file” is a claim browse checks: a large file is split
into regions that tile it, and a region that did not come back is reported as coverage incomplete rather than silently skipped. A clean file is a pass with zero findings and says
nothing above threshold, which is an answer, not a failure.
What a finding is
Each claim becomes a resource, urn:iki:finding:{id}, with the quote it is anchored to (the
exact text, and where it sits), the model’s note, and the severity the model proposed:
critical, major, minor, info or praise. Its id is a hash of the pass and the quote, so
the same pass over the same bytes names the same findings, here and on any machine:
| finding | line | proposed | the claim |
|---|---|---|---|
urn:iki:finding:bd771489c5a22c9a4d45f4dd | 3 | major | the slice panics on an empty word |
urn:iki:finding:1d920b0266c30789b5c61b08 | 2 | minor | return a &str instead |
A finding is pending until someone decides, and pending is the whole of its authority: the
listing says so on its second line, and nothing about a pending finding blocks a commit, a push
or a merge. (A finding gonk’s review queue would ask about — critical and major by default —
can also be sent to a second model, a judge, for a verdict that orders the queue. The judge
orders; it does not decide. This chapter’s host turns it off, since a judge of a stub would judge
nothing.)
Deciding
A decision is a Sink to the finding. Declining takes a reason from a closed set, because “why not” is the data that makes the next review better:
| reason | means |
|---|---|
misread | the claim is false: the model misunderstood the code or the domain |
restates | the code already says it: a comment that discloses a hazard, restated as a finding |
no-issue | true, but not a defect: style, “could be clearer”, an absence asserted as a problem |
wont-fix | real, and deliberately left as it is |
duplicate | already raised |
Publishing takes reproduced=yes when the person has made the defect happen. That is not a
formality: in the measurements behind the next chapter, a review model’s claims that a
second model had judged credible held, when someone tried to reproduce them, almost never. The
reproduction for this one is a few lines:
#![allow(unused)]
fn main() {
// The function under review, exactly as the repository has it.
fn initials(name: &str) -> String {
name.split(' ').map(|word| &word[..1]).collect()
}
assert_eq!(initials("Ada Lovelace"), "AL");
initials("Ada Lovelace"); // two spaces: the middle word is empty, and `[..1]` panics
}
So the major finding is published, with the reproduction as its note, and the minor one declined:
#![allow(unused)]
fn main() {
extern crate browse_host;
extern crate ikigai_core;
use std::sync::atomic::AtomicUsize;
use std::sync::Arc;
use browse_host::{ask, kernel, Scratch};
use ikigai_core::{Error, Verb};
let repo = Scratch::new();
let browse = kernel(repo.path(), Arc::new(AtomicUsize::new(0)));
ask(&browse, Verb::Source, "urn:repo:demo:review:src/lib.rs", &[]).unwrap();
let real = "urn:iki:finding:bd771489c5a22c9a4d45f4dd";
let wrong = "urn:iki:finding:1d920b0266c30789b5c61b08";
let declined = ask(&browse, Verb::Sink, wrong, &[
("decision", "decline"),
("reason", "misread"),
("content", "A borrowed &str cannot hold letters taken from several words."),
]).unwrap();
assert_eq!(declined.trim(), wrong);
let published = ask(&browse, Verb::Sink, real, &[
("decision", "publish"),
("reproduced", "yes"),
("content", r#"initials("Ada Lovelace") panics: the empty middle word has no byte 0."#),
]).unwrap();
assert_eq!(published.trim(), "urn:iki:annotation:bd771489c5a22c9a4d45f4dd");
let row = ask(&browse, Verb::Source, real, &[]).unwrap();
assert!(row.contains("L3 [published] major -> major [reproduced]"), "{row}");
let row = ask(&browse, Verb::Source, wrong, &[]).unwrap();
assert!(row.contains("L2 [declined] minor -> minor (misread)"), "{row}");
// The published one is now an annotation on the file; the declined one is not.
let notes = ask(&browse, Verb::Source, "urn:repo:demo:annotations:src/lib.rs", &[]).unwrap();
assert!(notes.contains(r#""iri":"urn:iki:annotation:bd771489c5a22c9a4d45f4dd""#), "{notes}");
assert!(notes.contains(r#""derived_from":"urn:iki:finding:bd771489c5a22c9a4d45f4dd""#));
assert!(!notes.contains("1d920b0266c30789b5c61b08"));
// A decision is a record. Changing it means saying which decision is revised.
let again = ask(&browse, Verb::Sink, wrong, &[("decision", "publish")]);
assert!(matches!(again, Err(Error::InvalidArgument { .. })));
assert!(format!("{}", again.unwrap_err())
.contains("revises=urn:iki:finding:1d920b0266c30789b5c61b08:decision"));
// And the reasons are a closed set.
let made_up = ask(&browse, Verb::Sink, real, &[("decision", "decline"), ("reason", "meh")]);
assert!(format!("{}", made_up.unwrap_err())
.contains("one of: misread, restates, no-issue, wont-fix, duplicate"));
}
-> major is the severity the person settled on (leaving severity= out accepts the model’s
proposal). The annotation keeps the finding’s id and says which finding it was derived from, so
the published note and the model’s claim behind it stay one hop apart. And a decision is never
overwritten: a second, different one must name the first with revises=, and both are kept.
Who may do which
The rule at the top of the chapter is not a convention. Deriving a review spends the model, so it
needs the network grant; publishing writes what everyone else reads, so it needs
urn:cap:annotate; and nothing that may derive is given the other by default:
#![allow(unused)]
fn main() {
extern crate browse_host;
extern crate ikigai_core;
use std::sync::atomic::AtomicUsize;
use std::sync::Arc;
use browse_host::{ask_as, kernel, Scratch};
use ikigai_core::{Capability, Error, Verb};
let repo = Scratch::new();
let browse = kernel(repo.path(), Arc::new(AtomicUsize::new(0)));
let reader = Capability::scoped(["urn:cap:browse:read:demo".to_string()]);
let deriver = Capability::scoped([
"urn:cap:browse:read:demo".to_string(),
"urn:cap:net:localhost".to_string(),
]);
let review = "urn:repo:demo:review:src/lib.rs";
assert!(matches!(ask_as(&browse, &reader, Verb::Source, review, &[]), Err(Error::Denied(_))));
assert!(ask_as(&browse, &deriver, Verb::Source, review, &[]).is_ok());
let publish = ask_as(&browse, &deriver, Verb::Sink, "urn:iki:finding:bd771489c5a22c9a4d45f4dd",
&[("decision", "publish")]);
assert!(matches!(publish, Err(Error::Denied(ref why)) if why.contains("urn:cap:annotate")));
}
A reader can read the archive; a deriver can run a pass and read its findings; only a holder of
urn:cap:annotate can publish. gonk uses the split both ways. Its client roles are cumulative:
--browse read reads, and --browse derive is read plus urn:cap:annotate plus the
network grant, because a person at a client who may spend the model is also trusted to decide.
Its headless reviewer — the one a commit hook would trigger — is the opposite case: gonk refuses
to arm it with any grant that can publish, so a machine that spends the model can never also
promote what it produced.
In gonk
gonk’s file page has a Review button beside Explain, with the same cost line
(review: already reviewed at this version by … — the findings open instantly from the archive, no model call.), and its Queue lists the pending findings a person should look at, serious ones
first, with Decline (and its five reasons) and Publish on each. Over the socket the resources are
the ones above, under the urn:gk: alias of Over the socket:
urn:gk:repo:{root}:review:{path}, urn:gk:repo:{root}:findings:{path}, a Sink to
urn:gk:iki:finding:{id}. Running a pass needs a mounted model, and this part’s pages mount none
that answers, so the explain chapter shows only that gonk refuses it as
unavailable; Review every commit, later, runs a pass through gonk
with a stub model mounted.
What gonk can also do — review every file a commit touches, from a post-commit hook — is off,
and the next chapter is why.
What you built
The atoms are the file and browse’s store, now holding pass records, findings and decisions beside the explain archive. A finding is a resource a composition (the review pass) mints and only a person moves; a decision is a resource too, one per decision, never rewritten; an annotation is a view that exists because a decision says so. The code written for it is still only the stub model.
The audit protocol, and why per-commit review is off
The last chapter showed the machinery: a review pass reads a file, a model proposes findings,
and a person declines them or publishes them with the evidence that they are real. gonk can
run that pass on every commit, from a post-commit hook. On the machine this book was written
on, that hook is installed in every repository and does nothing, because
gonk.review.arm is not true. This chapter is why, told with the numbers that decided it,
and what replaced it.
There is nothing to run on this page. It is the one chapter in the book whose claims are measurements someone else made, so it says where they are and what they do and do not show.
The question
A per-commit reviewer is cheap to start and expensive to keep. Every commit queues every file it touched; a model reads each one and proposes findings; a person reads every proposal and decides. By late September 2026 that queue held more than a thousand pending findings across the repositories gonk browses, most of them minor, and the people reading it were spending their attention on the reviewer’s guesses.
So the question was put as an experiment, with the rule for deciding written down before any data came in: per serious bug actually found, which costs less — following the reviewer’s leads, or a strong model auditing the same code with no leads at all? A bug counted only if it came with a reproduction: a test or probe that fails, on the pinned commit, because of the defect. “Serious” meant wrong output, a crash, a security hole or lost data. The cost was the tokens each arm spent.
Round 1: the local reviewer’s leads (2026-10-03)
Three repositories, each at a pinned commit, read-only. The led arm was handed the reviewer’s findings that a second model had already judged credible, and set out to reproduce them; the unled arm audited the same files blind. Same model in both arms, the same tool-call budget.
| serious bugs | tokens | serious per 100k tokens | |
|---|---|---|---|
| led (follow the reviewer) | 6 | 756,357 | 0.79 |
| unled (audit blind) | 19 | 1,128,583 | 1.68 |
The unled audits found about 2.1 times as many serious bugs per token, and the overlap was small: in one repository the two arms found none of each other’s bugs. Worse for the reviewer, 47 of its claims that had been judged credible were checked one by one, and none held as stated — two were partly true, with the mechanism wrong. Where the leads helped, they helped by pointing at a file, and the file list did not need them.
Round 2: a frontier reviewer (2026-10-05)
Round 1’s reviewer was a local model, so a fair objection was that a better model would review better. Round 2 used the same frontier model in every arm, on two repositories (the ledger itself and a web application), and added two arms:
| arm | what it did | serious per 100k tokens |
|---|---|---|
| R | per-commit diff review in the style of a review bot, read-only, then a separate pass to reproduce its claims (both passes’ tokens count) | 0.72 |
| U | an unled audit, as in round 1 | 1.49 |
| U2 | a second, independent unled audit, same protocol | 1.99 |
| D | an unled audit that starts from the window’s commits, then follows the code | 1.85 |
Sixteen distinct serious bugs were found by some arm; any single audit found 10 to 13 of them.
What the round decided, by the rules written before it:
- Per-commit review stays off. The frontier reviewer was precise — 12 of its 15 claims reproduced, against 0 of 47 in round 1 — but it found less than half as much per token. A diff never shows the reviewer a defect that is older than the diff, and a quiet week of dependency bumps gives it almost nothing to read. A better model makes a reviewer’s findings honest; it does not make them complete.
- Two independent auditors per repository. The second audit, identical in protocol, added 5 serious bugs to the first’s 10 — exactly the 50% the rule had named as the bar.
- Starting from the commits is allowed, not required. D beat U on paper, but U and U2 — the same protocol run twice — differed by more than D and U did, so the gap is inside the noise. D did find one class of bug (a reflected script injection) that no other arm saw, so one of the two auditors may start from the commits.
What the evidence is, and what it is not
It is five pairings across five repositories, one model per round, every bug backed by a reproduction that was re-run by someone other than the arm that wrote it, and decision rules fixed before the data. It is enough to stop paying for something that was measurably worse.
It is not a controlled study. The samples are small; there is no variance estimate beyond the one duplicated arm, which showed that run-to-run variance is large; severity classes were harmonized across arms by hand, after the fact, by the person who scored them; and in round 1 the file lists were chosen by the reviewer’s findings, which favored the led arm. It says nothing about other models, other codebases, or review whose purpose is teaching rather than finding bugs. And it was run by the people who built the reviewer, which cuts both ways.
The write-ups are in claude/research/review-exp-2026-10-03/README.md and
claude/research/review-exp2-2026-10-05/README.md of ikigai-devtools, a private repository;
the numbers above are copied from them, and the reproductions that back them are not public.
The protocol that replaced it
Real bugs now come from periodic unled audits, one repository at a time, on a rotation (oldest first; a repository with a large recent change or a security surface moves up):
- Two independent auditors, each in its own detached worktree at one pinned commit, blind to each other and to every earlier finding, annotation and review for that repository. Code, tests, documentation and history are fair game.
- Read-only. No fixes during an audit: the findings are about one known commit, and fixing is a separate piece of work scheduled from the report.
- A focus and a budget — a file list or an area, and a number of tool calls — and a report that says whether the budget ran out or the work did, and lists what was not read.
- A bug counts only with a reproduction. An unreproduced suspicion is reported as a suspicion, never as a bug.
- A finding is closed only with evidence: fixed (the regression test and the change), refuted (why the reproduction was wrong), or deferred (to what). The evidence is written on the ledger item before it is closed.
The two auditors’ reports are merged by root cause into one ledger item per repository, and that item is the work. Nobody reads a model’s unreproduced guesses any more.
What is still there
None of the machinery went away, only the trigger:
- One-off review is the previous chapter, unchanged: the Review button on a file page in
gonk’s browser, or a Source of
urn:repo:{root}:review:{path}. It is useful exactly where the experiment says a reviewer is — pointed by a person at a file the person already suspects. - Findings stay pending until a person acts. A review pass never publishes; an annotation
exists only because someone published a finding, and
reproduced=says how they knew. - The hook is still installed, and
gonk.review.arm = truein the config home turns it back on. Nothing about this decision is enforced by deleting code; it is a configuration that can be reversed by the same evidence that set it. - What an armed queue fires is gonk’s decision, not the queue directory’s. The review
space keeps a
handlerfile naming what to fire for each request, and by default the reactor underneath would fire whatever that file names, under the reviewer’s authority. gonk fires its own review pass and nothing else: ahandlerfile retargeted at any other resource gets the request refused, dead-lettered into the space’serror/directory with a note naming that target, rather than run. Dropping a request is open to anything that can write the directory, so the directory must not be able to choose what runs.
OpenSpec in one chapter
A ledger says what to do next. It does not say what the software is supposed to do, and an
agent working from a ledger item alone is guessing at that. OpenSpec
(MIT, by Fission AI; this book follows its 1.x layout, current release 1.14.1) is a small
convention for writing it down where the code is: Markdown files in an openspec/ directory,
with just enough structure that a tool — or a SPARQL query — can find the parts.
This chapter is the convention, on a tree this book commits; the next two lift that tree into a graph and keep its work in a ledger. Nothing here needs OpenSpec’s own CLI, which drafts these files with an AI assistant and validates and archives them; what follows is only the format it reads and writes.
Two halves
crates/openspec-ledger/openspec/
├── specs/ what the system does NOW, one folder per capability
│ ├── board/spec.md
│ └── moves/spec.md
└── changes/ what someone PROPOSES to change, one folder per change
└── add-undo/
├── proposal.md why, and what changes
├── specs/moves/spec.md the change as a delta against specs/moves/spec.md
└── tasks.md the checklist that implements it
Specs are the source of truth: they describe how the system behaves today, grouped by capability. Changes are proposals, kept apart until they are done, so several can be in flight without editing the truth underneath each other. The game is tic-tac-toe, as in The ikigai Book, because everybody already knows its rules and the spec can be short.
A spec: requirements and scenarios
# moves Specification
## Purpose
How players take turns, and which moves the game refuses.
## Requirements
### Requirement: Players alternate
The system SHALL give the turn to the other player after every accepted move.
#### Scenario: X then O
- **WHEN** X takes a square
- **THEN** it is O's turn
### Requirement: A taken square is refused
The system MUST refuse a move to a square that is already taken, and MUST NOT change the
turn when it does.
#### Scenario: Moving onto X
- **WHEN** O moves to a square X holds
- **THEN** the move is refused
- **AND** it is still O's turn
A ### Requirement: is one behavior, stated with an RFC 2119 keyword — SHALL or MUST
for an absolute, SHOULD for a recommendation, MAY for an option. Each has at least one
#### Scenario:, a concrete case written as WHEN / THEN (and AND) steps that a test
could check. That is all the structure there is: headings with fixed prefixes, and bullets under
them. A spec says what is observable, never how it is built; class names and libraries belong in
a change’s design, not here.
A change: a proposal, a delta, and tasks
The proposal says why, and which capabilities the change touches:
# Proposal
## Why
A misclick ends a game of tic-tac-toe. Players want to take back the last move, and only the
last one, so that undo cannot be used to replay a lost game from the start.
## What Changes
- A player can undo the most recent move, which empties its square and gives the turn back.
- Undo after the game is over is refused.
## Capabilities
### New Capabilities
### Modified Capabilities
- `moves`: adds undo, and says what alternation means once a move can be taken back.
## Impact
The move history becomes state the game keeps; the board view gains an Undo control.
The delta spec says what will be different, by section — ADDED, MODIFIED and REMOVED Requirements — rather than restating the whole spec, so a reviewer reads only the change:
# Spec Delta
## ADDED Requirements
### Requirement: The last move can be undone
The system SHALL let a player undo the most recent move of a game that is not over: its square
becomes empty and the turn returns to the player who made it.
#### Scenario: Undoing X's move
- **WHEN** X takes the center and then undoes it
- **THEN** the center is empty
- **AND** it is X's turn again
#### Scenario: Undo after a win
- **WHEN** the game is over and a player asks to undo
- **THEN** the undo is refused
## MODIFIED Requirements
### Requirement: Players alternate
The system SHALL give the turn to the other player after every accepted move, and back to the
player whose move was undone after an undo.
#### Scenario: X then O
- **WHEN** X takes a square
- **THEN** it is O's turn
A MODIFIED requirement is written out in full, as it will read afterwards, and is matched to
the current one by its name. And the tasks are an ordinary Markdown checklist, numbered by
group:
# Tasks
## 1. Undo
- [x] 1.1 Keep the game's moves in order, as the history undo reads
- [ ] 1.2 Empty the last move's square and return the turn (The last move can be undone)
- [ ] 1.3 Refuse undo once the game is over (The last move can be undone)
## 2. Turns
- [ ] 2.1 Give the turn back after an undo (Players alternate)
⚠ One convention here is this book’s, not OpenSpec’s. A task that ends in a requirement’s
name in parentheses — (The last move can be undone) — says which requirement it implements.
OpenSpec’s tasks are free text and name nothing; this book needs the link to file each task as a
ledger item about its requirement, so it adds the parenthesis, and a task without one (1.1
here) is about its change alone. A project adopting this would agree the same convention, or put
the link somewhere else and change one construct.
Archiving
When a change is done, OpenSpec archives it: the delta is merged into the current specs (the
added requirement appended to specs/moves/spec.md, the modified one replaced), and the change’s
folder moves to changes/archive/<date>-<id>/, intact, as the record of why the specs say
what they say. So at any moment, changes/ is exactly the work in flight, and changes/archive/
the work done. The ledger in two chapters’ time keys off that move.
Where this goes next
Everything above is text, and a person reads it well. What a person cannot do is ask the tree a question across files: which requirements does this change touch, which of its tasks are still open, which requirement has the most work against it, which items in the ledger are about a requirement that no longer exists. The next chapter turns the tree into a graph that can be asked, without writing a parser for any of it.
An openspec/ tree as a graph
The previous chapter’s tree is five Markdown files. This chapter makes it a graph in which every requirement, scenario, change and task has a name — an IRI — and asks it questions with SPARQL. It writes no parser to do it.
The lifting is ikigai-markdown, and it works in
two stages that are worth keeping apart:
- A generic lift that knows Markdown and nothing else: documents, headings with their levels and their enclosing section, list items with their checkbox state, paragraphs, links. Every node is named under the document’s IRI; there are no blank nodes.
- A mapping, which is a resource, not code: a small lift profile plus SPARQL
CONSTRUCTqueries that turn that structure into a domain — here, OpenSpec’s. The lifter has never heard of a requirement. Every word of OpenSpec in this chapter is in the mapping.
ikigai-markdown is on crates.io (its first release was 0.1.0); this book’s workspace takes
0.1.4.
The mapping, by name
A mapping is found by name, urn:markdown:mapping:{name}, as a file under the config home:
<config home>/markdown/mappings/openspec/mapping.ttl. The one this book uses is
crates/openspec-ledger/config/markdown/mappings/openspec/mapping.ttl;
copying that directory into ~/.config/ikigai/markdown/mappings/ installs it for any ikigai host
on your machine. The host in this chapter is given config/ beside it as its config home instead,
so the book never reads yours:
/// The store, the ledger, and the lift, as one space.
///
/// The lift finds mappings BY NAME under a config home, at
/// `<home>/markdown/mappings/<name>/mapping.ttl`. A host passes its own; this crate's is
/// `config/` beside it, so the book never reads the reader's `~/.config`.
pub fn space(mapping_home: &Path) -> Fallback {
let store = DurableStore::in_memory().expect("an in-memory store opens");
let home = Arc::new(MappingHome::new(Some(mapping_home.to_path_buf())));
Fallback::new(vec![
Arc::new(ikigai_store::space(store)) as Arc<dyn Space>,
Arc::new(ikigai_ledger::space()) as Arc<dyn Space>,
Arc::new(ikigai_markdown::space_with(home)) as Arc<dyn Space>,
])
}
/// The host: [`space`] over this crate's config home, a Meta renderer and the story clock.
pub fn kernel() -> Kernel {
Kernel::with_meta_renderer(
Arc::new(space(&crate_dir().join("config"))),
Arc::new(ikigai_vocab::TurtleRenderer),
)
.with_clock(Arc::new(ledger_host::StoryClock::default()))
}
The store and the ledger are the first chapter’s; the lift is the third space. The graph will live in the same store as the ledger, which is what the next chapter needs.
A mapping’s constructs are SPARQL, and the lift runs them under a time budget, the same one the
store’s own doors use: 5 seconds for a caller, raised by a urn:cap:store:budget:<ms> grant, 120
seconds for root, and a Timeout past it. An evaluation that cannot be stopped is counted, and
while too many are still running the lift refuses new ones, so the count is what keeps a runaway
mapping from holding the machine’s cores. space_with makes that budget itself. A host that
mounts the lift more than once, or wants other numbers, makes ONE EvaluationBudget and passes it
to every space_with_budget it mounts, so that every lift shares one count. This host mounts one
lift, so the default is already the one. The store keeps its own count for its own doors.
The mapping’s lift profile has one entry. It turns every RFC 2119 keyword in the tree into a match the constructs can see, one per occurrence:
# --- the lift profile --------------------------------------------------------------------
# One md:Match per RFC 2119 keyword, wherever it is. SPARQL's REPLACE gives one value per
# row, so "MUST refuse … and MUST NOT change" would be one keyword to a construct; the
# profile makes it two rows. Longest alternatives first: the regex takes the first that fits.
md:citation <urn:markdown:mapping:openspec:citation:keyword> ;
<urn:markdown:mapping:openspec:citation:keyword>
md:name "keyword" ;
md:pattern "(?P<v>MUST NOT|SHALL NOT|SHOULD NOT|MUST|SHALL|SHOULD|MAY)" .
A construct could find the first keyword in a paragraph with a regular expression, but not both of “MUST refuse … and MUST NOT change”. The profile is where the one-to-many part of reading text belongs. Then the constructs, six of them. Each runs over one document’s structural graph, and decides what that document is from its path — OpenSpec’s own rule. This is the first: a capability and its requirements, from a current spec.
# 1. A capability and its requirements, from a current spec.
md:construct """
PREFIX md: <https://ikigai-rs.dev/ns/md#>
PREFIX os: <http://example.org/openspec#>
CONSTRUCT {
?cap a os:Capability ; os:name ?capName ; os:requirement ?req .
?req a os:Requirement ; os:name ?reqName ; os:capability ?cap ; os:line ?line .
}
WHERE {
?doc a md:Document ; md:path ?path .
FILTER(REGEX(?path, "(^|/)openspec/specs/[^/]+/spec[.]md$"))
BIND(REPLACE(?path, "^(.*/)?openspec/specs/([^/]+)/spec[.]md$", "$2") AS ?capName)
BIND(IRI(CONCAT("urn:openspec:spec:", ?capName)) AS ?cap)
# OPTIONAL, so a capability with no requirements is still a capability. Its BINDs come AFTER
# it: a group inside OPTIONAL is evaluated on its own, where ?cap is not bound yet.
OPTIONAL {
?h a md:Heading ; md:document ?doc ; md:level 3 ; md:text ?text ; md:line ?line .
FILTER(STRSTARTS(?text, "Requirement: "))
}
BIND(SUBSTR(?text, 14) AS ?reqName)
BIND(IRI(CONCAT(STR(?cap), ":requirement:",
REPLACE(REPLACE(LCASE(?reqName), "[^a-z0-9]+", "-"), "^-+|-+$", ""))) AS ?req)
}
""" ;
md:Heading, md:level, md:text and md:path are the generic lift’s words; os: is
OpenSpec’s, made up for this book (http://example.org/openspec#, a namespace reserved for
examples). Every IRI is minted from names in the tree — urn:openspec:spec:moves:requirement:players-alternate
— so the same bytes always give the same graph, and a requirement named in a delta spec and the
same requirement in the current spec are one node. The others: each requirement’s scenarios and
their steps; the keywords; a change, and the date it was archived if its folder is under
archive/; what a delta adds, modifies or removes; and a change’s tasks:
# 6. A change's tasks: every checkbox in its tasks.md. A task that ends in "(Requirement
# name)" cites that requirement BY NAME; which capability it belongs to is the change's
# delta, another document, so resolving the name is a query over the union.
md:construct """
PREFIX md: <https://ikigai-rs.dev/ns/md#>
PREFIX os: <http://example.org/openspec#>
CONSTRUCT {
?change os:task ?task .
?task a os:Task ; os:change ?change ; os:number ?number ; os:title ?title ;
os:done ?done ; os:group ?group ; os:cites ?cites .
}
WHERE {
?doc a md:Document ; md:path ?path .
FILTER(REGEX(?path, "(^|/)openspec/changes/(archive/)?[^/]+/tasks[.]md$"))
BIND(REPLACE(?path, "^(.*/)?openspec/changes/(archive/[0-9]{4}-[0-9]{2}-[0-9]{2}-)?([^/]+)/tasks[.]md$", "$3") AS ?id)
BIND(IRI(CONCAT("urn:openspec:change:", ?id)) AS ?change)
?item a md:ListItem ; md:document ?doc ; md:checked ?done ; md:text ?text .
FILTER(REGEX(?text, "^[0-9]+([.][0-9]+)* "))
BIND(REPLACE(?text, "^([0-9]+([.][0-9]+)*) .*$", "$1") AS ?number)
BIND(REPLACE(?text, "^[0-9.]+ +", "") AS ?rest)
BIND(REPLACE(?rest, " *[(][^()]+[)]$", "") AS ?title)
BIND(IF(REGEX(?rest, "[(][^()]+[)]$"), REPLACE(?rest, "^.*[(]([^()]+)[)]$", "$1"), ?unbound) AS ?cites)
BIND(IRI(CONCAT(STR(?change), ":task:", ?number)) AS ?task)
OPTIONAL { ?item md:section ?g . ?g md:text ?group }
}
""" .
A task’s checkbox is the generic lift’s md:checked; its number, title and cited requirement
are cut from its text. What a task does not get here is the requirement itself: the name
The last move can be undone is in tasks.md, the requirement it names is in the delta spec,
and a construct only ever sees one document. That join is a query, below.
Lifting one document
urn:markdown:lift takes the Markdown (content, or a resource IRI as src), the path, and the
mapping, and answers N-Quads: the structural graph and the mapped one, together, in a named graph
for the document.
#![allow(unused)]
fn main() {
extern crate openspec_ledger;
extern crate ikigai_core;
use ikigai_core::Verb;
use openspec_ledger::{ask, crate_dir, kernel, MAPPING};
let host = kernel();
let tasks = std::fs::read_to_string(
crate_dir().join("openspec/changes/add-undo/tasks.md")).unwrap();
let quads = ask(&host, Verb::Source, "urn:markdown:lift", &[
("content", &tasks),
("path", "openspec/changes/add-undo/tasks.md"),
("mapping", MAPPING),
]).unwrap();
let graph = "<urn:markdown:doc:openspec/changes/add-undo/tasks.md>";
let task = "<urn:openspec:change:add-undo:task:1.2>";
let os = |p: &str| format!("<http://example.org/openspec#{p}>");
for line in [
format!(r#"{task} {} "1.2" {graph} ."#, os("number")),
format!(r#"{task} {} "false"^^<http://www.w3.org/2001/XMLSchema#boolean> {graph} ."#, os("done")),
format!(r#"{task} {} "The last move can be undone" {graph} ."#, os("cites")),
format!(r#"{task} {} <urn:openspec:change:add-undo> {graph} ."#, os("change")),
] {
assert!(quads.lines().any(|l| l == line), "missing: {line}");
}
// The structural half is there too, under the document's own IRI.
assert!(quads.contains("<https://ikigai-rs.dev/ns/md#checked>"));
}
Task 1.2 is unchecked, numbered, part of add-undo, and cites a requirement by name. The lift is
cacheable — a function of its arguments and of the mapping it names — so asking again for the same
bytes, path and mapping is answered from the kernel’s cache.
The tree, in the store
A tree is many documents, so a host lifts each and loads it into the store, one named graph per document. Archiving a change moves files, so lifting a tree also drops the graphs of documents that are gone:
/// Make the store's spec graphs exactly the lift of `tree`: one graph per document, named by
/// its path, and no graph for a document that is gone (archiving a change MOVES its files, so
/// the old paths must disappear). Returns how many documents were lifted.
pub fn lift_tree(kernel: &Kernel, tree: &[(String, String)]) -> Result<usize> {
for graph in spec_graphs(kernel)? {
ask(
kernel,
Verb::Sink,
"urn:iki:store:update",
&[("content", &format!("DROP SILENT GRAPH <{graph}>"))],
)?;
}
for (path, text) in tree {
let quads = ask(
kernel,
Verb::Source,
"urn:markdown:lift",
&[("content", text), ("path", path), ("mapping", MAPPING)],
)?;
ask(
kernel,
Verb::Sink,
"urn:iki:store:load",
&[("content", &quads), ("format", "application/n-quads")],
)?;
}
Ok(tree.len())
}
and asks the union of those graphs, through the store’s narrow read door for a set of graphs:
/// A SPARQL SELECT over the union of every spec graph and the ledger's graph, answered as a
/// tab-separated table (one header line, terms in N-Triples spelling).
///
/// The union is the store's `graph=` set: one value, several IRIs, and the dataset becomes their
/// merge — so a query needs no `GRAPH` clause to join a task in `tasks.md` to the requirement it
/// names in a delta spec, or to the ledger item about it.
pub fn select(kernel: &Kernel, query: &str) -> Result<String> {
let mut graphs = spec_graphs(kernel)?;
graphs.push(LEDGER_GRAPH.to_string());
ask(
kernel,
Verb::Source,
"urn:iki:store:graph-select",
&[
("query", query),
("graph", &graphs.join(" ")),
("as", "text/tab-separated-values"),
],
)
}
Now the questions. Which requirements are there, and how strong is each?
#![allow(unused)]
fn main() {
extern crate openspec_ledger;
use openspec_ledger::{crate_dir, kernel, lift_tree, read_tree, select};
let host = kernel();
assert_eq!(lift_tree(&host, &read_tree(&crate_dir())).unwrap(), 5);
let strength = select(&host, r#"
PREFIX os: <http://example.org/openspec#>
SELECT DISTINCT ?capability ?requirement ?keyword WHERE {
?c a os:Capability ; os:name ?capability ; os:requirement ?r .
?r os:name ?requirement ; os:keyword ?keyword .
} ORDER BY ?capability ?requirement ?keyword"#).unwrap();
assert_eq!(strength, "\
?capability\t?requirement\t?keyword
\"board\"\t\"A new game has an empty board\"\t\"SHALL\"
\"board\"\t\"Three in a line wins\"\t\"SHALL\"
\"moves\"\t\"A taken square is refused\"\t\"MUST\"
\"moves\"\t\"A taken square is refused\"\t\"MUST NOT\"
\"moves\"\t\"Players alternate\"\t\"SHALL\"
");
}
What does the change touch, and which of its tasks implements which requirement? This is the
join no construct could make, across tasks.md and the delta spec:
#![allow(unused)]
fn main() {
extern crate openspec_ledger;
use openspec_ledger::{crate_dir, kernel, lift_tree, read_tree, select};
let host = kernel();
lift_tree(&host, &read_tree(&crate_dir())).unwrap();
let work = select(&host, r#"
PREFIX os: <http://example.org/openspec#>
SELECT DISTINCT ?number ?done ?how ?name WHERE {
?change os:id "add-undo" ; os:task ?t .
?t os:number ?number ; os:done ?done ; os:cites ?name .
{ ?change os:adds ?r BIND("adds" AS ?how) }
UNION { ?change os:modifies ?r BIND("modifies" AS ?how) }
?r os:name ?name .
} ORDER BY ?number"#).unwrap();
assert_eq!(work, "\
?number\t?done\t?how\t?name
\"1.2\"\tfalse\t\"adds\"\t\"The last move can be undone\"
\"1.3\"\tfalse\t\"adds\"\t\"The last move can be undone\"
\"2.1\"\tfalse\t\"modifies\"\t\"Players alternate\"
");
}
Two things to notice. DISTINCT is not decoration: a fact several documents state (that
add-undo is a change, say — every file in its folder says so) is in the union once per
document, so counting without DISTINCT over-counts. And task 1.1, which cites nothing, is not in
the answer, because the query asked for tasks that cite; nothing about it is lost, it is simply
about its change and no requirement.
What you built
The atoms are the Markdown files and the mapping file; the graph is derived from them,
one named graph per document, and rebuilt from them whenever the host asks (lift_tree holds
nothing). The lift and the mapping are compositions the host did not write; the queries are
views. The code this chapter added to the book is a directory walk and two loops of requests.
Changes and tasks as ledger items, and next over them
The previous chapter put the spec and the ledger in one store. This one connects
them: every open task of a change in flight becomes a ledger item about the requirement it
implements, next ranks them beside everything else in the ledger, checking a box closes its
item, and archiving the change closes the rest. The tree stays the source of truth for what;
the ledger is where who, when and what next live; and neither copies the other’s job.
Which tasks, and which items
The whole decision is two SPARQL queries over the union of the spec graphs and the ledger’s own graph — the join gonk’s single store exists to make cheap:
/// Every open task of an unarchived change, with the requirement each names (if it names one the
/// change adds or modifies). Whether a task already HAS an item is not asked here: the ledger
/// answers that itself, when the task's IRI is the append's key.
pub const OPEN_TASKS: &str = r#"
PREFIX os: <http://example.org/openspec#>
SELECT DISTINCT ?task ?id ?number ?title ?requirement WHERE {
?task a os:Task ; os:change ?change ; os:number ?number ; os:title ?title ; os:done false .
?change os:id ?id .
FILTER NOT EXISTS { ?change os:archivedOn ?archived }
OPTIONAL {
?task os:cites ?name .
?change os:adds|os:modifies ?requirement .
?requirement os:name ?name .
}
}
ORDER BY ?id ?number
"#;
/// Open ledger items about a task that is done, or whose change has been archived.
pub const FINISHED: &str = r#"
PREFIX os: <http://example.org/openspec#>
PREFIX ledger: <https://ikigai-rs.dev/ns/ledger#>
SELECT DISTINCT ?number ?id ?task ?why WHERE {
?item ledger:about ?task ; ledger:number ?number ; ledger:status ledger:open .
?task a os:Task ; os:change ?change .
?change os:id ?id .
OPTIONAL { ?task os:done ?done }
OPTIONAL { ?change os:archivedOn ?archived }
FILTER(?done = true || BOUND(?archived))
BIND(IF(BOUND(?archived), CONCAT("archived with ", ?id, " on ", STR(?archived)),
"checked off in tasks.md") AS ?why)
}
ORDER BY ?number
"#;
OPEN_TASKS is every open task of an unarchived change, with the requirement its parenthesis
names if the change adds or modifies one by that name — so a typo in a task files an item about
the task alone rather than about the wrong requirement. It does not ask whether a task already has
an item; the ledger answers that. FINISHED is an open item about a task that is checked off, or
whose change has been archived. Acting on the rows is one ledger request each:
/// Bring the ledger in line with the tree: file an item for every open task, keyed by the task's
/// IRI and about the task and the requirement it names; close every item whose task is checked
/// off or whose change is archived. Idempotent, and safe to run twice at once: the key lets the
/// ledger file at most one item per task, in the same store update that checks for one.
/// Returns one line per write, in the ledger's own words.
pub fn sync_ledger(kernel: &Kernel) -> Result<Vec<String>> {
let mut done = Vec::new();
for row in rows(&select(kernel, OPEN_TASKS)?) {
let [task, id, number, title, requirement] = &row[..] else {
return Err(Error::Endpoint(format!(
"an OPEN_TASKS row of {} columns",
row.len()
)));
};
let about = format!("{requirement} {task}");
let answer = ask(
kernel,
Verb::Sink,
"urn:iki:ledger:append",
&[
("content", &format!("{id} {number}: {title}")),
("about", about.trim()),
("labels", &format!("openspec,{id}")),
("key", task),
],
)?;
// `#4 <iri>` when it filed; `#4 <iri> existing open` when an item already carries the
// key, and nothing was written. Only a filing is a write.
match answer.split_whitespace().nth(2) {
None => done.push(format!("{} — {id} {number}", answer.trim())),
Some("existing") => {}
Some(other) => {
return Err(Error::Endpoint(format!(
"an append answered `{}`: neither filed nor existing ({other})",
answer.trim()
)))
}
}
}
for row in rows(&select(kernel, FINISHED)?) {
let [number, _id, _task, why] = &row[..] else {
return Err(Error::Endpoint(format!(
"a FINISHED row of {} columns",
row.len()
)));
};
let closed = ask(
kernel,
Verb::Sink,
"urn:iki:ledger:close",
&[("item", number), ("content", why)],
)?;
done.push(format!("{} — {why}", closed.trim()));
}
Ok(done)
}
That function is the one piece of real code in this part, and it is code for a reason: there is
no resource that turns a result set into ledger writes. Everything it decides is in the two
queries and the key; everything it does is the ledger’s append and close. And it is
idempotent by construction — a task with an item answers existing, an item that is closed is not
FINISHED — so a host can run it on every commit, or on a file watcher, and a second run with
nothing changed writes nothing.
“Writes nothing” is meant literally, and it matters to whoever reads the ledger in between. A
re-sync whose appends all answer existing changes nothing in the store, so it cuts no golden
thread, and a next that a reader has already asked for is still served from the cache after it.
A sync on every commit costs the ledger’s readers nothing on the commits that touch no task. The
walk below asserts it with Kernel::is_cached, which asks the cache without resolving anything;
the sync that does close an item cuts next like any other write.
The task’s IRI is the key
Each append carries key=<the task's IRI>. A key is the caller’s own name for an item, unique in
its ledger: if an item already carries it (or carried it and was deleted), nothing is filed,
and the answer names that item with a third word a filing never has — #1 <iri> existing open
instead of #1 <iri>. sync_ledger reads that word and reports only real filings.
The key is checked by the ledger itself, in the same store update that files the item, and that is
what makes the sync safe to run twice at once. The other way to be idempotent is to ask first —
“which open tasks does no item point at?”, a FILTER NOT EXISTS { ?item ledger:about ?task } in
the query — and then append. That is a check followed by an act, and two syncs started together
both see every task unfiled and both file it. Four syncs over this tree, with the query instead of
the key and each one’s question answered before any of them appended, file twelve items for three
tasks: every one of them files every task. That is the worst interleaving the race allows, made
certain by a barrier between the question and the appends, and
check_then_append_syncs_file_every_task_once_per_sync, in the crate’s tests, holds the figure to
it. With the key the same four file three, and concurrent_syncs_file_each_task_once holds that.
The keyed append arrived in ikigai-ledger 0.4.0; this part was first written against 0.3.0 with
the query, and carried that cost as a warning.
⚠ What the sync recognizes is the key, not about. An item a person files by hand about a
task, without the task’s IRI as its key, is not that task’s item to the sync: the next sync files
a second one beside it (an_item_filed_about_a_task_without_its_key_is_filed_again). To file a
task’s item by hand, file it as the sync would, with key=<the task's IRI>; the sync then answers
existing for it.
The walk
A copy of the committed tree, in a scratch directory, so a box can be checked and the change archived for real:
#![allow(unused)]
fn main() {
extern crate openspec_ledger;
extern crate ikigai_core;
use ikigai_core::{Capability, Iri, Request, Verb};
use openspec_ledger::{ask, kernel, lift_tree, sync_ledger, Workspace};
let tree = Workspace::new();
let host = kernel();
lift_tree(&host, &tree.tree()).unwrap();
// Three open tasks; 1.1 is already checked, so it is never filed.
let filed = sync_ledger(&host).unwrap();
assert_eq!(filed.len(), 3, "{filed:?}");
assert!(filed[0].starts_with("#1 urn:iki:ledger:default:item:") && filed[0].ends_with("add-undo 1.2"));
assert!(filed[2].ends_with("add-undo 2.1"));
assert!(sync_ledger(&host).unwrap().is_empty(), "a second sync files nothing");
// What the second sync was told, for each task: the key is taken, and nothing was filed.
let again = ask(&host, Verb::Sink, "urn:iki:ledger:append", &[
("content", "add-undo 1.2: Empty the last move's square and return the turn"),
("key", "urn:openspec:change:add-undo:task:1.2"),
]).unwrap();
let first = filed[0].split(" — ").next().unwrap();
assert_eq!(again, format!("{first} existing open\n"));
// Each is about its task and the requirement it implements.
let item = ask(&host, Verb::Source, "urn:iki:ledger:item:1", &[]).unwrap();
assert!(item.contains("key: urn:openspec:change:add-undo:task:1.2"), "{item}");
assert!(item.contains("about: urn:openspec:change:add-undo:task:1.2"), "{item}");
assert!(item.contains("about: urn:openspec:spec:moves:requirement:the-last-move-can-be-undone"));
// So the ledger can be asked by requirement: how many items implement it.
let undo = ask(&host, Verb::Source, "urn:iki:ledger:count", &[
("about", "urn:openspec:spec:moves:requirement:the-last-move-can-be-undone"),
]).unwrap();
assert_eq!(undo, "2\n");
// And `next` ranks them with everything else in the ledger.
let next = ask(&host, Verb::Source, "urn:iki:ledger:next", &[]).unwrap();
assert!(next.contains("ready: 3"), "{next}");
// A sync with nothing to do cuts nothing: that `next` is still in the cache after it.
let cached = || host.is_cached(
&Request::new(Verb::Source, Iri::parse("urn:iki:ledger:next").unwrap()),
&Capability::root(),
);
assert!(cached());
assert!(sync_ledger(&host).unwrap().is_empty());
assert!(cached(), "a re-sync that filed nothing cut next");
// Check off 1.2 in tasks.md, lift, sync: its item closes, with the reason.
tree.check("add-undo", "1.2");
lift_tree(&host, &tree.tree()).unwrap();
assert_eq!(sync_ledger(&host).unwrap(), ["closed #1 (done) — checked off in tasks.md"]);
assert!(!cached(), "a close is a write, and cuts next");
// Archive the change: its folder moves, the lift sees the new paths, the rest close.
tree.archive("add-undo", "2026-10-07");
lift_tree(&host, &tree.tree()).unwrap();
assert_eq!(sync_ledger(&host).unwrap(), [
"closed #2 (done) — archived with add-undo on 2026-10-07",
"closed #3 (done) — archived with add-undo on 2026-10-07",
]);
let next = ask(&host, Verb::Source, "urn:iki:ledger:next", &[]).unwrap();
assert!(next.starts_with("nothing is ready"), "{next}");
}
count takes the same filter items does (about, status, labels, …) and answers the number
alone: one COUNT query, without the listing’s page or the check items makes over the whole ledger
for an item it cannot read. It arrived in ikigai-ledger 0.9.0; items with the same about=
ends with the same total (2 item(s)) and the two items besides.
The labels name the change ([add-undo openspec]), the title carries the task’s number, and the
close reason is a comment on the item, so a person reading the ledger later sees why it closed
without opening the tree. Nothing in the tree was written by any of this: the ledger points at the
spec by IRI, and the spec does not know the ledger exists.
What about buys
Because an item names the requirement, the questions that cross the two are one query each. Which
requirement has the most open work? Which open item is about a requirement that a change has since
removed? Is there a requirement with scenarios and no item, no test, no change in flight? Each
is a SELECT over the same union select already asks, and none needs a new resource — which is
the point of keeping the spec, the work and the code’s annotations in one store as gonk does.
In gonk, and not in this page
gonk holds two of this chapter’s three spaces — the store and the ledger — and not the lift:
ikigai-markdown is not in its manifest, and what is not compiled into gonk cannot be reached
through it. So the split is the natural one. A host of your own links the lift and runs
lift_tree and sync_ledger as above, with its store and ledger names pointed at gonk’s socket
(as in Over the socket, where urn:gk:iki:store:load,
urn:gk:iki:store:graph-select and urn:gk:iki:ledger:append are all served to the owner).
The graphs then live in gonk’s durable store beside the ledger, and gonk’s own pages show the
items. The key needs a gonk whose ledger knows it, ikigai-ledger 0.4.0 or later, and count
one that links 0.9.0 or later; the gonk this book pins (gonk.rev)
links 0.9.0, so both are served there as urn:gk:iki:ledger:append and urn:gk:iki:ledger:count.
(This book’s checks run the in-process host above, not that arrangement.) gonk does not run the sync itself; doing that
on a watcher over a browse root’s openspec/ directory would be a separate piece of work.
There are no Run cells in this part, on purpose. The page’s kernel is the ledger over an in-memory
store, compiled to wasm, and the lift could join it — but urn:markdown:mapping:{name}, the name
this chapter installs the mapping under, is the one resource ikigai-markdown does not build
for wasm (there is no config home in a browser), and the tree itself would have to be baked into
the page. A page that ran a different mapping lookup over a frozen copy of the tree would be
showing something this chapter does not teach, so the Rust above is the runnable form.
What you built
The atoms are the tree’s files and the ledger’s graph. The spec graphs are derived from the
files and the items are derived from the spec graphs, each by a composition the host did not write;
OPEN_TASKS and FINISHED are views that span both; sync_ledger is the glue that acts on
them. Close an item by hand and it stays closed; delete the tree and the items still say what they
were about, by name.
Filing roborev findings
roborev reviews each commit with a coding agent and reports findings: a problem, a fix, a severity, and usually a file and line. Its reports are good at being read once. A ledger is good at what comes after: triage, a priority, a claim, a link to the item it blocks, a close with a reason. This bridge files every finding of every completed roborev review as a ledger item, without changing how roborev runs.
It is one command, ikigai-gonk roborev file, which roborev runs from a hook when a review
completes. This page runs it by hand, against a review saved to a file, which is exactly what
the hook hands it.
A review, as roborev hands it over
roborev’s agent returns a structured document, and what a hook receives is that document
rendered as Markdown, in this shape (save it as review.md):
## Summary
The change adds a retry loop around the store write, but one path retries without a bound and a second swallows the error it retries on.
**Agent assessment:** Fail
## Findings
### 1. Critical
**Location:** /work/demo/src/retry.rs:41-58
**Problem:** `write_with_retry` loops while the store answers `Busy` and has no attempt limit. Trigger: a writer that holds the RocksDB lock for longer than the caller waits (a second `ikigai-gonk` on the same store) spins this loop forever at 100% CPU.
**Fix:** Bound the loop (five attempts with backoff) and return the last `Busy` to the caller.
**Reported by:** codex, claude-code
### 2. High
**Location:** src/retry.rs:72
**Problem:** On the final attempt the error is logged and `Ok(())` is returned.
The caller therefore records a write that never happened. Trigger: any store refusal on the third attempt.
**Fix:** Return the error from the final attempt instead of `Ok(())`.
**Reported by:** codex
### 3. Medium
**Problem:** The retry delay is a fixed 100 ms, so two writers retrying together collide on every attempt.
**Fix:** Add jitter to the delay.
**Reported by:** claude-code
### 4. Low
**Location:** `src/retry.rs:12`
**Problem:** The doc comment says "retries three times" and the constant is 4.
**Fix:** Make the comment name the constant.
**Reported by:** codex
roborev file parses that rendering back, and accepts a finding only when rendering it again
reproduces the bytes exactly. A review that has drifted from the format (a new roborev that
renders differently, say) is refused, loudly, with nothing filed, rather than filed as a guess.
Filing it
Start a scratch gonk in a new empty directory, as in every chapter. The last chapter to start one was Explain, with a mounted model (the review and spec chapters run none); if that gonk is still running, stop it first, since this one takes the same port:
$ ikigai-gonk --config-home "$PWD/.config/ikigai" --data-home "$PWD/.ikigai" --port 1070 --no-quic --no-backup
ikigai-gonk 0.1.0 — holding the store at …/.ikigai/store
…
--dry-run prints what it would file, each append’s arguments with the key among them, and
touches nothing. It does not ask gonk anything, so it says would file for a finding that is
already there, and it does not check that the door grants the ledger; the real run is the one
that finds out both:
$ ikigai-gonk roborev file --gonk http://127.0.0.1:1070 --ledger default --root demo --repo-path /work/demo --job 42 --sha 1a2b3c4 --agent codex --findings - --dry-run < review.md
skipped low below --min-severity medium: The doc comment says "retries three times" and the constant is 4.
would file critical: `write_with_retry` loops while the store answers `Busy` and has no attempt limit. Trigger: a writer…
key=urn:roborev:finding:9b1fa066c174c6e500245e3a48d8eae8
labels=roborev,critical
priority=0
about=urn:repo:demo:file:src/retry.rs urn:roborev:finding:9b1fa066c174c6e500245e3a48d8eae8
author=roborev
revision=1a2b3c4
would file high: On the final attempt the error is logged and `Ok(())` is returned.
key=urn:roborev:finding:f8ac8944363de4badd880f8a593b1ccc
labels=roborev,high
priority=1
about=urn:repo:demo:file:src/retry.rs urn:roborev:finding:f8ac8944363de4badd880f8a593b1ccc
author=roborev
revision=1a2b3c4
would file medium: The retry delay is a fixed 100 ms, so two writers retrying together collide on every attempt.
key=urn:roborev:finding:9f1fdc5febfc449e1de32552a5365e54
labels=roborev,medium
priority=2
about=urn:roborev:finding:9f1fdc5febfc449e1de32552a5365e54
author=roborev
revision=1a2b3c4
Each line of that is a decision the command made, and each is worth reading once:
- The low finding is skipped. roborev renders low findings even on a passing review, and
one ledger item per nit buries the findings worth a person’s time.
--min-severity lowfiles them;highfiles less. - Severity becomes a label and a priority: critical 0, high 1, medium 2, low 3.
- A location becomes an
about./work/demo/src/retry.rs:41-58is under--repo-path, so it is made relative and named as the browse resource for that file,urn:repo:demo:file:src/retry.rs, wheredemois the browse root name gonk serves the repository under (--root). That is the join browse’s file page makes, so the item shows up beside the file. The medium finding named no file, so it gets no fileaboutrather than a guessed one. - Every finding gets a key,
urn:roborev:finding:{hash}, over the root, the file and the problem’s words with their whitespace collapsed. It is filed as the append’skeyand again as anabout, and it is how the ledger knows a finding is already filed.
Now for real:
$ ikigai-gonk roborev file --gonk http://127.0.0.1:1070 --ledger default --root demo --repo-path /work/demo --job 42 --sha 1a2b3c4 --agent codex --findings - < review.md
skipped low below --min-severity medium: The doc comment says "retries three times" and the constant is 4.
filed critical #1 `write_with_retry` loops while the store answers `Busy` and has no attempt limit. Trigger: a writer…
filed high #2 On the final attempt the error is logged and `Ok(())` is returned.
filed medium #3 The retry delay is a fixed 100 ms, so two writers retrying together collide on every attempt.
$ curl -s http://127.0.0.1:1070/iki/ledger/item/1
#1 open p0 `write_with_retry` loops while the store answers `Busy` and has no attempt limit. Trigger: a writer… [critical roborev]
iri: urn:iki:ledger:default:item:…
key: urn:roborev:finding:9b1fa066c174c6e500245e3a48d8eae8
filed: … by roborev
updated: …
revision: 1a2b3c4
about: urn:repo:demo:file:src/retry.rs
about: urn:roborev:finding:9b1fa066c174c6e500245e3a48d8eae8
`write_with_retry` loops while the store answers `Busy` and has no attempt limit. Trigger: a writer that holds the RocksDB lock for longer than the caller waits (a second `ikigai-gonk` on the same store) spins this loop forever at 100% CPU.
Fix: Bound the loop (five attempts with backoff) and return the last `Busy` to the caller.
- severity: critical
- location: /work/demo/src/retry.rs:41-58
- commit: 1a2b3c4
- roborev job: 42 (`roborev show 42`)
- agent: codex
- reported by: codex, claude-code
- key: urn:roborev:finding:9b1fa066c174c6e500245e3a48d8eae8
The title is the problem’s first line, cut near a hundred characters; the body keeps the whole
problem, the fix, and where it came from. The item is an ordinary ledger item from here on:
next ranks it, a person claims it, and closing it is a decision with a reason rather than a
report scrolling away.
The hook
What ran that by hand is one [[hooks]] entry in roborev’s config (~/.roborev/config.toml,
or a repository’s .roborev.toml):
[[hooks]]
event = "review.completed"
command = "ikigai-gonk roborev file --gonk http://127.0.0.1:1062 --ledger default --root {repo_name} --repo-path {repo} --job {job_id} --sha {sha} --agent {agent} --findings {findings}"
roborev replaces each {…} with a single-quoted value before sh -c runs the line, so
nothing in a finding can escape into the shell, and {findings} is the whole Markdown review
as one argument. {repo_name} is right for --root when the repository’s name and its browse
root’s name match; otherwise write the root’s name in. (The URL is a real gonk’s port, 1062,
because that is what your hook will name. The page uses 1070.)
So the hook’s own invocation, for the review above, is this. It is the third time this page has handed gonk the same review, and it files nothing:
$ ikigai-gonk roborev file --gonk http://127.0.0.1:1070 --ledger default --root 'demo' --repo-path '/work/demo' --job '42' --sha '1a2b3c4' --agent 'codex' --findings "$(cat review.md)"
skipped low below --min-severity medium: The doc comment says "retries three times" and the constant is 4.
already critical #1 (open) urn:roborev:finding:9b1fa066c174c6e500245e3a48d8eae8
already high #2 (open) urn:roborev:finding:f8ac8944363de4badd880f8a593b1ccc
already medium #3 (open) urn:roborev:finding:9f1fdc5febfc449e1de32552a5365e54
Each finding is one request, a keyed append (append key=…): the ledger checks the key and
files the item in the same store update, and a key that is already taken, by an open, closed or
deleted item, answers that item and files nothing. The command reads that answer through the
ledger’s JSON face, not its plain text, and prints already with the item’s status. So a payload
run twice files once, a later review that carries a finding forward word for word files nothing
new, and two hooks filing the same finding at the same instant file it once. roborev runs every
hook in its own goroutine, so that last one is not hypothetical; gonk’s own tests race eight
hooks on one finding and get one item. One limit, stated rather than discovered: the same defect
in different words is a new key and a second item.
A roborev fix or task job fires the same event, and its output is prose, not a review. The
command says so and files nothing, with exit status 0, so roborev’s log stays quiet:
$ printf 'Applied the fix and ran the tests.\n' | ikigai-gonk roborev file --gonk http://127.0.0.1:1070 --ledger default --root demo --findings -
not a structured review (a fix or task job?): nothing to file
Whose ledger it files into
The command speaks to gonk’s HTTP door as its anonymous loopback caller, which holds the read
and write tokens of the ledgers in gonk.http.ledger and nothing else. The keyed append is a
write, and the door grants a listed ledger’s read and write together. A ledger the door does not
grant is refused at that first append, and the command stops with exit status 1 and names the
setting:
$ ikigai-gonk roborev file --gonk http://127.0.0.1:1070 --ledger reviews --root demo --findings - < review.md
skipped low below --min-severity medium: The doc comment says "retries three times" and the constant is 4.
ikigai-gonk: gonk refused POST /iki/ledger/reviews/append (403): denied: this capability does not hold `urn:cap:ledger:write:reviews`. A ledger grant names exactly one ledger — holding a grant over another ledger satisfies the declared family `urn:cap:ledger:write:*` but not this resource, which is the point of naming them. The HTTP door grants an anonymous loopback caller the read and write tokens of the ledgers in `gonk.http.ledger` and nothing else; filing needs both (`ikigai-gonk grants <ledger> write` prints them, and listing the ledger there grants them)
To keep findings in a ledger of their own, list it beside default in the config home’s
config.toml and restart gonk:
gonk.http.ledger = "default"
gonk.http.ledger = "reviews"
roborev logs a hook’s failure and its output in its daemon log, and never retries, so a hook that is refused is visible there and nowhere else. That is worth a look the first time a review completes.
⚠ One more limit, from roborev’s side. A panel review fires review.completed once per
member and once more for the synthesis, with the findings merged and reworded, and a hook cannot
tell which is which. So a panel files each member’s findings and the synthesis’s, deduplicated
only where the words match. Point the hook at single-agent reviews if that is noise.
Importing a kata ledger
kata keeps issues in its own database, for people and for
agents. ikigai-ledger took its model from kata — issues with authors, priorities, labels,
comments, closes with a reason, and parent, blocks and related links — so most of an
import is one to one. This chapter imports one, runs it again to show that it converges, and
says plainly what does not survive the trip.
An export
In a new empty directory, as in every chapter: if the gonk from Filing roborev findings is still running, stop it first, since this one takes the same port.
kata export writes the database out as JSON Lines, one record per line, each with a kind
(stop kata’s daemon first, or pass --allow-running-daemon):
$ kata export --output kata.jsonl
Here is a small one: a project called flight, four issues (one closed wontfix, one deleted),
three comments, three labels, four links, and an event. Save it as kata.jsonl:
{"kind":"meta","data":{"key":"export_version","value":"27"}}
{"kind":"meta","data":{"key":"instance_uid","value":"01K6Z0000000000000000INST1"}}
{"kind":"meta","data":{"key":"schema_version","value":"27"}}
{"kind":"project","data":{"created_at":"2026-09-01T12:00:00.000Z","id":1,"metadata":{},"name":"flight","revision":1,"uid":"01K6Z00000000000000000PRJ1"}}
{"kind":"issue","data":{"author":"chris","body":"The loop should stop when the queue is empty.\n\nSee the flight log.","closed_at":null,"closed_reason":null,"content_revision":1,"created_at":"2026-09-02T09:15:00.000Z","deleted_at":null,"id":1,"metadata":{},"owner":"chris","priority":1,"project_id":1,"revision":3,"short_id":"0001","status":"open","title":"Stop the loop on an empty queue","uid":"01K6Z000000000000000000001","updated_at":"2026-09-03T10:00:00.000Z"}}
{"kind":"issue","data":{"author":"chris","body":"","closed_at":"2026-09-04T08:00:00.000Z","closed_reason":"wontfix","content_revision":0,"created_at":"2026-09-02T09:20:00.000Z","deleted_at":null,"id":2,"metadata":{},"owner":null,"priority":3,"project_id":1,"revision":2,"short_id":"0002","status":"closed","title":"Rename the flight binary","uid":"01K6Z000000000000000000002","updated_at":"2026-09-04T08:00:00.000Z"}}
{"kind":"issue","data":{"author":"agent-7","body":"Child of 0001.","closed_at":null,"closed_reason":null,"content_revision":0,"created_at":"2026-09-02T11:00:00.000Z","deleted_at":null,"id":3,"metadata":{},"owner":null,"project_id":1,"revision":1,"short_id":"0003","status":"open","title":"Write the empty-queue test","uid":"01K6Z000000000000000000003","updated_at":"2026-09-02T11:00:00.000Z"}}
{"kind":"issue","data":{"author":"chris","body":"Filed by mistake.","closed_at":null,"closed_reason":null,"content_revision":0,"created_at":"2026-09-02T12:00:00.000Z","deleted_at":"2026-09-02T12:05:00.000Z","id":4,"metadata":{},"owner":null,"priority":2,"project_id":1,"revision":2,"short_id":"0004","status":"open","title":"A mistake","uid":"01K6Z000000000000000000004","updated_at":"2026-09-02T12:05:00.000Z"}}
{"kind":"comment","data":{"author":"chris","body":"Seen twice this week.","created_at":"2026-09-02T10:00:00.000Z","id":1,"issue_id":1,"uid":"01K6Z0000000000000000000C1"}}
{"kind":"comment","data":{"author":"chris","body":"Agent 7 is on it.","created_at":"2026-09-03T10:00:00.000Z","id":2,"issue_id":1,"teammate":"agent-7","uid":"01K6Z0000000000000000000C2"}}
{"kind":"comment","data":{"author":"chris","body":"Never mind.","created_at":"2026-09-02T12:04:00.000Z","id":3,"issue_id":4,"uid":"01K6Z0000000000000000000C3"}}
{"kind":"issue_label","data":{"author":"chris","created_at":"2026-09-02T09:15:00.000Z","issue_id":1,"label":"bug"}}
{"kind":"issue_label","data":{"author":"chris","created_at":"2026-09-02T09:15:00.000Z","issue_id":1,"label":"area:loop"}}
{"kind":"issue_label","data":{"author":"chris","created_at":"2026-09-02T09:20:00.000Z","issue_id":2,"label":"chore"}}
{"kind":"link","data":{"author":"agent-7","created_at":"2026-09-02T11:00:00.000Z","from_issue_id":3,"from_issue_uid":"01K6Z000000000000000000003","id":1,"to_issue_id":1,"to_issue_uid":"01K6Z000000000000000000001","type":"parent"}}
{"kind":"link","data":{"author":"agent-7","created_at":"2026-09-02T11:01:00.000Z","from_issue_id":3,"from_issue_uid":"01K6Z000000000000000000003","id":2,"to_issue_id":1,"to_issue_uid":"01K6Z000000000000000000001","type":"blocks"}}
{"kind":"link","data":{"author":"chris","created_at":"2026-09-02T09:21:00.000Z","from_issue_id":1,"from_issue_uid":"01K6Z000000000000000000001","id":3,"to_issue_id":2,"to_issue_uid":"01K6Z000000000000000000002","type":"related"}}
{"kind":"link","data":{"author":"chris","created_at":"2026-09-02T12:01:00.000Z","from_issue_id":1,"from_issue_uid":"01K6Z000000000000000000001","id":4,"to_issue_id":4,"to_issue_uid":"01K6Z000000000000000000004","type":"related"}}
{"kind":"event","data":{"actor":"chris","created_at":"2026-09-02T09:15:00.000Z","id":1,"issue_id":1,"payload":{},"project_id":1,"type":"issue.created","uid":"01K6Z0000000000000000000E1"}}
{"kind":"sqlite_sequence","data":{"name":"issues","seq":4}}
Into a ledger of its own
The importer files over gonk’s HTTP door, like roborev file, as its anonymous loopback caller,
so the ledger it files into must be one that door grants. Start gonk granting kata as well as
default (--http-ledger replaces the configured list, so name both; in a config file it is
one gonk.http.ledger line each):
$ ikigai-gonk --config-home "$PWD/.config/ikigai" --data-home "$PWD/.ikigai" --port 1070 --no-quic --no-backup --http-ledger default --http-ledger kata
ikigai-gonk 0.1.0 — holding the store at …/.ikigai/store
http http://localhost:1070/ — loopback (127.0.0.1:1070); anonymous read+write: default, kata; 0 passkey(s); anonymous SPARQL budget 1000 ms
…
--dry-run reads the export, asks the ledger for each issue’s key exactly as the real run
does, and prints the plan; it writes nothing. So it says already for an issue that is there,
reports the comments, closes and links a real run would add, and is refused, like the real run,
on a ledger the door does not grant. (roborev file --dry-run is the other way round: it asks
gonk nothing.) ⚠ The one thing a read cannot tell: an issue whose item was deleted in the
ledger answers like one never filed, so the dry run says would file where the real run leaves it
alone. Into an empty ledger, the plan is every issue:
$ ikigai-gonk kata import kata.jsonl --gonk http://127.0.0.1:1070 --ledger kata --dry-run
kata export version 27: 4 issue(s), 4 link(s) (dry run: reads only)
would file kata 0001 Stop the loop on an empty queue
key=urn:kata:issue:01K6Z000000000000000000001
about=urn:kata:issue:01K6Z000000000000000000001
author=chris
priority=1
labels=bug,area:loop
then 2 comment(s)
would file kata 0002 Rename the flight binary
key=urn:kata:issue:01K6Z000000000000000000002
about=urn:kata:issue:01K6Z000000000000000000002
author=chris
priority=3
labels=chore
then 0 comment(s), close
would file kata 0003 Write the empty-queue test
key=urn:kata:issue:01K6Z000000000000000000003
about=urn:kata:issue:01K6Z000000000000000000003
author=agent-7
then 0 comment(s)
deleted kata 0004 not imported
would linked 01K6Z000000000000000000003 parent 01K6Z000000000000000000001
would linked 01K6Z000000000000000000003 blocks 01K6Z000000000000000000001
would linked 01K6Z000000000000000000001 related 01K6Z000000000000000000002
skipped link 01K6Z000000000000000000001 related 01K6Z000000000000000000004: an end was not imported, or the type is not one the ledger has
dry run, nothing written: 3 would be filed, 0 already there; 2 comment(s), 1 close(s), 3 link(s) would be added; 1 deleted issue(s) and 1 link(s) skipped; not read: event 1, sqlite_sequence 1
and without it, the import:
$ ikigai-gonk kata import kata.jsonl --gonk http://127.0.0.1:1070 --ledger kata
kata export version 27: 4 issue(s), 4 link(s)
filed kata#1 kata 0001 Stop the loop on an empty queue
commented kata#1 kata comment 01K6Z0000000000000000000C1
commented kata#1 kata comment 01K6Z0000000000000000000C2
filed kata#2 kata 0002 Rename the flight binary
closed kata#2 kata 0002
filed kata#3 kata 0003 Write the empty-queue test
deleted kata 0004 not imported
linked kata#3 parent kata#1
linked kata#3 blocks kata#1
linked kata#1 related kata#2
skipped link 01K6Z000000000000000000001 related 01K6Z000000000000000000004: an end was not imported, or the type is not one the ledger has
3 filed, 0 already there; 2 comment(s), 1 close(s), 3 link(s) added; 1 deleted issue(s) and 1 link(s) skipped; not read: event 1, sqlite_sequence 1
Issues first, so that every link has two items to join, then the links. The soft-deleted issue is not imported, and neither is anything that hangs from it: its comment, and the link to it, which is reported as skipped. The last line counts everything the importer did not read, by kind, so nothing in the export is silently dropped.
The links came across as links, which means next already knows the order of the work:
$ curl -s http://127.0.0.1:1070/iki/ledger/kata/next
1. kata#3 open p- Write the empty-queue test
unprioritized — no priority set, so it ranks below every item that has one; last updated …
policy: priority-recency (weighs priority, recency, number)
ready: 1 excluded: 1
not kata#1: blocked by kata#3
Again, and later
Every issue is filed with one request, a keyed append whose key is urn:kata:issue:<uid>
(also filed as an about): the ledger checks the key and files the item in the same store
update, and a key already taken answers the item that holds it and files nothing. So the same
export imported twice files nothing:
$ ikigai-gonk kata import kata.jsonl --gonk http://127.0.0.1:1070 --ledger kata
kata export version 27: 4 issue(s), 4 link(s)
already kata#1 kata 0001
already kata#2 kata 0002
already kata#3 kata 0003
deleted kata 0004 not imported
skipped link 01K6Z000000000000000000001 related 01K6Z000000000000000000004: an end was not imported, or the type is not one the ledger has
0 filed, 3 already there; 0 comment(s), 0 close(s), 0 link(s) added; 1 deleted issue(s) and 1 link(s) skipped; not read: event 1, sqlite_sequence 1
It does more than skip. A run converges on the items already there: a comment whose kata
id is not on its item is added, an issue closed in kata whose item is open is closed, and a
missing link is made. So a later export, with work done in kata since, brings exactly that work
across. Here a comment is added to the export, as a later kata export would have it, and the
dry run, which asks the ledger, names exactly the one comment the import then adds:
$ cp kata.jsonl later.jsonl
$ printf '%s\n' '{"kind":"comment","data":{"author":"agent-7","body":"The test fails on an empty queue, as it should.","created_at":"2026-09-05T08:30:00.000Z","id":4,"issue_id":3,"uid":"01K6Z0000000000000000000C4"}}' >> later.jsonl
$ ikigai-gonk kata import later.jsonl --gonk http://127.0.0.1:1070 --ledger kata --dry-run
kata export version 27: 4 issue(s), 4 link(s) (dry run: reads only)
already kata#1 kata 0001
already kata#2 kata 0002
already kata#3 kata 0003
would commented kata#3 kata comment 01K6Z0000000000000000000C4
deleted kata 0004 not imported
skipped link 01K6Z000000000000000000001 related 01K6Z000000000000000000004: an end was not imported, or the type is not one the ledger has
dry run, nothing written: 0 would be filed, 3 already there; 1 comment(s), 0 close(s), 0 link(s) would be added; 1 deleted issue(s) and 1 link(s) skipped; not read: event 1, sqlite_sequence 1
$ ikigai-gonk kata import later.jsonl --gonk http://127.0.0.1:1070 --ledger kata
kata export version 27: 4 issue(s), 4 link(s)
already kata#1 kata 0001
already kata#2 kata 0002
already kata#3 kata 0003
commented kata#3 kata comment 01K6Z0000000000000000000C4
deleted kata 0004 not imported
skipped link 01K6Z000000000000000000001 related 01K6Z000000000000000000004: an end was not imported, or the type is not one the ledger has
0 filed, 3 already there; 1 comment(s), 0 close(s), 0 link(s) added; 1 deleted issue(s) and 1 link(s) skipped; not read: event 1, sqlite_sequence 1
⚠ What does not converge: an issue’s title, body, labels and priority are written once, when it is filed. An edit made in kata later does not reach the item. Two imports of one export at the same instant file each issue once, because the filing is the keyed append; but only the filing is atomic. The convergence reads the item and then writes, and the ledger has no keyed comment, so two imports racing on the same new comment can both add it.
What is lost, and why
$ curl -s http://127.0.0.1:1070/iki/ledger/kata/item/1 | grep -E 'filed:|Imported from'
filed: … by chris
Imported from kata flight#0001 (01K6Z000000000000000000001), filed 2026-09-02T09:15:00.000Z by chris; owner chris.
The two filed times disagree, and that is the loss in one line. The ledger stamps every item
with the time it was filed and mints its own number, because those are facts the ledger
vouches for, and it has no way today to record an item as filed at some other time by some
other system. So kata’s created_at, its short_id (0001) and its uid survive only as
text, in the provenance line every imported body ends with, and in each comment’s closing
note. The uid is also the item’s key (and an about), which is what makes a re-run find it.
Carried as text only, or not at all:
| kata | in the ledger |
|---|---|
created_at, short_id, uid | the provenance line; the uid is also the item’s key and an about |
| a comment’s time | its closing note (kata comment <uid>, <created_at>) |
owner | the provenance line, not a claim |
updated_at, metadata, who added a label or link and when | not carried |
| the event history | not carried; counted on the last line (event 1) |
| sync bindings, federation state, claims, purge logs | not carried; counted on the last line |
A faithful import, keeping kata’s times and ids as data rather than text and carrying the history, needs the ledger to accept an item’s original times and a foreign id. That is tracked as ledger item 774. Until then the import is lossy in exactly the ways above, and says so.
Checkout
A browse root has to be on disk before gonk starts: gonk refuses to start on a root that names
a missing directory, because that is the shape of a typo. On a new machine that means cloning
every repository and writing one gonk.browse.root line for each. ikigai-gonk checkout
does both, and keeps the clones current afterwards.
It is a command, like roborev file: it opens no store and binds no door, and the server
itself has no code that fetches anything. So this page does not start a gonk at all.
Two repositories to check out
A real checkout clones from GitHub or another server. This page makes two repositories of its
own to stand in for them, and clones them by file:// URL, which git treats like any other
remote:
$ git init -q -b main upstream/notes
$ printf '# Notes\n' > upstream/notes/README.md
$ git -C upstream/notes add README.md
$ git -C upstream/notes -c user.name='A Reader' -c user.email=reader@example.com commit -qm 'Start the notes'
$ git init -q -b main upstream/specs
$ printf '# Specs\n' > upstream/specs/README.md
$ git -C upstream/specs add README.md
$ git -C upstream/specs -c user.name='A Reader' -c user.email=reader@example.com commit -qm 'Start the specs'
Clone, and write the roots
$ ikigai-gonk checkout --config-home "$PWD/.config/ikigai" --data-home "$PWD/.ikigai" "file://$PWD/upstream/notes" "file://$PWD/upstream/specs"
cloned notes at … …/.ikigai/checkouts/notes
cloned specs at … …/.ikigai/checkouts/specs
browse roots:
gonk.browse.root = "notes=…/.ikigai/checkouts/notes"
gonk.browse.root = "specs=…/.ikigai/checkouts/specs"
not written: add the lines above to …/.config/ikigai/config.toml, or run again with --write-config
Each URL is cloned into checkouts/<name> under the data home (~/.ikigai/checkouts/<name>
on a machine that names no homes), where <name> is the URL’s last path segment without
.git, and it is also the browse root’s name. name=url sets a different one
(kata=git@github.com:kenn-io/kata.git), and --dir puts the clones somewhere else.
--config-home and --data-home are the server’s two flags, so the checkout writes the
config.toml the server will read and clones under the data home it will use; this page names
both inside its own directory. A path under your home directory is printed and written with
~, and any other in full, which is why the lines above begin with …: where this page’s
directory is depends on where you run it. (Before gonk PR 106, ledger #918, checkout had
neither flag and read the process’s homes, so this page had to set HOME for it.)
Without --write-config the lines are only printed. With it, the missing ones are appended to
the config home’s config.toml:
$ ikigai-gonk checkout --config-home "$PWD/.config/ikigai" --data-home "$PWD/.ikigai" "file://$PWD/upstream/notes" "file://$PWD/upstream/specs" --write-config
current notes at … …/.ikigai/checkouts/notes
current specs at … …/.ikigai/checkouts/specs
browse roots:
gonk.browse.root = "notes=…/.ikigai/checkouts/notes"
gonk.browse.root = "specs=…/.ikigai/checkouts/specs"
…/.config/ikigai/config.toml:
added gonk.browse.root = "notes=…/.ikigai/checkouts/notes"
added gonk.browse.root = "specs=…/.ikigai/checkouts/specs"
gonk reads gonk.browse.root at startup only: restart gonk to serve the new root(s).
$ cat .config/ikigai/config.toml
gonk.browse.root = "notes=…/.ikigai/checkouts/notes"
gonk.browse.root = "specs=…/.ikigai/checkouts/specs"
The second run found both clones already there and current, so it cloned nothing, and only
wrote the config. The rules for that file are conservative: when it exists, it is copied beside
itself before anything is appended (a backup line names the copy), no other line is touched,
a root already configured for the same directory is left alone, and a name already configured
for a different directory is a conflict that writes nothing for that name.
Keeping them current
Upstream moves on:
$ printf 'A second line.\n' >> upstream/notes/README.md
$ git -C upstream/notes -c user.name='A Reader' -c user.email=reader@example.com commit -qam 'A second line'
Run the same command again, and each clone is fetched and its default branch fast-forwarded:
$ ikigai-gonk checkout --config-home "$PWD/.config/ikigai" --data-home "$PWD/.ikigai" "file://$PWD/upstream/notes" "file://$PWD/upstream/specs"
updated notes …..… (1 commit(s), fast-forward) …/.ikigai/checkouts/notes
current specs at … …/.ikigai/checkouts/specs
browse roots:
gonk.browse.root = "notes=…/.ikigai/checkouts/notes"
gonk.browse.root = "specs=…/.ikigai/checkouts/specs"
not written: add the lines above to …/.config/ikigai/config.toml, or run again with --write-config
A root gonk already serves needs no restart after an update: gonk watches it, and a fast-forward is a change on disk like any other (the watcher is shown working in Browse roots). Only a NEW root needs the restart the first run asked for.
Every checkout, without the URLs
Repeating the URL list is fine once and tiresome on a timer. --all updates every checkout
already in the managed directory, each from its own origin and under its own directory name.
To see what else it reports, check out a third repository and do not write its root:
$ git init -q -b main upstream/tools
$ printf '# Tools\n' > upstream/tools/README.md
$ git -C upstream/tools add README.md
$ git -C upstream/tools -c user.name='A Reader' -c user.email=reader@example.com commit -qm 'Start the tools'
$ ikigai-gonk checkout --config-home "$PWD/.config/ikigai" --data-home "$PWD/.ikigai" "file://$PWD/upstream/tools"
cloned tools at … …/.ikigai/checkouts/tools
browse roots:
gonk.browse.root = "tools=…/.ikigai/checkouts/tools"
not written: add the lines above to …/.config/ikigai/config.toml, or run again with --write-config
$ printf 'A third line.\n' >> upstream/notes/README.md
$ git -C upstream/notes -c user.name='A Reader' -c user.email=reader@example.com commit -qam 'A third line'
$ ikigai-gonk checkout --config-home "$PWD/.config/ikigai" --data-home "$PWD/.ikigai" --all
updated notes …..… (1 commit(s), fast-forward) …/.ikigai/checkouts/notes
current specs at … …/.ikigai/checkouts/specs
current tools at … …/.ikigai/checkouts/tools
coverage of …/.ikigai/checkouts by the roots in …/.config/ikigai/config.toml:
unused tools …/.ikigai/checkouts/tools no gonk.browse.root points into it: gonk does not browse it (`checkout <url> --write-config` adds its line)
Each checkout gets its line, in the same words as before, and then a coverage report that
changes nothing: unused for a checkout no gonk.browse.root points into (gonk does not
browse it, so it is fetched for nothing), and missing for a root that points into the managed
directory at nothing (gonk will refuse to start until it exists). Coverage is information; it
does not change the exit status.
What --all never does is as deliberate: it clones nothing, writes no config (--all --write-config is refused), and takes no URLs. And a bare checkout with no URLs is an
error that names --all, rather than quietly being it, because a command that fetches every
repository should say so on its own line.
And nothing else
A fast-forward is all it does. A checkout with local changes (untracked files included), on another branch, or with commits upstream does not have is refused and left exactly as it was: nothing is reset, stashed, merged or discarded.
$ printf 'work in progress\n' > .ikigai/checkouts/specs/wip.txt
$ ikigai-gonk checkout --config-home "$PWD/.config/ikigai" --data-home "$PWD/.ikigai" --all
current notes at … …/.ikigai/checkouts/notes
REFUSED specs …/.ikigai/checkouts/specs
it has local changes (1 path(s); `git -C …/.ikigai/checkouts/specs status`). A managed checkout is fast-forwarded only; nothing was fetched or changed
current tools at … …/.ikigai/checkouts/tools
coverage of …/.ikigai/checkouts by the roots in …/.config/ikigai/config.toml:
unused tools …/.ikigai/checkouts/tools no gonk.browse.root points into it: gonk does not browse it (`checkout <url> --write-config` adds its line)
$ cat .ikigai/checkouts/specs/wip.txt
work in progress
Each checkout is reported on its own and the rest still run. Any refusal or failure makes the exit status 1, so a scheduled run that something has stopped is visible to whatever ran it, and a refusal names the command that shows what is in the way.
git runs with its standard input closed and GIT_TERMINAL_PROMPT=0, and ssh in BatchMode
unless you have already said how to run ssh, so a repository that wants a password fails with
git’s own message instead of waiting for one. Credential helpers and ssh agents work as
usual.
⚠ A managed clone shows its default branch. Work happening on other branches or in other worktrees (an agent loop’s worktrees, say) is not what gonk browses there; point a root at that worktree by hand if that is what you want to read.
On a timer
Nothing in the server fetches, so a periodic update is checkout --all run by the system’s
scheduler, and gonk needs no restart for it. On macOS, a launchd agent beside gonk’s own
(~/Library/LaunchAgents/dev.ikigai-rs.gonk-checkout.plist), every fifteen minutes:
<plist version="1.0"><dict>
<key>Label</key><string>dev.ikigai-rs.gonk-checkout</string>
<key>ProgramArguments</key><array>
<string>/bin/sh</string>
<string>-c</string>
<string>date; exec /Users/you/.cargo/bin/ikigai-gonk checkout --all</string>
</array>
<key>StartInterval</key><integer>900</integer>
<key>StandardOutPath</key><string>/tmp/ikigai-gonk-checkout.log</string>
<key>StandardErrorPath</key><string>/tmp/ikigai-gonk-checkout.log</string>
</dict></plist>
$ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/dev.ikigai-rs.gonk-checkout.plist
On Linux, the same as a crontab line (crontab -e):
*/15 * * * * (date; $HOME/.cargo/bin/ikigai-gonk checkout --all) >> /tmp/ikigai-gonk-checkout.log 2>&1
The date is there because the command prints no time of its own and neither scheduler adds
one. Under either, git runs without a terminal: an HTTPS remote needs a credential helper and
an SSH remote a key the agent already holds, or that checkout is a FAILED line with git’s
own message rather than a hang.
A fresh machine, end to end
The three bridges in the order a new machine needs them: the repositories first, then gonk serving them, then the work already tracked elsewhere brought in, then the hook that keeps new review findings arriving. Every step is a command from the last three chapters, and this page runs them in sequence in one scratch home, so the sequence itself is checked, not only its parts.
Before it: gonk and ikigai installed at the versions this book pins
(Which gonk), and git. In a new empty directory, as in every
chapter: if the gonk from Importing a kata ledger
is still running, stop it first, since this one takes the same port. (This page is a fresh machine:
none of the last three chapters’ directories is reused.)
1. The repositories
On a real machine these are URLs on GitHub. Here, two repositories made on the spot stand in
for them, cloned by file:// URL:
$ git init -q -b main upstream/flight
$ printf '# Flight\n' > upstream/flight/README.md
$ git -C upstream/flight add README.md
$ git -C upstream/flight -c user.name='A Reader' -c user.email=reader@example.com commit -qm 'Start flight'
$ git init -q -b main upstream/notes
$ printf '# Notes\n' > upstream/notes/README.md
$ git -C upstream/notes add README.md
$ git -C upstream/notes -c user.name='A Reader' -c user.email=reader@example.com commit -qm 'Start the notes'
Clone them and write their roots in one command:
$ ikigai-gonk checkout --config-home "$PWD/.config/ikigai" --data-home "$PWD/.ikigai" "file://$PWD/upstream/flight" "file://$PWD/upstream/notes" --write-config
cloned flight at … …/.ikigai/checkouts/flight
cloned notes at … …/.ikigai/checkouts/notes
browse roots:
gonk.browse.root = "flight=…/.ikigai/checkouts/flight"
gonk.browse.root = "notes=…/.ikigai/checkouts/notes"
…/.config/ikigai/config.toml:
added gonk.browse.root = "flight=…/.ikigai/checkouts/flight"
added gonk.browse.root = "notes=…/.ikigai/checkouts/notes"
gonk reads gonk.browse.root at startup only: restart gonk to serve the new root(s).
2. gonk, serving them
gonk reads its roots when it starts, which is what the checkout’s last line says. If it is
already running as a service, restart it the way that service is run; under launchd, with the
label your plist gives it (dev.ikigai-rs.gonk here; the checkout stopped printing one, because a
label is one operator’s choice):
$ launchctl kickstart -k gui/$(id -u)/dev.ikigai-rs.gonk
Here it starts for the first time, with the same two homes the checkout was given, so it reads
the config.toml the checkout wrote:
$ ikigai-gonk --config-home "$PWD/.config/ikigai" --data-home "$PWD/.ikigai" --port 1070 --no-quic --no-backup
ikigai-gonk 0.1.0 — holding the store at …/.ikigai/store
http http://localhost:1070/ — loopback (127.0.0.1:1070); anonymous read+write: default; 0 passkey(s); anonymous SPARQL budget 1000 ms
browse urn:repo:{flight (watched), notes (watched)}:* — annotations and archive in <urn:iki:browse:graph:default>
…
Both roots, both watched. A read through the socket says they are what was cloned:
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:repo:flight:file:README.md'
# Flight
[uncacheable]
3. The work already tracked
A kata export from the old tracker, here two issues, one blocking the other:
{"kind":"meta","data":{"key":"export_version","value":"27"}}
{"kind":"project","data":{"created_at":"2026-09-01T12:00:00.000Z","id":1,"metadata":{},"name":"flight","revision":1,"uid":"01K6Z00000000000000000PRJ1"}}
{"kind":"issue","data":{"author":"chris","body":"The loop should stop when the queue is empty.","closed_at":null,"closed_reason":null,"content_revision":0,"created_at":"2026-09-02T09:15:00.000Z","deleted_at":null,"id":1,"metadata":{},"owner":"chris","priority":1,"project_id":1,"revision":1,"short_id":"0001","status":"open","title":"Stop the loop on an empty queue","uid":"01K6Z000000000000000000001","updated_at":"2026-09-02T09:15:00.000Z"}}
{"kind":"issue","data":{"author":"agent-7","body":"","closed_at":null,"closed_reason":null,"content_revision":0,"created_at":"2026-09-02T11:00:00.000Z","deleted_at":null,"id":2,"metadata":{},"owner":null,"project_id":1,"revision":1,"short_id":"0002","status":"open","title":"Write the empty-queue test","uid":"01K6Z000000000000000000002","updated_at":"2026-09-02T11:00:00.000Z"}}
{"kind":"link","data":{"author":"agent-7","created_at":"2026-09-02T11:01:00.000Z","from_issue_id":2,"from_issue_uid":"01K6Z000000000000000000002","id":1,"to_issue_id":1,"to_issue_uid":"01K6Z000000000000000000001","type":"blocks"}}
$ ikigai-gonk kata import kata.jsonl --gonk http://127.0.0.1:1070
kata export version 27: 2 issue(s), 1 link(s)
filed #1 kata 0001 Stop the loop on an empty queue
filed #2 kata 0002 Write the empty-queue test
linked #2 blocks #1
2 filed, 0 already there; 0 comment(s), 0 close(s), 1 link(s) added; 0 deleted issue(s) and 0 link(s) skipped
Import it again whenever the old tracker has moved on: a re-run converges, adding comments, closes and links that are new and filing nothing twice (Importing a kata ledger).
4. The hook
The last piece is one [[hooks]] entry in roborev’s config, so every completed review files
its findings here from now on (Filing roborev findings):
[[hooks]]
event = "review.completed"
command = "ikigai-gonk roborev file --gonk http://127.0.0.1:1070 --ledger default --root {repo_name} --repo-path {repo} --job {job_id} --sha {sha} --agent {agent} --findings {findings}"
(On your machine the port is gonk’s own, 1062.) The next review roborev completes on notes
runs that line with roborev’s values in place; this is what it receives and runs:
## Summary
The README names the project and says nothing about how to run it.
**Agent assessment:** Fail
## Findings
### 1. High
**Location:** README.md:1
**Problem:** The README has a title and no instructions, so a new reader cannot start the project.
**Fix:** Add a "Getting started" section with the one command that runs it.
**Reported by:** claude-code
$ ikigai-gonk roborev file --gonk http://127.0.0.1:1070 --ledger default --root 'notes' --repo-path "$PWD/.ikigai/checkouts/notes" --job '7' --sha 'abc1234' --agent 'claude-code' --findings "$(cat review.md)"
filed high #3 The README has a title and no instructions, so a new reader cannot start the project.
The finding is about urn:repo:notes:file:README.md, a file gonk now serves, so browse’s page
for that file and the ledger item find each other.
Where that leaves you
$ curl -s http://127.0.0.1:1070/iki/ledger/next
1. #3 open p1 The README has a title and no instructions, so a new reader cannot start the project. [high roborev]
p1 — priority 1; last updated …
2. #2 open p- Write the empty-queue test
unprioritized — no priority set, so it ranks below every item that has one; last updated …
policy: priority-recency (weighs priority, recency, number)
ready: 2 excluded: 1
not #1: blocked by #2
One ledger: the work imported from kata, with its order intact, and a review finding filed by a hook, about a file in a repository gonk checked out and watches. What keeps it current:
| what changes | what brings it in | restart? |
|---|---|---|
| a repository upstream | ikigai-gonk checkout --all, by hand or on a timer | no: the watcher sees the fast-forward |
| a new repository | ikigai-gonk checkout <url> --write-config | yes: roots are read at startup |
| kata | ikigai-gonk kata import <a new export> | no |
| a roborev review | the hook, by itself | no |
The three doors, and who is asking
Everything so far reached gonk from the same machine, apart from Over MCP, which used as much of the QUIC door as an agent needs. This part is about reaching it from others, and it starts with what every request has to answer first: who is asking, and what may they do? A kernel answers that with a capability, and gonk’s doors are where a request is given one. There are three, and each authenticates something different.
| door | reached by | authenticates | the capability it grants |
|---|---|---|---|
| HTTP | a browser, curl, on loopback only | that the request is from this machine and not cross-site (the Host; for a write the Origin; for a read from another page, that it navigated here rather than loading this as an image, script, frame or fetch, and then only the read half of the grant); a passkey session if there is one | the anonymous grant (read and write of the ledgers gonk.http.ledger names, default unless told otherwise), plus a signed-in passkey’s grant |
| socket | another process of the same user (ikigai --mount) | the socket file’s owner-only mode, and the peer’s user id | root (the owner can read the store’s files anyway), or what an owner process narrowed itself to with cap seal |
| QUIC | another machine (ikigai --connect quic://…) | a client certificate, pinned by fingerprint, over mutual TLS — no certificate authority | the grant its fingerprint is enrolled under, and a name for the client, urn:iki:host:gonk:client:<fingerprint> |
This page opens all three on one scratch gonk and sends a request through each, then reads gonk’s access log to see what gonk knew about each caller. The next chapter is the QUIC door in depth. In a new empty directory, as in every chapter: if the gonk from A fresh machine, end to end is still running, stop it first, since this one takes the same port.
Opening the QUIC door
The QUIC door does not open until a certificate is enrolled: a door that every client is refused at
is not worth a listening port. So the first command enrolls one, under the grant name laptop, as
a writer of the default ledger. Like the server below, it is told which config home to write
into, and --port says which port the server will listen on, so the --connect line it prints
is right:
$ ikigai-gonk client add laptop --ledger default=write --port 1070 --config-home "$PWD/.config/ikigai"
client `laptop` …/.config/ikigai/gonk/quic/clients/laptop
fingerprint …
principal urn:iki:host:gonk:client:…
enrolled grant `laptop` (4 scopes) in …/.config/ikigai/gonk/grants.json
restart ikigai-gonk: trusted certificates are read at startup
this bundle holds the client's PRIVATE key and the server never reads it — move the directory to the client, then from the client:
ikigai --connect quic://<gonk host>:1070 --cert-dir <the moved directory>
principal is the name gonk will give this client on every request it makes: the certificate’s
fingerprint, as an IRI. It is a name, not authority; what the client may do is the grant. Then
start gonk, with its standard error — where the access lines go — copied to a file this page can
read back:
$ ikigai-gonk --config-home "$PWD/.config/ikigai" --data-home "$PWD/.ikigai" --port 1070 --no-backup 2>&1 | tee gonk.log
ikigai-gonk 0.1.0 — holding the store at …/.ikigai/store
http http://localhost:1070/ — loopback (127.0.0.1:1070); anonymous read+write: default; 0 passkey(s); anonymous SPARQL budget 1000 ms
…
socket …/.ikigai/gonk.sock — owner only
quic udp 0.0.0.0:1070 — 1 trusted certificate(s), 1 enrolled
log one `gonk:Access` line per request at each door, on stderr; the Queue badge's poll only when it fails, takes a second, or is the 60th (`gonk.log.access = false` turns it off)
…
--port moves both network doors: HTTP on TCP 1070, loopback only, and QUIC on UDP 1070, every
interface, because a QUIC door is for other machines. TCP 1070 and UDP 1070 are different ports,
so the two doors share the number. (Through gonk’s audit round 4 the QUIC door stayed on UDP 1060,
its default then, whatever --port said, so a scratch gonk with an enrolled client collided with
a real one.)
--quic-bind IP:PORT still names a bind outright, and naming one changes its meaning from “if
you can” to “must”: gonk refuses to start with a bind named and no certificate enrolled, rather
than run with the door silently shut.
One request through each door
$ curl -s http://127.0.0.1:1070/iki/ledger/items
no items match
$ curl -s -X POST --data-binary 'From the HTTP door' http://127.0.0.1:1070/iki/ledger/append
#1 urn:iki:ledger:default:item:…
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:append From the socket'
#2 urn:iki:ledger:default:item:…
[uncacheable]
$ ikigai --connect quic://127.0.0.1:1070 --cert-dir .config/ikigai/gonk/quic/clients/laptop -c 'sink urn:iki:ledger:append From QUIC' -c 'source urn:iki:ledger:items'
#3 urn:iki:ledger:default:item:…
[uncacheable]
#3 open p- From QUIC
#2 open p- From the socket
#1 open p- From the HTTP door
3 item(s)
[uncacheable]
— batch: 2 commands · 2 uncacheable
Three doors, one ledger. The socket and QUIC clients are the same program, ikigai, in its two
remote forms: --mount composes gonk’s resources into a local kernel under a prefix of your
choosing (here urn:gk:), and --connect sends every command to the server as it is typed, so
the names are gonk’s own.
What gonk knew
Every request a door dispatches is one line in the access log, on standard error, in a fixed
key=value shape made for grep:
$ grep ' gonk:Access ' gonk.log
… gonk:Access urn:iki:ledger:items door=http verb=source outcome=ok bytes=… dur=… principal=- q=-
… gonk:Access urn:iki:ledger:append door=http verb=sink outcome=ok bytes=… dur=… principal=anon q=-
… gonk:Access urn:iki:ledger:append door=socket verb=sink outcome=ok bytes=… dur=… principal=owner q=-
… gonk:Access urn:iki:ledger:append door=quic verb=sink outcome=ok bytes=… dur=… principal=urn:iki:host:gonk:client:… q=-
… gonk:Access urn:iki:ledger:items door=quic verb=source outcome=ok bytes=… dur=… principal=urn:iki:host:gonk:client:… q=-
$ grep 'quic client' gonk.log
ikigai-gonk: quic client … → grant "laptop"
door= is which door, and principal= is who the door could say was asking:
- HTTP:
anonon an anonymous write and-on an anonymous read; a signed-in passkey’s IRI on every request, reads included. That last part is new in the gonk this book pins (gonk PR 122): before it the HTTP library told the door who a write was from and never a read, so a signed-in read was logged-too. - socket:
owner, always — there is exactly one user it lets in. - QUIC: the client’s name,
urn:iki:host:gonk:client:<fingerprint>, on every request, reads included. The certificate authenticated the connection, so the door knows which client is asking whatever it asks. The connection line below the access lines says the same thing in short (the fingerprint’s first sixteen hex digits) with the grant it mapped to.
The QUIC name is not only in the log. The door mints it into the request’s capability, as
core’s principal scope (urn:cap:principal:<iri>, ikigai-core 0.1.93), and that is where gonk
reads it. A capability carries at most one principal, and when the door narrows a capability the
client carried to the grant it holds, the result keeps the door’s name and drops any the client
sent, so it cannot be forged from the client side, and a client that narrows its own capability
(an agent’s grant carried with the request) is named all the same. (ikigai-quic also still
stamps the name on each request as an argument called principal, after removing any the client
sent; until gonk PR 122 that argument was where gonk read it.) A write that names no author
is filed with this name as its author, and a write that names a different principal as its
author is refused:
$ ikigai --connect quic://127.0.0.1:1070 --cert-dir .config/ikigai/gonk/quic/clients/laptop -c 'source urn:iki:ledger:item:3' -c 'sink urn:iki:ledger:append author=urn:iki:gonk:passkey:ada Not mine to sign'
#3 open p- From QUIC
iri: urn:iki:ledger:default:item:…
filed: … by urn:iki:host:gonk:client:…
updated: …
[uncacheable]
error: denied: `author` names a principal (urn:iki:gonk:passkey:ada), and only the door names one: a write may name its own principal or a plain-text author, never another identity. This request is from urn:iki:host:gonk:client:….
— batch: 2 commands · uncacheable
A plain-text author (author=agent-7, as in Over the socket) is still
kept: it renders as text, and claims nothing a door could check.
The client’s name has a second, older spelling, and that is where it matters. The name
follows every host’s convention, urn:iki:host:{name}:client:<fingerprint>, with this host’s
name, gonk. Before gonk PR 126 (ledger #1183, 2026-10-10) gonk minted
urn:iki:gonk:client:<fingerprint>, and that spelling is a permanent alias: never minted
again, and never rewritten where it is stored, so an item filed, or an access line written, by a
QUIC client before the change still says it. Read an older ledger or log, and match both. The door
treats the two as one actor: a client may name itself as author in either spelling, the old
spelling of another machine’s fingerprint is refused like the new one, and the item page shows
both spellings of one fingerprint as one actor, in the current form. Enrollment is unchanged:
clients.json is keyed by fingerprint, so an enrolled certificate needs nothing redone.
outcome= is what the resource answered (ok, denied, not-found, …). One kind of refusal
writes no line: a request the door’s own kernel refuses before dispatching it, because the
caller’s capability does not even cover the family the name belongs to. That refusal is still
returned to the caller, with the token it wanted:
$ ikigai --connect quic://127.0.0.1:1070 --cert-dir .config/ikigai/gonk/quic/clients/laptop -c 'source urn:iki:store:select'
error: denied: capability does not grant `urn:cap:store:read` (declared by `urn:iki:store:select`)
The laptop grant holds a ledger’s tokens, and the store’s broad query door wants the broad read
grant, which no QUIC grant may carry (gonk refuses a session whose grant holds it, rather than
serve the whole dataset to a certificate). So the request ends
at the door, and the log stays a record of what was served.
The grant’s narrower store tokens are there for the ledger, which writes under the caller’s
capability; they do not let the client write the ledger’s graph through the store instead. That
raw write is refused at this door and at HTTP unless the grant was minted with --ledger-graph,
which the next chapter shows.
A QUIC client, and the grant it runs under
The previous chapter opened gonk’s QUIC door and sent one request through it. This one sets up a client the way you would for a second machine: an identity minted on the server, a grant that says exactly what that identity may do, the identity moved to the client, and the server refusing everything outside the grant — including a certificate it was never told to trust.
Both “machines” here are directories on one machine: the server keeps its two homes in the page’s
directory, as in every chapter, and laptop/ stands in
for the client’s machine. If the gonk from the previous chapter is still running, stop
it first, since this one takes the same port; its laptop is a different client from this page’s,
with a different certificate. Everything below
works the same with the second one somewhere else, with 127.0.0.1 replaced by the server’s
address.
A repository, and an identity with a grant
The client will read a repository as well as the ledger, so the server browses one:
$ git init -q -b main notes
$ printf '# Notes\n' > notes/README.md
$ mkdir -p .config/ikigai
$ printf 'gonk.browse.root = "notes=%s/notes"\n' "$PWD" > .config/ikigai/config.toml
$ cat .config/ikigai/config.toml
gonk.browse.root = "notes=…/notes"
The root’s path is written out in full: a ~ in config.toml means the home directory of
whoever runs gonk, which is yours, not this page’s.
client add mints a certificate and its key, writes them as a bundle with the server’s own
certificate, and enrolls the client’s fingerprint under a grant. The grant is built from roles,
never typed as tokens: --ledger default=write is the ledger’s writer, --browse read --root notes reads that one repository.
$ ikigai-gonk client add laptop --ledger default=write --browse read --root notes --port 1070 --config-home "$PWD/.config/ikigai"
client `laptop` …/.config/ikigai/gonk/quic/clients/laptop
fingerprint …
principal urn:iki:host:gonk:client:…
enrolled grant `laptop` (6 scopes) in …/.config/ikigai/gonk/grants.json
restart ikigai-gonk: trusted certificates are read at startup
this bundle holds the client's PRIVATE key and the server never reads it — move the directory to the client, then from the client:
ikigai --connect quic://<gonk host>:1070 --cert-dir <the moved directory>
$ ls .config/ikigai/gonk/quic/clients/laptop
client.crt
client.key
server.crt
$ cat .config/ikigai/gonk/grants.json
{
"laptop": [
"urn:cap:ledger:read:default",
"urn:cap:store:read:graph:urn:iki:ledger:graph:default",
"urn:cap:ledger:write:default",
"urn:cap:store:write:graph:urn:iki:ledger:graph:default",
"urn:cap:browse:read:notes",
"urn:cap:store:read:graph:urn:iki:browse:graph:default"
]
}
Six tokens: the four-token ledger writer from the first chapter, reading
the notes root, and reading browse’s graph through the store’s narrow door (so the client can
join its items to what browse knows, as the embedding chapter, at the
end of the book, does in a kernel of its own).
clients.json, beside it, maps the fingerprint to the grant’s name. Two files, re-read on every
connection: editing either changes what a client may do, or revokes it, without a restart. The
certificates gonk trusts are read once, at startup — so start it now:
$ ikigai-gonk --config-home "$PWD/.config/ikigai" --data-home "$PWD/.ikigai" --port 1070 --no-backup 2>&1 | tee gonk.log
ikigai-gonk 0.1.0 — holding the store at …/.ikigai/store
…
quic udp 0.0.0.0:1070 — 1 trusted certificate(s), 1 enrolled
…
The client
The bundle holds the client’s private key, so it belongs on the client and nowhere else. Copy it to
the client’s home (on a real second machine, move it there and delete it here — client add’s
last lines say so):
$ mkdir -p laptop
$ cp -R .config/ikigai/gonk/quic/clients/laptop laptop/gonk
and connect with it, naming the bundle with --cert-dir:
$ ikigai --connect quic://127.0.0.1:1070 --cert-dir laptop/gonk -c 'sink urn:iki:ledger:append Read the notes' -c 'source urn:repo:notes:tree' -c 'source urn:repo:notes:file:README.md'
#1 urn:iki:ledger:default:item:…
[uncacheable]
README.md file 8
[uncacheable]
# Notes
[uncacheable]
— batch: 3 commands · 3 uncacheable
The client pinned the server by the server.crt in its bundle, and the server pinned the client
by its fingerprint. There is no certificate authority anywhere: each side knows exactly one key
for the other, and that is the whole of the trust.
Outside the grant
Everything this client is not granted is refused, by the resource that was asked, naming what it wanted:
$ ikigai --connect quic://127.0.0.1:1070 --cert-dir laptop/gonk -c 'delete urn:iki:ledger:item:1' -c 'sink urn:iki:ledger:acme:append Not mine'
error: denied: capability does not grant `urn:cap:ledger:delete:*` (declared by `urn:iki:ledger:item:1`; any grant under `urn:cap:ledger:delete:` satisfies it)
error: denied: this capability does not hold `urn:cap:ledger:write:acme`. A ledger grant names exactly one ledger — holding a grant over another ledger satisfies the declared family `urn:cap:ledger:write:*` but not this resource, which is the point of naming them
The first is the family: this grant holds no ledger’s delete token at all, so the door refuses it
before dispatch, and the refusal says so itself, naming the prefix any grant under which would
satisfy it. The second is subtler, and it is why a ledger’s name is in its IRI rather than an
argument: the client holds the write family (for default), so the door lets the request
through, and the ledger itself refuses it, because the token names a different ledger.
A certificate the server was never told to trust gets nothing. Mint one with no grant, after the
server started. The hint it prints names the grant’s shape rather than a grant, because a
command copied from it could narrow a client’s existing authority (it printed a fixed
--ledger default=write until gonk PR 106, ledger #918):
$ ikigai-gonk client add stranger --port 1070 --config-home "$PWD/.config/ikigai"
client `stranger` …/.config/ikigai/gonk/quic/clients/stranger
fingerprint …
principal urn:iki:host:gonk:client:…
NOT enrolled — a trusted certificate with no grant is refused. Enroll it:
ikigai-gonk client add stranger --ledger <ledger>=<read|worker|write|delete|purge>
restart ikigai-gonk: trusted certificates are read at startup
this bundle holds the client's PRIVATE key and the server never reads it — move the directory to the client, then from the client:
ikigai --connect quic://<gonk host>:1070 --cert-dir <the moved directory>
$ ikigai --connect quic://127.0.0.1:1070 --cert-dir .config/ikigai/gonk/quic/clients/stranger -c 'source urn:iki:ledger:items'
error: unavailable: quic transport: …
Refused twice over: the running server does not trust the certificate (it was not there at
startup), and if it did, a trusted certificate with no grant is refused anyway. What the client is
told depends on timing, which is why the page prints … there: either that the TLS handshake
failed because its certificate does not match the pinned certificate, or, if the connection is
torn down first, only read error: connection lost. ⚠ The second is all an honest client with a
stale bundle may hear, so when a connection is lost straight away, check the bundle against the
server’s clients.json and restart the server if the certificate was enrolled after it started.
What the server saw
$ grep -E 'quic client| door=quic ' gonk.log
ikigai-gonk: quic client … → grant "laptop"
… gonk:Access urn:iki:ledger:append door=quic verb=sink outcome=ok bytes=… dur=… principal=urn:iki:host:gonk:client:… q=-
… gonk:Access urn:repo:notes:tree door=quic verb=source outcome=ok bytes=… dur=… principal=urn:iki:host:gonk:client:… q=-
… gonk:Access urn:repo:notes:file:README.md door=quic verb=source outcome=ok bytes=… dur=… principal=urn:iki:host:gonk:client:… q=-
ikigai-gonk: quic client … → grant "laptop"
… gonk:Access urn:iki:ledger:acme:append door=quic verb=sink outcome=denied bytes=… dur=… principal=urn:iki:host:gonk:client:… q=-
One connection line per session naming the grant, and one access line per request the door
dispatched, each naming the client — including the ledger’s own refusal, outcome=denied. The
delete, refused at the door before dispatch, wrote no line, and the stranger, refused in the
handshake, wrote nothing at all.
Writing the graph raw
The grant holds the store’s write token for the default ledger’s graph, because the ledger
makes its own writes under the caller’s capability (Whose ledger).
That token is the ledger’s to use. It is not authority to write the graph’s triples directly,
through the store, which is how an author naming someone else would get past the ledger’s own
check; so a network door refuses that raw write, and says what to do instead:
$ ikigai --connect quic://127.0.0.1:1070 --cert-dir laptop/gonk -c 'sink urn:iki:store:graph-update graph=urn:iki:ledger:graph:default INSERT DATA { GRAPH <urn:iki:ledger:graph:default> { <urn:example:note> <urn:example:says> "written raw" } }'
error: denied: a raw store write to the ledger graph <urn:iki:ledger:graph:default> is refused at this door. A ledger grant carries that graph's store token for the ledger's OWN writes, which it issues itself; it is not authority to write the graph's triples directly — that is how an `author` naming someone else would get in. Write through the ledger's endpoints (`urn:iki:ledger:*`). Raw writes are the owner's, at root over the socket, or an identity's whose grant an operator minted with `--ledger-graph <ledger>` (`urn:cap:gonk:raw-write:graph:urn:iki:ledger:graph:default`)
An identity that really does need to write raw (a repair or migration tool on another machine)
is given it by name, with --ledger-graph, which adds one more token. The grant laptop already
exists, so changing it takes --force, which names what it changes:
$ ikigai-gonk client add laptop --ledger default=write --browse read --root notes --ledger-graph default --force --port 1070 --config-home "$PWD/.config/ikigai"
client `laptop` …/.config/ikigai/gonk/quic/clients/laptop
fingerprint …
principal urn:iki:host:gonk:client:…
enrolled grant `laptop` (7 scopes) in …/.config/ikigai/gonk/grants.json
REPLACED grant `laptop` (--force) — every identity enrolled under it holds the new list from its next request or connection:
adds urn:cap:gonk:raw-write:graph:urn:iki:ledger:graph:default
restart ikigai-gonk: trusted certificates are read at startup
the client's identity is unchanged — a client already holding this bundle needs nothing new; it connects as before:
ikigai --connect quic://<gonk host>:1070 --cert-dir <its bundle>
$ ikigai --connect quic://127.0.0.1:1070 --cert-dir laptop/gonk -c 'sink urn:iki:store:graph-update graph=urn:iki:ledger:graph:default INSERT DATA { GRAPH <urn:iki:ledger:graph:default> { <urn:example:note> <urn:example:says> "written raw" } }'
updated <urn:iki:ledger:graph:default>: +1 -0 quads
[uncacheable]
The bundle in laptop/ was not touched, and the same certificate now holds the new grant, with
no restart: --force replaces a client’s grant and never its key pair. (ikigai-gonk grants --ledger-graph default prints the tokens it adds, for writing a grant by hand.) The raw grant
covers that ledger’s graph and not its graveyard of deleted items, which stays the owner’s, at
root over the socket.
A client’s lifecycle
client list is every client the server knows: each bundle and each enrolled fingerprint, its
grant, and whether a connection from it would be admitted.
$ ikigai-gonk client list --config-home "$PWD/.config/ikigai"
laptop … grant laptop trusted and enrolled
stranger … grant - trusted, NOT enrolled (refused at connection)
A key pair that may have leaked is replaced with --rotate: a new identity for the same name,
under the same authority. With no scope flags the old certificate’s enrollment moves to the new
one, the same grant, and grants.json is not touched (with scope flags the grant is rewritten
as for any client add, and refused without --force if it changes). Until gonk PR 106 (ledger
#918) a rotation without flags left the new certificate enrolled under nothing, so the client
was refused at its next connection:
$ ikigai-gonk client add laptop --rotate --port 1070 --config-home "$PWD/.config/ikigai"
client `laptop` …/.config/ikigai/gonk/quic/clients/laptop
fingerprint …
principal urn:iki:host:gonk:client:…
enrolled under grant `laptop`, unchanged — the old certificate's … moved to this one
ROTATED the old certificate … was unenrolled — a client still holding the old bundle is refused from its next connection; give it this one
restart ikigai-gonk: trusted certificates are read at startup
this bundle holds the client's PRIVATE key and the server never reads it — move the directory to the client, then from the client:
ikigai --connect quic://<gonk host>:1070 --cert-dir <the moved directory>
$ ikigai --connect quic://127.0.0.1:1070 --cert-dir laptop/gonk -c 'source urn:iki:ledger:items'
error: unavailable: quic transport: …
The old bundle is refused at once: admission is decided per connection, from clients.json.
The new one is not trusted until gonk restarts, because the certificates it trusts are read at
startup; so a rotation is a restart and a copy of the new bundle to the client.
A client that should go away entirely is removed, bundle and enrollment:
$ ikigai-gonk client remove stranger --config-home "$PWD/.config/ikigai"
removed …/.config/ikigai/gonk/quic/clients/stranger
(its certificate was not enrolled)
grants.json is unchanged: a grant name may be shared by other certificates and passkeys. Restart ikigai-gonk to stop trusting the certificate at the TLS handshake
$ ikigai-gonk client list --config-home "$PWD/.config/ikigai"
laptop … grant laptop trusted and enrolled
grants.json is left alone because a grant name may be shared, by other certificates and by
passkeys. client remove --fingerprint <fp> removes an enrollment that has no bundle, which is
what client list shows as enrolled, NO bundle.
On a second machine
$ scp -r server:.config/ikigai/gonk/quic/clients/laptop ~/gonk-laptop
$ ikigai --connect quic://server.local:1062 --cert-dir ~/gonk-laptop -c 'source urn:iki:ledger:next'
The same, with the bundle moved rather than copied, the server reachable on UDP 1062 (a gonk on
the default port opens its QUIC door on UDP 1062, every interface), and the client’s ikigai
built with --features quic.
A client that should reach gonk all the time, rather than per command, puts the same two things in
its own config home as a mount, mount = "prefer urn:iki:ledger:=quic://server.local:1062 /home/you/gonk-laptop", and every urn:iki:ledger:* it asks then goes to gonk.
Your first saved query
The ikigai Book’s Your first script published a query at a name, and read the name to run it. gonk does the same for its ledger: a saved query is a page anybody on this machine can open, and it stays right as the ledger changes. Two steps, the first by hand and the second from a sentence. Nothing here needs more than Over the socket.
Start a model, then gonk
The second step asks a language model. This page uses a stand-in, model-stub from this book’s
crates/model-stub, which answers each question with the same query every time, so the page
prints the same thing on every machine (installed as Which gonk
says). In a new empty directory, as in every chapter: if
the gonk from A QUIC client, and the grant it runs under is still
running, stop it first, since this one takes the same port. Start the stub first:
$ model-stub "$PWD/model.sock"
model-stub: urn:llm:ask and urn:llm:config on …/model.sock (model stub)
Tell gonk where the model is, in its config home, and start a scratch gonk, as in A ledger of your own:
$ mkdir -p .config/ikigai
$ printf 'gonk.mount = "prefer urn:llm:=%s/model.sock"\n' "$PWD" > .config/ikigai/config.toml
$ ikigai-gonk --config-home "$PWD/.config/ikigai" --data-home "$PWD/.ikigai" --port 1070 --no-quic --no-backup
ikigai-gonk 0.1.0 — holding the store at …/.ikigai/store
http http://localhost:1070/ — loopback (127.0.0.1:1070); anonymous read+write: default; 0 passkey(s); anonymous SPARQL budget 1000 ms
…
1. Your first saved query
The query counts the open items in the default ledger, open-count.rq:
# How many items are open.
PREFIX ledger: <https://ikigai-rs.dev/ns/ledger#>
SELECT (COUNT(?item) AS ?open) WHERE {
GRAPH <urn:iki:ledger:graph:default> { ?item ledger:status ledger:open }
}
Publish it over the socket, as the owner, at the name open-count. public=true lets anybody on
this machine run it, signed in or not:
$ cat open-count.rq | ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:script:open-count language=sparql public=true'
urn:script:open-count:version:sha256:…
[uncacheable]
Its answer is now a page. Open it in a browser:
$ open http://localhost:1070/script/open-count/result
The browser shows the answer as SPARQL JSON, where the number is the value. as=text/csv asks
for a plainer face of the same answer, which is what the checks on this page read:
$ curl -s 'http://localhost:1070/script/open-count/result?as=text/csv'
open
0
Nothing is open, because nothing has been filed. File an item, and refresh:
$ curl -s -X POST --data-binary 'Read the first page' http://127.0.0.1:1070/iki/ledger/append
#1 urn:iki:ledger:default:item:…
$ curl -s 'http://localhost:1070/script/open-count/result?as=text/csv'
open
1
Nobody told the query that the ledger changed. gonk remembers an answer only until something it
read is written, so the refresh ran the query again. On your own gonk the page is
http://localhost:1062/script/open-count/result, and it is right every time you open it.
2. Ask gonk in a sentence
Now let the model write the query. focus= says which graph the question is about, and save=
the name to keep the answer at.
$ printf 'How many open items are there?' | ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:nl:sparql save=how-many-open focus=urn:iki:ledger:graph:default'
urn:script:how-many-open:version:sha256:… (a DRAFT of urn:script:how-many-open: review it, then publish it with state=published)
PREFIX ledger: <https://ikigai-rs.dev/ns/ledger#>
SELECT (COUNT(?item) AS ?count) WHERE {
GRAPH <urn:iki:ledger:graph:default> { ?item ledger:status ledger:open }
}
preview (LIMIT 5):
?count
"1"^^<http://www.w3.org/2001/XMLSchema#integer>
[uncacheable]
The answer is a draft: the model’s query, checked (it parses, and reads only what you may
read), run once as a preview that already says 1, and saved at how-many-open, where nobody can
run it yet. Look at it. It is the query you wrote by hand in step 1. Publishing it is a person’s
act: the draft’s own text, sunk again with state=published, and public=true as before:
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:script:how-many-open | sink urn:gk:script:how-many-open language=sparql state=published public=true'
urn:script:how-many-open:version:sha256:…
[2 uncacheable]
And it is a page, like the one you wrote:
$ open http://localhost:1070/script/how-many-open/result
$ curl -s 'http://localhost:1070/script/how-many-open/result?as=text/csv'
count
1
Next
Scripts and sentences in gonk does the same things in depth: who may draft, run and publish through each of gonk’s three doors, why a machine drafts and only a person publishes, and a draft the check passed that was still wrong.
Scripts and sentences in gonk
The ikigai Book showed code as a resource: a query published at a
name, fetched without running, run as a read, versioned by its content, and run under the least of
what its publisher held, what its runner holds and what the host allows. A sentence becomes a
draft, two chapters later, let a model write the query, as a draft
nobody but the person who asked could see, until a person published it. Your first saved query did
each once in gonk, as its owner. This chapter does not teach either idea again. It shows the same
two things in gonk, which mounts ikigai-script and
ikigai-nl behind its three doors, and so has to answer a
question a library cannot: who is asking, through which door, and what each of them may do.
The cast, all on this page’s one scratch gonk:
| who | door | what they hold |
|---|---|---|
| the owner | the socket | everything: root, as in Over the socket |
bot, a drafting agent | QUIC | read the ledger, draft from a sentence, save drafts in the team- namespace |
reviewer, a second machine | QUIC | read the ledger, read and run the team- scripts |
worker, a remote worker | QUIC | read, claim and comment on the ledger; run public scripts |
| anyone on this machine | HTTP, signed out | the configured ledger, and every PUBLIC script, within a budget |
ann, a person | HTTP, signed in with a passkey | write the ledger, draft, publish in the team- namespace |
ann is the one this page cannot be: signing in is a passkey ceremony in a browser, which no check
can perform. Her commands are shown where they belong and marked as not run; everything she does
is also done here by a door the check can drive, and the page says which.
A model that is not one
gonk drafts through the model its gonk.mount line mounts at urn:llm:, as
Explain does. A transcript must never reach a real model, so this page
starts a stub on a socket and mounts that instead: model-stub, from this book’s
crates/model-stub, answers urn:llm:ask with a fixed SPARQL draft for each ask, the way the
ikigai Book’s stub does in process, and prints a line for every ask it is sent. The mount is the
real one, so everything between the drafter and the model is gonk’s own code; only the model’s
words are canned. In a new empty directory, as in every
chapter: if the gonk and the model-stub from Your first
saved query are still running, stop them first, since this gonk takes the same port.
$ model-stub "$PWD/model.sock" | tee model.log
model-stub: urn:llm:ask and urn:llm:config on …/model.sock (model stub)
The config names the mount, and lowers the anonymous run budget from its default of 30 a minute to 3, so the page can show the limit without sending thirty requests:
$ mkdir -p .config/ikigai
$ printf 'gonk.mount = "prefer urn:llm:=%s/model.sock"\n' "$PWD" > .config/ikigai/config.toml
$ printf 'gonk.script.anonymous_runs_per_minute = 3\n' >> .config/ikigai/config.toml
$ cat .config/ikigai/config.toml
gonk.mount = "prefer urn:llm:=…/model.sock"
gonk.script.anonymous_runs_per_minute = 3
Grants: --script and --nl
Two flags on client add and passkey invite grant scripts and drafting, beside the --ledger
roles the earlier chapters used:
--script <name>=<read|run|publish>, repeatable, each authority holding the one before:readfetches a script’s source without running it,runruns it, andpublishpublishes, replaces and retires it. The name is ONE script (open-by-label) or a namespace,<namespace>-*:team-*coversteam-reportandteam-a-report, neverteammateorteam. There is no “every script” grant below the owner.publicis the one name that is not a script:--script public=runruns and reads every script its publisher marked public, which is what a signed-out HTTP caller holds.--nl draft: may draft a query from a sentence, which spends the mounted model, so it is the mount’s network grant,urn:cap:net:<the mount's host>(localhostfor a socket). It is refused on a server with nogonk.mount, which is why the config came first. A draft reads only what the caller’s other grants read, and is SAVED only under a name its--script … =publishcovers.
⚠ Quote a namespace: 'team-*=publish'. Unquoted, zsh refuses the line (no matches found)
before gonk ever sees it, and another shell may expand the * against the files in the directory.
$ ikigai-gonk client add bot --ledger default=read --nl draft --script 'team-*=publish' --port 1070 --config-home "$PWD/.config/ikigai"
client `bot` …/.config/ikigai/gonk/quic/clients/bot
fingerprint …
principal urn:iki:host:gonk:client:…
enrolled grant `bot` (7 scopes) in …/.config/ikigai/gonk/grants.json
restart ikigai-gonk: trusted certificates are read at startup
this bundle holds the client's PRIVATE key and the server never reads it — move the directory to the client, then from the client:
ikigai --connect quic://<gonk host>:1070 --cert-dir <the moved directory>
$ ikigai-gonk client add reviewer --ledger default=read --script 'team-*=run' --port 1070 --config-home "$PWD/.config/ikigai"
client `reviewer` …/.config/ikigai/gonk/quic/clients/reviewer
…
enrolled grant `reviewer` (4 scopes) in …/.config/ikigai/gonk/grants.json
…
$ ikigai-gonk client add worker --worker default --script public=run --port 1070 --config-home "$PWD/.config/ikigai"
client `worker` …/.config/ikigai/gonk/quic/clients/worker
…
enrolled grant `worker` (… scopes) in …/.config/ikigai/gonk/grants.json
…
$ ikigai-gonk passkey invite ann --ledger default=write --nl draft --script 'team-*=publish' --port 1070 --config-home "$PWD/.config/ikigai"
passkey invite for `ann` — grant `ann` (… scopes) in …/.config/ikigai/gonk/grants.json
valid for 30 minutes, once
open http://localhost:1070/#invite=…
in a browser on this machine (localhost, not 127.0.0.1); the passkey is enrolled in …/.config/ikigai/gonk/clients.json
What the flags wrote, grant by grant:
$ jq -r 'del(.ann) | to_entries[] | .key, (.value[] | " " + .)' .config/ikigai/gonk/grants.json
bot
urn:cap:ledger:read:default
urn:cap:store:read:graph:urn:iki:ledger:graph:default
urn:cap:net:localhost
urn:cap:script:read:team-*
urn:cap:script:run:team-*
urn:cap:script:write:team-*
urn:cap:script:delete:team-*
reviewer
urn:cap:ledger:read:default
urn:cap:store:read:graph:urn:iki:ledger:graph:default
urn:cap:script:read:team-*
urn:cap:script:run:team-*
worker
urn:cap:ledger:read:default
urn:cap:store:read:graph:urn:iki:ledger:graph:default
urn:cap:ledger:claim:default
urn:cap:ledger:comment:default
urn:cap:store:write:graph:urn:iki:ledger:graph:default
urn:cap:script:read:public
urn:cap:script:run:public
bot’s seven: the ledger’s two read tokens, the mount’s host, and all four script tokens over
the namespace (write and delete are what publish means). reviewer holds read and run
over the same names and nothing that drafts. worker’s --worker default is a ledger role
(--ledger default=worker says the same): read, claim and comment, never close, for a
remote worker whose dispatcher closes. It carries the ledger graph’s store WRITE token, because
the ledger writes a claim or a comment as a store update under the caller’s own capability; the
socket and QUIC doors refuse a narrowed caller’s raw write to a ledger graph all the same
(Over the socket, A QUIC client). ann’s grant is bot’s with the ledger’s writer in
place of its reader.
Start gonk
$ ikigai-gonk --config-home "$PWD/.config/ikigai" --data-home "$PWD/.ikigai" --port 1070 --no-backup 2>&1 | tee gonk.log
ikigai-gonk 0.1.0 — holding the store at …/.ikigai/store
…
scripts urn:script:* in …/.ikigai/scripts — sparql and plan, read-only (the ledgers' reads); anonymous runs PUBLIC scripts and read-only plans, 3 a minute in all (draft checks too), each held to the anonymous budget; a QUIC client drafts, a person publishes; backed up with the store
nl urn:nl:grounding, urn:nl:sparql:check — under the caller's own grant; urn:nl:sparql drafts through urn:llm:ask for a caller holding urn:cap:net:localhost (--nl draft), saving DRAFT scripts a person publishes
…
llm …/model.sock — mounted, but no gonk.browse.root, so nothing here derives through it
…
quic udp 0.0.0.0:1070 — 3 trusted certificate(s), 3 enrolled
…
Two lines are this chapter’s. scripts says where the scripts live (plain files beside the store,
not in it: a version records what its publisher held, and that is authority, so no store grant may
reach it), that every script here is read-only (a query, or a plan whose steps only read,
held to the ledgers’ reads), and the two rules this page shows: anonymous callers run only public
scripts, within a shared budget, and a QUIC client drafts while a person publishes. nl says who
may draft: a caller holding the mount’s host.
A ledger to ask about, filed by the owner over the socket:
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:append priority=1 Passkey list and revoke' -c 'sink urn:gk:iki:ledger:append priority=2 Dependency floors never re-resolved' -c 'sink urn:gk:iki:ledger:append priority=3 A note for later' -c 'sink urn:gk:iki:ledger:label item=2 triage' -c 'sink urn:gk:iki:ledger:label item=3 triage'
#1 urn:iki:ledger:default:item:…
[uncacheable]
#2 urn:iki:ledger:default:item:…
[uncacheable]
#3 urn:iki:ledger:default:item:…
[uncacheable]
labeled #2 triage
[uncacheable]
labeled #3 triage
[uncacheable]
— batch: 5 commands · 5 uncacheable
A saved query, for a person and for a worker
The owner publishes a query that lists the open items carrying a label. Its one parameter is
declared in its leading comment; FROM names the graph it reads, which is what the publish derives
its authority from (the store’s read token for that graph, which the owner holds):
# Open items carrying a label, lowest number first.
# @param label xsd:string -- the label an item must carry
PREFIX ledger: <https://ikigai-rs.dev/ns/ledger#>
PREFIX dcterms: <http://purl.org/dc/terms/>
SELECT ?number ?title
FROM <urn:iki:ledger:graph:default>
WHERE {
?item a ledger:Item ; ledger:status ledger:open ; ledger:label ?label ;
ledger:number ?number ; dcterms:title ?title .
}
ORDER BY ?number
public=true marks it public: anyone holding public=run may run it, the signed-out HTTP caller
included.
$ cat open-by-label.rq | ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:script:open-by-label language=sparql public=true'
urn:script:open-by-label:version:sha256:…
[uncacheable]
A person at a browser on this machine runs it with no account at all, by its HTTP name, the parameter as a query argument and the answer as CSV:
$ curl -s 'http://localhost:1070/script/open-by-label/result?label=triage&as=text/csv'
number,title
2,Dependency floors never re-resolved
3,A note for later
A remote worker runs the same script over QUIC, under its own certificate, and takes the first item. What it may do next is its ledger role’s, not the script’s: a worker claims and comments, and its dispatcher closes.
$ ikigai --connect quic://127.0.0.1:1070 --cert-dir .config/ikigai/gonk/quic/clients/worker -c 'source urn:script:open-by-label:result label=triage as=text/csv'
number,title
2,Dependency floors never re-resolved
3,A note for later
[uncacheable]
$ ikigai --connect quic://127.0.0.1:1070 --cert-dir .config/ikigai/gonk/quic/clients/worker -c 'sink urn:iki:ledger:claim item=2 worker'
#2 claimed by worker
[uncacheable]
$ ikigai --connect quic://127.0.0.1:1070 --cert-dir .config/ikigai/gonk/quic/clients/worker -c 'sink urn:iki:ledger:close item=2'
error: denied: this capability holds neither `urn:cap:ledger:close:default` nor `urn:cap:ledger:write:default`, and this resource takes either. A ledger grant names exactly one ledger, and a grant for another verb (a worker's claim and comment, say) does not reach this one
One query, one name, two callers at two doors, each held to its own grant. Neither could publish it, and neither needed to read the store directly.
The anonymous budget
A signed-out HTTP caller cannot be told apart from any other, so all of them share ONE window of runs a minute (30 by default; 3 on this page). Every run of a script counts, and so does an ad hoc plan and a draft’s dry run; an answer from gonk’s cache does not, and neither does reading a script’s source. The person’s run above was the first. The worker’s claim since then wrote the ledger, which cut every cached answer that read it, so the same run now computes again, and counts; asked again straight away, it is answered from the cache, and does not:
$ curl -s 'http://localhost:1070/script/open-by-label/result?label=triage&as=text/csv'
number,title
2,Dependency floors never re-resolved
3,A note for later
$ curl -s 'http://localhost:1070/script/open-by-label/result?label=triage&as=text/csv'
number,title
2,Dependency floors never re-resolved
3,A note for later
$ curl -s 'http://localhost:1070/script/open-by-label/result?label=urgent'
{"head":{"vars":["number","title"]},"results":{"bindings":[]}}
$ curl -s -w ' (%{http_code})\n' 'http://localhost:1070/script/open-by-label/result?label=later'
endpoint error: rate-limited: `urn:iki:gonk:anonymous:runs` is capped at 3/60s — retry after …s (500)
Four requests and three runs: the first triage run, the recomputed one and urgent (no item
carries that label, so the answer is empty SPARQL JSON, the face when no as= is asked for) fill
the window, and later is refused, naming the window and when a slot frees.
(Each run is also held to the anonymous SPARQL budget, 1 s by default, as the anonymous caller’s
own /sparql is.) ⚠ The refusal arrives as 500; a 429 with Retry-After would say the same
thing to a client that does not read the body.
Drafting: who may, and what it costs
A signed-out caller may not draft at all, and is refused before any model is asked:
$ curl -s -X POST --data-binary 'the titles of the open items' 'http://localhost:1070/nl/sparql?save=team-titles&focus=urn:iki:ledger:graph:default'
denied: capability does not grant `urn:cap:net:localhost` (declared by `urn:nl:sparql`)
$ grep -c ' ask ' model.log
0
The token it names is the one --nl draft mints. gonk DECLARES it on urn:nl:sparql, so the
kernel refuses before the grounding is computed, and the manifold offers the drafter only to the
callers who may draft.
What the model is shown, and focus=
The drafter tells the model what the caller may see, and refuses a grounding over 1 MiB rather than
trimming it. Of the grounding it reads only what a query needs, asked for with parts=: the
vocabulary, the shape of each graph the caller may read, and the worked examples. It never reads
the actions, the caller’s manifold, because a query calls none of them, and they are what grows
with a gonk: each browse root adds its own family. So the size of a gonk does not refuse a draft.
Measured on a scratch gonk at this book’s pin browsing 48 roots, as the live gonk did on
2026-10-11: the whole grounding, actions included, is 4,632,254 bytes and refused unfocused; the
parts the drafter reads are 109,915 bytes, and an unfocused draft there was drafted, repaired and
saved. (The live gonk, whose drafter then read the whole grounding, refused every unfocused draft
that day; ledger #1243 is the fix.)
This page’s gonk shows the same shape at its own size. The actions are counted and left out, and
focus=, a graph IRI or a topic word, narrows what is left to what mentions it:
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:nl:grounding parts=vocabulary,graphs,examples as=application/json' 2>/dev/null | jq -r '.actions.note, (.vocabulary, .graphs, .examples | "\(.source): \(.shown) of \(.of)")'
left out: parts= did not ask for it (103 offered)
urn:ikigai:vocab: 199 of 199
urn:iki:store:graphs: 1 of 1
urn:script:catalog: 1 of 1
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:nl:grounding parts=vocabulary,graphs,examples focus=urn:iki:ledger:graph:default as=application/json' 2>/dev/null | jq -r '.vocabulary, .graphs, .examples | "\(.source): \(.shown) of \(.of)"'
urn:ikigai:vocab: 0 of 199
urn:iki:store:graphs: 1 of 1
urn:script:catalog: 1 of 1
Unfocused, the model would be shown all 199 terms of the vocabulary; focused on the ledger graph, the graph’s own shape and the example that reads it, and no term, since none mentions the graph.
Every draft on this page passes focus= all the same: what the model is not shown, it cannot
mistake. (graph=, the drafter’s older name for it, is accepted for one more release.)
A machine drafts
bot drafts the live demo’s ask, word for word, over QUIC. The ask is piped, so it arrives as
content; save= names the draft inside the namespace bot may publish in:
$ printf 'the number and title of every open item with priority 2 or higher (0 is highest), ordered by number' | ikigai --connect quic://127.0.0.1:1070 --cert-dir .config/ikigai/gonk/quic/clients/bot -c 'sink urn:nl:sparql save=team-open-p2 focus=urn:iki:ledger:graph:default'
urn:script:team-open-p2:version:sha256:… (a DRAFT of urn:script:team-open-p2: review it, then publish it with state=published). its preview is EMPTY: no row matched, and drafting stopped: the model repeated attempt 1, so no later attempt did better; the warnings name the patterns that match nothing on their own. Review it before publishing
# @param limit xsd:integer optional default 2
SELECT ?number ?title
FROM <urn:iki:ledger:graph:default>
WHERE {
GRAPH <urn:iki:ledger:graph:default> {
?item a <https://ikigai-rs.dev/ns/ledger#Item> ;
<https://ikigai-rs.dev/ns/ledger#status> "open" ;
<https://ikigai-rs.dev/ns/ledger#priority> ?priority ;
<https://ikigai-rs.dev/ns/ledger#number> ?number ;
<http://purl.org/dc/terms/title> ?title .
FILTER (?priority <= ?limit)
}
}
ORDER BY ?number
warning: the preview is empty: no row matches, and these triple patterns match nothing on their own in the graphs it reads: GRAPH <urn:iki:ledger:graph:default> { ?item <https://ikigai-rs.dev/ns/ledger#status> "open" }. A string written where the data holds an IRI (or the reverse), or a literal of another datatype, matches nothing: compare each with the kind of value its predicate takes
preview (LIMIT 5):
?number ?title
[uncacheable]
$ cat model.log
model-stub: urn:llm:ask and urn:llm:config on …/model.sock (model stub)
model-stub: ask 1: the number and title of every open item with priority 2 or higher (0 is highest), ordered by number
model-stub: ask 2 (a repair): the number and title of every open item with priority 2 or higher (0 is highest), ordered by number
That is what Llama 3.3 70B wrote on the live gonk, and the stub writes it here. The check passes
it: it parses, and every predicate it names is one the ledger graph uses. But its preview is empty,
and the check says why, by trying each triple pattern on its own and naming the one that matches
nothing: ledger:status "open". The ledger stores the status as the IRI ledger:open, and a
string never equals an IRI. So the drafter sends the model that warning and asks again (ask 2 (a repair) in the stub’s log). The stub answers with the same draft, as a model that cannot see its
mistake would, and a repair that repeats an earlier attempt stops the drafting: asking a third time
would buy nothing. The draft is saved all the same, MARKED on the first line of the answer, so the
person who reviews it starts from the warning. (FROM beside a GRAPH block naming the same graph
looks like a second error, and in standard SPARQL it would be one; here it is harmless, because a
script’s graphs are passed as both its default and its named graphs, as the ikigai Book’s claims
test pins.)
The attempts, and where the draft came from
as= picks the face of the answer. as=application/json is every attempt with what the check
said of it — here for an ask whose first draft the check refuses, so the drafter repairs it:
$ printf 'the titles of the open items' | ikigai --connect quic://127.0.0.1:1070 --cert-dir .config/ikigai/gonk/quic/clients/bot -c 'sink urn:nl:sparql save=team-titles focus=urn:iki:ledger:graph:default as=application/json' 2>/dev/null | jq -r '.status, .script, (.attempts[] | "attempt \(.n) by \(.model): valid=\(.valid) \(.errors | join("; "))"), "preview: \(.check.preview.rows | length) row(s)"'
saved
urn:script:team-titles
attempt 1 by stub: valid=false the predicate <https://ikigai-rs.dev/ns/ledger#state> is used by none of the graphs this query reads and is not in the vocabulary
attempt 2 by stub: valid=true
preview: 3 row(s)
as=text/turtle is the drafting’s provenance in PROV-O: the activity, the grounding and prompts
it used (each by digest), every attempt as an entity with its model, its text and its verdict, and
the script it produced:
$ printf 'the titles of the open items' | ikigai --connect quic://127.0.0.1:1070 --cert-dir .config/ikigai/gonk/quic/clients/bot -c 'sink urn:nl:sparql save=team-titles-prov focus=urn:iki:ledger:graph:default as=text/turtle' 2>/dev/null | grep -E 'draftValid|wasRevisionOf|script:team-titles-prov>' | sed -E 's/[0-9a-f]{64}/…/g'
<urn:nl:sparql:drafting:…:attempt:1> <https://ikigai-rs.dev/ns#draftValid> "false"^^<http://www.w3.org/2001/XMLSchema#boolean> .
<urn:nl:sparql:drafting:…:attempt:2> <https://ikigai-rs.dev/ns#draftValid> "true"^^<http://www.w3.org/2001/XMLSchema#boolean> .
<urn:nl:sparql:drafting:…:attempt:2> <http://www.w3.org/ns/prov#wasRevisionOf> <urn:nl:sparql:drafting:…:attempt:1> .
<urn:script:team-titles-prov> <http://www.w3.org/ns/prov#wasGeneratedBy> <urn:nl:sparql:drafting:…> .
<urn:script:team-titles-prov> <http://www.w3.org/ns/prov#wasDerivedFrom> <urn:nl:sparql:drafting:…:attempt:2> .
The same provenance is written into the draft itself, as comment lines above the query, so it travels with the text into the version a person publishes.
A draft is its author’s
To anyone else, even a caller holding the same namespace, a draft does not exist. reviewer may
read and run every team- script, and is told there is no such script; the owner’s socket names
nobody, so the owner is root there and sees every draft:
$ ikigai --connect quic://127.0.0.1:1070 --cert-dir .config/ikigai/gonk/quic/clients/reviewer -c 'source urn:script:team-open-p2'
error: not found: no script is published at urn:script:team-open-p2
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:script:catalog'
urn:script:open-by-label published public sha256:…
urn:script:team-open-p2 draft sha256:…
urn:script:team-titles draft sha256:…
urn:script:team-titles-prov draft sha256:…
[uncacheable]
A draft cannot be run either, by anyone. Its author asks first, then the owner:
$ ikigai --connect quic://127.0.0.1:1070 --cert-dir .config/ikigai/gonk/quic/clients/bot -c 'source urn:script:team-titles:result'
error: conflict: urn:script:team-titles is a draft; publish it (`state=published`) to run it
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:script:team-titles:result'
error: conflict: urn:script:team-titles is a draft; publish it (`state=published`) to run it
Both are told the same true thing: it is a draft, and publishing it is how to run it. Only a caller
who may see the draft hears that much; to reviewer it does not exist.
A person publishes
bot holds the namespace’s publish grant, and still cannot publish, because its door is QUIC:
$ ikigai --connect quic://127.0.0.1:1070 --cert-dir .config/ikigai/gonk/quic/clients/bot -c 'source urn:script:team-titles | sink urn:script:team-titles language=sparql state=published'
error: denied: a client certificate may save `urn:script:team-titles` as a DRAFT (`state=draft`) and may not publish it: a machine drafts, a person publishes (sign in with a passkey, or use the owner's socket, which sees every draft)
[uncacheable]
A machine drafts, a person publishes. One grant table serves certificates and passkeys alike,
so the rule cannot live in the grant; it lives at the door, and the QUIC door is the one whose
callers are machines. A machine’s draft is also its own, so of the people, only the owner (at the
socket, which sees every draft) can publish it; ann could publish a draft SHE made.
The owner reviews team-open-p2, writes the status as the IRI it is and the bound into the text,
and checks the result before publishing it. The check is the drafter’s own, dry run included, with
focus= naming the graph:
PREFIX ledger: <https://ikigai-rs.dev/ns/ledger#>
PREFIX dcterms: <http://purl.org/dc/terms/>
SELECT ?number ?title
WHERE {
GRAPH <urn:iki:ledger:graph:default> {
?item a ledger:Item ;
ledger:status ledger:open ;
ledger:priority ?priority ;
ledger:number ?number ;
dcterms:title ?title .
FILTER (?priority <= 2)
}
}
ORDER BY ?number
$ cat team-open-p2.rq | ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:nl:sparql:check focus=urn:iki:ledger:graph:default'
valid select over <urn:iki:ledger:graph:default>
requires: urn:cap:store:read:graph:urn:iki:ledger:graph:default
preview (LIMIT 5):
?number ?title
"1"^^<http://www.w3.org/2001/XMLSchema#integer> "Passkey list and revoke"
"2"^^<http://www.w3.org/2001/XMLSchema#integer> "Dependency floors never re-resolved"
[uncacheable]
Rows, where the draft had none. Publishing it under the draft’s name replaces the draft, and
public=true lets a signed-out caller run it:
$ cat team-open-p2.rq | ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:script:team-open-p2 language=sparql state=published public=true'
urn:script:team-open-p2:version:sha256:…
[uncacheable]
$ ikigai --connect quic://127.0.0.1:1070 --cert-dir .config/ikigai/gonk/quic/clients/reviewer -c 'source urn:script:team-open-p2:result as=text/csv'
number,title
1,Passkey list and revoke
2,Dependency floors never re-resolved
[uncacheable]
reviewer, who was told a minute ago that the script did not exist, runs it. And a published
script is never replaced by a draft: drafting under its name now is refused before any model is
asked.
$ printf 'all the open items' | ikigai --connect quic://127.0.0.1:1070 --cert-dir .config/ikigai/gonk/quic/clients/bot -c 'sink urn:nl:sparql save=team-open-p2 focus=urn:iki:ledger:graph:default'
error: invalid argument `save`: urn:script:team-open-p2 is a published script, and a draft saved there would replace it; choose another name
$ grep -c ': all the open items$' model.log
0
The same, signed in
ann does all of this over HTTP, with the session cookie her passkey’s sign-in set (see The
ledger in a browser for the ceremony). These commands are the HTTP spelling
of what bot and the owner did above; the body of a POST is the ask or the query, and every
argument is a query argument:
$ curl -X POST -b 'gonk_session=…' --data-binary 'the titles of the open items' 'http://localhost:1070/nl/sparql?save=team-ann-titles&focus=urn:iki:ledger:graph:default'
urn:script:team-ann-titles:version:sha256:… (a DRAFT of urn:script:team-ann-titles: review it, then publish it with state=published)
…
$ curl -X POST -b 'gonk_session=…' --data-binary 'the titles of the open items' 'http://localhost:1070/nl/sparql?save=team-ann-titles2&focus=urn:iki:ledger:graph:default&as=application/json'
$ curl -X POST -b 'gonk_session=…' --data-binary 'the titles of the open items' 'http://localhost:1070/nl/sparql?save=team-ann-titles3&focus=urn:iki:ledger:graph:default&as=text/turtle'
$ curl -b 'gonk_session=…' 'http://localhost:1070/script/team-ann-titles'
$ curl -X POST -b 'gonk_session=…' --data-binary @team-ann-titles.rq 'http://localhost:1070/script/team-ann-titles?language=sparql&state=published'
$ curl -b 'gonk_session=…' 'http://localhost:1070/script/team-ann-titles/result?as=text/csv'
Draft, the attempts, the provenance, review, publish, run. She may publish because her draft is
her own and her door is not a machine’s; she could not publish bot’s.
What each door may do
Everything above, in one table:
| HTTP, signed out | HTTP, signed in | QUIC (a machine) | the socket (the owner) | |
|---|---|---|---|---|
| run and read a PUBLIC script | yes, within the shared budget | yes | with --script public=run | yes |
| run and read a named script | no | per --script | per --script | yes |
| publish, replace, retire | no | per --script … =publish | no: drafts only | yes |
| see a draft | no | its own | its own | every draft |
| draft from a sentence | no, before any model call | with --nl draft | with --nl draft | yes |
| what a run may read | the least of: the runner’s grant, what the publisher held at publish, and gonk’s ceiling (the ledgers’ reads) |
What you built
Nothing new, and that is the point. The atoms are the scripts (plain files, each version content-addressed, each recording what its publisher held) and the ledger’s graph. Every answer on this page is a composition the libraries already define — a script’s result, a draft from the grounding and the model door — and gonk’s own contribution is the line between them and each caller: which door, which principal, which grant, and the two rules no grant can carry (a machine drafts, a person publishes; a draft is its author’s).
Two teams, one gonk publishes a report under one team’s grant: the other team may run every report in the namespace and still cannot run that one, and a report over both teams’ ledgers is refused at publish.
Two teams, one gonk
A query’s authority is its dataset, in The ikigai Book, put two tenants’ graphs in one store and ran one SPARQL text for three callers: each got the answer its grants allowed, and a join one of them could not have was refused rather than narrowed. That chapter is the rule, taught with a kernel of its own. This one is the same rule in gonk, where the tenants are teams, the graphs are their ledgers, and the callers come in through gonk’s doors:
| who | door | what they hold |
|---|---|---|
| the owner | the socket | everything: root, as in Over the socket |
alpha-agent, team alpha’s agent | QUIC | write the alpha ledger; run the report- scripts |
beta-agent, team beta’s agent | QUIC | write the beta ledger; run the report- scripts |
manager | QUIC | read both ledgers and the repositories; run the report- scripts |
| alpha’s lead | the socket, sealed to the lead’s grant | read alpha; publish report- scripts |
| anyone on this machine | HTTP, signed out | the default ledger, and nothing of either team’s |
No part of gonk knows what a team is. Each team’s ledger is a named graph in gonk’s one store,
every grant is a list of capability tokens, and every answer on this page is a resource the
earlier chapters already used: next, the store’s query doors, a saved query, the action
manifold. What this page adds is two teams side by side, so that what each one cannot see is as
visible as what it can.
A repository both teams work in
Both teams change one file, and a code review has something to say about it. The review needs a
model, so the page starts the same stub as Scripts and sentences in gonk,
which also answers a review. In a new empty directory, as in every
chapter: if the gonk and the model-stub from Scripts and
sentences in gonk are still running, stop them first, since this gonk takes
the same port. The stub:
$ model-stub "$PWD/model.sock" | tee model.log
model-stub: urn:llm:ask and urn:llm:config on …/model.sock (model stub)
/// A name's initials: "Ada Lovelace" is "AL".
pub fn initials(name: &str) -> String {
name.split(' ').map(|word| &word[..1]).collect()
}
Saved as shop/src/lib.rs, it becomes a repository, and gonk serves it as a browse root beside
the model’s mount, as in Browse roots:
$ git init -q -b main shop
$ git -C shop add src/lib.rs
$ git -C shop -c user.name=Ada -c user.email=ada@example.com commit -q -m 'initials'
$ mkdir -p .config/ikigai
$ printf 'gonk.browse.root = "shop=%s/shop"\ngonk.mount = "prefer urn:llm:=%s/model.sock"\n' "$PWD" "$PWD" > .config/ikigai/config.toml
The grants
Three identities for the QUIC door, each built from roles, as in A QUIC
client. The scripts are a namespace both teams may run, report-*,
which matters later:
$ ikigai-gonk client add alpha-agent --ledger alpha=write --script 'report-*=run' --port 1070 --config-home "$PWD/.config/ikigai"
client `alpha-agent` …/.config/ikigai/gonk/quic/clients/alpha-agent
…
enrolled grant `alpha-agent` (6 scopes) in …/.config/ikigai/gonk/grants.json
…
$ ikigai-gonk client add beta-agent --ledger beta=write --script 'report-*=run' --port 1070 --config-home "$PWD/.config/ikigai"
client `beta-agent` …/.config/ikigai/gonk/quic/clients/beta-agent
…
enrolled grant `beta-agent` (6 scopes) in …/.config/ikigai/gonk/grants.json
…
$ ikigai-gonk client add manager --ledger alpha=read --ledger beta=read --browse read --script 'report-*=run' --port 1070 --config-home "$PWD/.config/ikigai"
client `manager` …/.config/ikigai/gonk/quic/clients/manager
…
enrolled grant `manager` (8 scopes) in …/.config/ikigai/gonk/grants.json
…
$ jq -r 'to_entries[] | .key, (.value[] | " " + .)' .config/ikigai/gonk/grants.json
alpha-agent
urn:cap:ledger:read:alpha
urn:cap:store:read:graph:urn:iki:ledger:graph:alpha
urn:cap:ledger:write:alpha
urn:cap:store:write:graph:urn:iki:ledger:graph:alpha
urn:cap:script:read:report-*
urn:cap:script:run:report-*
beta-agent
urn:cap:ledger:read:beta
urn:cap:store:read:graph:urn:iki:ledger:graph:beta
urn:cap:ledger:write:beta
urn:cap:store:write:graph:urn:iki:ledger:graph:beta
urn:cap:script:read:report-*
urn:cap:script:run:report-*
manager
urn:cap:ledger:read:alpha
urn:cap:store:read:graph:urn:iki:ledger:graph:alpha
urn:cap:ledger:read:beta
urn:cap:store:read:graph:urn:iki:ledger:graph:beta
urn:cap:browse:read:*
urn:cap:store:read:graph:urn:iki:browse:graph:default
urn:cap:script:read:report-*
urn:cap:script:run:report-*
Read them as datasets. Each agent’s store tokens name one graph, its own ledger’s. The manager’s name three: both ledgers and browse’s graph, where the review’s annotations will land. Nothing here is a team concept; a team is the set of tokens its ledger’s name appears in.
Start gonk, and fill both ledgers
$ ikigai-gonk --config-home "$PWD/.config/ikigai" --data-home "$PWD/.ikigai" --port 1070 --no-backup 2>&1 | tee gonk.log
ikigai-gonk 0.1.0 — holding the store at …/.ikigai/store
http http://localhost:1070/ — loopback (127.0.0.1:1070); anonymous read+write: default; 0 passkey(s); anonymous SPARQL budget 1000 ms
browse urn:repo:{shop (watched)}:* — annotations and archive in <urn:iki:browse:graph:default>
…
quic udp 0.0.0.0:1070 — 3 trusted certificate(s), 3 enrolled
…
The owner files one notice for everyone and two items for each team. A ledger is named in the
resource’s IRI (urn:iki:ledger:alpha:append), which is what lets a grant bind to it (Whose
ledger). One item on each team is about the same file:
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:append priority=1 The office is closed on Friday' -c 'sink urn:gk:iki:ledger:alpha:append priority=1 about=urn:repo:shop:file:src/lib.rs Initials for names with accents' -c 'sink urn:gk:iki:ledger:alpha:append priority=2 Ship the order form' -c 'sink urn:gk:iki:ledger:beta:append priority=0 about=urn:repo:shop:file:src/lib.rs Initials crash on a double space' -c 'sink urn:gk:iki:ledger:beta:append priority=3 Rotate the supplier keys'
#1 urn:iki:ledger:default:item:…
[uncacheable]
alpha#1 urn:iki:ledger:alpha:item:…
[uncacheable]
alpha#2 urn:iki:ledger:alpha:item:…
[uncacheable]
beta#1 urn:iki:ledger:beta:item:…
[uncacheable]
beta#2 urn:iki:ledger:beta:item:…
[uncacheable]
— batch: 5 commands · 5 uncacheable
Each team’s next
Each agent asks for its own ledger’s next, and asks for the other’s:
$ ikigai --connect quic://127.0.0.1:1070 --cert-dir .config/ikigai/gonk/quic/clients/alpha-agent -c 'source urn:iki:ledger:alpha:next'
1. alpha#1 open p1 Initials for names with accents
p1 — priority 1; last updated …
2. alpha#2 open p2 Ship the order form
p2 — priority 2; last updated …
policy: priority-recency (weighs priority, recency, number)
ready: 2 excluded: 0
[uncacheable]
$ ikigai --connect quic://127.0.0.1:1070 --cert-dir .config/ikigai/gonk/quic/clients/beta-agent -c 'source urn:iki:ledger:beta:next'
1. beta#1 open p0 Initials crash on a double space
p0 — priority 0; last updated …
2. beta#2 open p3 Rotate the supplier keys
p3 — priority 3; last updated …
policy: priority-recency (weighs priority, recency, number)
ready: 2 excluded: 0
[uncacheable]
$ ikigai --connect quic://127.0.0.1:1070 --cert-dir .config/ikigai/gonk/quic/clients/alpha-agent -c 'source urn:iki:ledger:beta:next'
error: denied: this capability does not hold `urn:cap:ledger:read:beta`. A ledger grant names exactly one ledger — holding a grant over another ledger satisfies the declared family `urn:cap:ledger:read:*` but not this resource, which is the point of naming them
The two nexts are one resource family, urn:iki:ledger:{ledger}:next, with one policy, and the
name is the only difference: the ledger checks it against the caller’s grant before it reads
anything. A bare urn:iki:ledger:next is default’s, which neither agent holds, so an agent of
a named ledger always says which ledger it means.
One text, four answers
next is the ledger’s own question. A question the ledger did not anticipate is SPARQL, and here
the ikigai Book’s chapter applies directly. Here is one, with no graph in it:
PREFIX ledger: <https://ikigai-rs.dev/ns/ledger#>
PREFIX dcterms: <http://purl.org/dc/terms/>
SELECT ?ledger ?title
WHERE {
GRAPH ?ledger {
?item a ledger:Item ; ledger:status ledger:open ;
ledger:priority ?priority ; dcterms:title ?title .
}
}
ORDER BY ?priority ?ledger
Over QUIC, gonk’s urn:sparql:select takes a query with no graph= over the union of every
graph the caller may read (Asking in SPARQL). The same file,
piped by each identity in turn (a batch’s stdin fills a source whose one required argument,
query, is unfilled):
$ cat open.rq | ikigai --connect quic://127.0.0.1:1070 --cert-dir .config/ikigai/gonk/quic/clients/alpha-agent -c 'source urn:sparql:select as=text/csv'
ledger,title
urn:iki:ledger:graph:alpha,Initials for names with accents
urn:iki:ledger:graph:alpha,Ship the order form
[uncacheable]
$ cat open.rq | ikigai --connect quic://127.0.0.1:1070 --cert-dir .config/ikigai/gonk/quic/clients/beta-agent -c 'source urn:sparql:select as=text/csv'
ledger,title
urn:iki:ledger:graph:beta,Initials crash on a double space
urn:iki:ledger:graph:beta,Rotate the supplier keys
[uncacheable]
$ cat open.rq | ikigai --connect quic://127.0.0.1:1070 --cert-dir .config/ikigai/gonk/quic/clients/manager -c 'source urn:sparql:select as=text/csv'
ledger,title
urn:iki:ledger:graph:beta,Initials crash on a double space
urn:iki:ledger:graph:alpha,Initials for names with accents
urn:iki:ledger:graph:alpha,Ship the order form
urn:iki:ledger:graph:beta,Rotate the supplier keys
[uncacheable]
$ curl -s -G -H 'Accept: text/csv' --data-urlencode query@open.rq http://127.0.0.1:1070/sparql
ledger,title
urn:iki:ledger:graph:default,The office is closed on Friday
Four answers, all correct, and nobody checked the text against a list of permitted graphs. The
fourth is the signed-out HTTP caller’s, whose union is the one ledger the HTTP door grants
anonymously, default. (--data-urlencode query@open.rq is curl’s way of sending a file’s
content as the query parameter.)
The manager’s join
Two teams working on one function is worth knowing before either merges. Which items, in the two ledgers, are about the same thing?
PREFIX ledger: <https://ikigai-rs.dev/ns/ledger#>
PREFIX dcterms: <http://purl.org/dc/terms/>
SELECT ?about ?alpha ?beta
WHERE {
GRAPH <urn:iki:ledger:graph:alpha> { ?a ledger:about ?about ; dcterms:title ?alpha }
GRAPH <urn:iki:ledger:graph:beta> { ?b ledger:about ?about ; dcterms:title ?beta }
}
The manager holds both graphs and gets the join. Team alpha’s agent names the same dataset, both ledgers, and is refused, naming the grant it lacks:
$ cat overlap.rq | ikigai --connect quic://127.0.0.1:1070 --cert-dir .config/ikigai/gonk/quic/clients/manager -c 'source urn:sparql:select as=text/csv graph="urn:iki:ledger:graph:alpha urn:iki:ledger:graph:beta"'
about,alpha,beta
urn:repo:shop:file:src/lib.rs,Initials for names with accents,Initials crash on a double space
[uncacheable]
$ cat overlap.rq | ikigai --connect quic://127.0.0.1:1070 --cert-dir .config/ikigai/gonk/quic/clients/alpha-agent -c 'source urn:sparql:select as=text/csv graph="urn:iki:ledger:graph:alpha urn:iki:ledger:graph:beta"'
error: denied: reading <urn:iki:ledger:graph:beta> through `urn:iki:store:graph-select` needs the grant `urn:cap:store:read:graph:urn:iki:ledger:graph:beta`, which this capability does not hold. Every graph a scoped read names must be granted: the read is refused rather than answered over the graphs that are, because a narrower dataset than the one asked for returns rows that look right and are not. A grant names exactly one graph; holding `urn:cap:store:read` does not imply it, and is instead the authority for `urn:iki:store:select` over the whole dataset.
That is Refuse, never narrow, through gonk’s
QUIC door. Now the same agent asks without graph=, leaving the dataset to its grant:
$ cat overlap.rq | ikigai --connect quic://127.0.0.1:1070 --cert-dir .config/ikigai/gonk/quic/clients/alpha-agent -c 'source urn:sparql:select as=text/csv'
about,alpha,beta
[uncacheable]
⚠ An empty answer, not a refusal, at this book’s pin. The dataset is alpha’s graph alone,
the GRAPH <…beta> block names a graph outside it, and a constant graph outside the dataset
matches nothing. Nothing leaked, since beta’s graph was never in the dataset; but “no overlap” is
the answer alpha’s agent would act on, and it is a statement about its grant, not about the
teams. The gonk this book pins links ikigai-store 0.2.21. From 0.2.22 the store refuses a query
that names a graph outside its dataset, by name, as the ikigai Book’s escape
table shows; until a pinned gonk links
it, name the dataset you mean with graph=, which is refused here as it is there.
Joined with the code review
The manager’s third graph is browse’s, where a published review finding becomes an annotation on the line it is about. The owner runs one review pass over the file with the stub, as in One-off review, and publishes the serious finding, which a person would do with a reproduction in hand:
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:repo:shop:review:src/lib.rs'
review by stub · review-v5@stub · 2 findings · reviewed the whole file (142 bytes)
--- annotations (2) ---
L2 [review:stub] "pub fn initials(name: &str) -> String {" -- Returning a String allocates on every call; return a &str instead.
L3 [review:stub] "name.split(' ').map(|word| &word[..1]).collect()" -- Slicing the first byte panics on an empty word, so a name with two spaces in a row, or an empty name, crashes the caller.
[uncacheable]
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:finding:a385f29c06511574fc4be921 decision=publish reproduced=yes initials("Ada Lovelace") panics: the empty middle word has no byte 0.'
urn:iki:annotation:a385f29c06511574fc4be921
[uncacheable]
The finding’s id is a hash of the pass and the quote, so it is the same on every machine that reviews these bytes. Now the question the manager actually has: what work, in either team, is about a line someone has published a finding on?
PREFIX ledger: <https://ikigai-rs.dev/ns/ledger#>
PREFIX dcterms: <http://purl.org/dc/terms/>
PREFIX ik: <https://ikigai-rs.dev/ns#>
PREFIX oa: <http://www.w3.org/ns/oa#>
SELECT ?ledger ?title ?line
WHERE {
GRAPH ?ledger { ?item ledger:about ?file ; dcterms:title ?title }
?note a oa:Annotation ; ik:annotates ?file ; oa:hasSelector ?at .
?at oa:exact ?line .
}
ORDER BY ?ledger
$ cat reviewed.rq | ikigai --connect quic://127.0.0.1:1070 --cert-dir .config/ikigai/gonk/quic/clients/manager -c 'source urn:sparql:select as=text/csv graph="urn:iki:ledger:graph:alpha urn:iki:ledger:graph:beta urn:iki:browse:graph:default"'
ledger,title,line
urn:iki:ledger:graph:alpha,Initials for names with accents,name.split(' ').map(|word| &word[..1]).collect()
urn:iki:ledger:graph:beta,Initials crash on a double space,name.split(' ').map(|word| &word[..1]).collect()
[uncacheable]
$ cat reviewed.rq | ikigai --connect quic://127.0.0.1:1070 --cert-dir .config/ikigai/gonk/quic/clients/alpha-agent -c 'source urn:sparql:select as=text/csv graph="urn:iki:ledger:graph:alpha urn:iki:browse:graph:default"'
error: denied: reading <urn:iki:browse:graph:default> through `urn:iki:store:graph-select` needs the grant `urn:cap:store:read:graph:urn:iki:browse:graph:default`, which this capability does not hold. Every graph a scoped read names must be granted: the read is refused rather than answered over the graphs that are, because a narrower dataset than the one asked for returns rows that look right and are not. A grant names exactly one graph; holding `urn:cap:store:read` does not imply it, and is instead the authority for `urn:iki:store:select` over the whole dataset.
Three graphs in one dataset, two teams’ work and one review, joined on the file’s IRI that the
ledger’s about and browse’s ik:annotates both name. That join is why gonk keeps the ledgers
and browse in one store (The embedding chapter builds it in
a kernel of your own). Alpha’s agent may ask its half of it only when it holds the review’s graph
too.
A report one team publishes
A saved query is published once and run by other people later, so its authority cannot come from
whoever runs it; it is derived from the graphs its text names, and it is never more than its
publisher held (Who a script runs as). Team alpha’s lead publishes
one. A lead is a person, and would sign in with a passkey and publish over HTTP as ann did in
Scripts and sentences in gonk; this page cannot
perform a passkey ceremony, so it does what that chapter did, at the owner’s socket, sealed to
exactly the lead’s grant: alpha’s ledger read, and publishing in report-. What the seal holds is
what the publish records.
# Team alpha's open items, lowest number first.
PREFIX ledger: <https://ikigai-rs.dev/ns/ledger#>
PREFIX dcterms: <http://purl.org/dc/terms/>
SELECT ?number ?title
FROM <urn:iki:ledger:graph:alpha>
WHERE {
?item a ledger:Item ; ledger:status ledger:open ;
ledger:number ?number ; dcterms:title ?title .
}
ORDER BY ?number
$ cat report-alpha-open.rq | ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'cap seal urn:cap:ledger:read:alpha urn:cap:store:read:graph:urn:iki:ledger:graph:alpha urn:cap:script:read:report-* urn:cap:script:run:report-* urn:cap:script:write:report-* urn:cap:script:delete:report-*' -c 'sink urn:gk:script:report-alpha-open language=sparql public=true'
sealed — capability: urn:cap:ledger:read:alpha, urn:cap:script:delete:report-*, urn:cap:script:read:report-*, urn:cap:script:run:report-*, urn:cap:script:write:report-*, urn:cap:store:read:graph:urn:iki:ledger:graph:alpha · sealed (this is the floor: `cap reset`, `logout` and `login` cannot rise above it)
urn:script:report-alpha-open:version:sha256:…
[uncacheable]
— batch: 2 commands · uncacheable
All three identities hold run over report-*, so the namespace is not what tells them apart:
$ ikigai --connect quic://127.0.0.1:1070 --cert-dir .config/ikigai/gonk/quic/clients/alpha-agent -c 'source urn:script:report-alpha-open:result as=text/csv'
number,title
1,Initials for names with accents
2,Ship the order form
[uncacheable]
$ ikigai --connect quic://127.0.0.1:1070 --cert-dir .config/ikigai/gonk/quic/clients/manager -c 'source urn:script:report-alpha-open:result as=text/csv'
number,title
1,Initials for names with accents
2,Ship the order form
[uncacheable]
$ ikigai --connect quic://127.0.0.1:1070 --cert-dir .config/ikigai/gonk/quic/clients/beta-agent -c 'source urn:script:report-alpha-open:result as=text/csv'
error: denied: capability does not grant `urn:cap:store:read:graph:urn:iki:ledger:graph:alpha` (declared by `urn:script:report-alpha-open:result`)
A run grant is not a read grant. Beta’s agent may run every report- script, and this one
reads alpha’s graph, which its grant does not name; the script’s own description declares that
token, so the kernel refuses before the script host runs anything.
The ceiling runs the other way too. Alpha’s lead tries to publish a report over both teams’ ledgers:
PREFIX ledger: <https://ikigai-rs.dev/ns/ledger#>
PREFIX dcterms: <http://purl.org/dc/terms/>
SELECT ?title
FROM <urn:iki:ledger:graph:alpha>
FROM <urn:iki:ledger:graph:beta>
WHERE { ?item a ledger:Item ; dcterms:title ?title }
$ cat report-everyone.rq | ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'cap seal urn:cap:ledger:read:alpha urn:cap:store:read:graph:urn:iki:ledger:graph:alpha urn:cap:script:read:report-* urn:cap:script:run:report-* urn:cap:script:write:report-* urn:cap:script:delete:report-*' -c 'sink urn:gk:script:report-everyone language=sparql'
sealed — capability: urn:cap:ledger:read:alpha, urn:cap:script:delete:report-*, urn:cap:script:read:report-*, urn:cap:script:run:report-*, urn:cap:script:write:report-*, urn:cap:store:read:graph:urn:iki:ledger:graph:alpha · sealed (this is the floor: `cap reset`, `logout` and `login` cannot rise above it)
error: denied: a script cannot be given more than its publisher holds, and this capability does not hold urn:cap:store:read:graph:urn:iki:ledger:graph:beta — declare less, or publish under a capability that holds it
No elevation. A script is not a way to hand someone else’s authority to a third party: if the lead could publish it, the manager running it would be running beta’s read under alpha’s name. The refusal is at publish, so no such script exists for anyone to run.
What a signed-out caller sees
The HTTP door serves anyone on this machine without an account, under the anonymous grant: the
ledgers in gonk.http.ledger, default unless configured. Neither team’s ledger is in it:
$ curl -s http://localhost:1070/l/alpha
denied: Sign in to read the ledger "alpha".
Signed in already? Your passkey's grant doesn't include the ledger "alpha".
The grant it needs: urn:cap:ledger:read:alpha (to read the ledger "alpha")
$ curl -s -G -H 'Accept: text/csv' --data-urlencode 'query=SELECT ?g (COUNT(*) AS ?n) WHERE { GRAPH ?g { ?s ?p ?o } } GROUP BY ?g' http://127.0.0.1:1070/sparql
g,n
urn:iki:ledger:graph:default,…
$ curl -s -w ' (%{http_code})\n' 'http://localhost:1070/script/report-alpha-open/result?as=text/csv'
denied: capability does not grant `urn:cap:store:read:graph:urn:iki:ledger:graph:alpha` (declared by `urn:script:report-alpha-open:result`) (403)
The lead published the report public=true, and a signed-out caller still cannot run it.
public says who may run a script, the anonymous grant’s urn:cap:script:run:public; what a
run may read is the runner’s own grant, and the anonymous one does not name alpha’s graph. A
public report over the default ledger would answer, as open-by-label did in Scripts and
sentences in gonk.
Deleted is gone, unless you hold the graveyard
A delete is a move into the ledger’s own graveyard graph, recoverable by hand (Over the socket). The owner deletes alpha’s second item:
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'delete urn:gk:iki:ledger:alpha:item:2'
deleted alpha#2 urn:iki:ledger:alpha:item:…
7 quad(s) moved to <urn:iki:ledger:graph:alpha:deleted> and recoverable
tombstone: urn:iki:ledger:alpha:tombstone:…
[uncacheable]
$ ikigai --connect quic://127.0.0.1:1070 --cert-dir .config/ikigai/gonk/quic/clients/alpha-agent -c 'source urn:iki:ledger:alpha:next'
1. alpha#1 open p1 Initials for names with accents
p1 — priority 1; last updated …
policy: priority-recency (weighs priority, recency, number)
ready: 1 excluded: 0
[uncacheable]
$ ikigai --connect quic://127.0.0.1:1070 --cert-dir .config/ikigai/gonk/quic/clients/alpha-agent -c 'source urn:iki:ledger:alpha:item:2'
error: not found: alpha#2 was deleted: its tombstone is `urn:iki:ledger:alpha:tombstone:…` and its content, unless purged, is in `urn:iki:ledger:graph:alpha:deleted`
Alpha’s agent is told the item was deleted and where its content went, which is the ledger’s own answer for a name it once served. The graveyard is a graph like any other, so the question of who sees it is the dataset question again. The same count of quads per graph, at root and as the manager:
SELECT ?g (COUNT(*) AS ?n) WHERE { GRAPH ?g { ?s ?p ?o } } GROUP BY ?g ORDER BY ?g
$ cat graphs.rq | ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:sparql:select as=text/csv'
g,n
urn:iki:browse:graph:default,…
urn:iki:ledger:graph:alpha,…
urn:iki:ledger:graph:alpha:deleted,7
urn:iki:ledger:graph:beta,…
urn:iki:ledger:graph:default,…
[uncacheable]
$ cat graphs.rq | ikigai --connect quic://127.0.0.1:1070 --cert-dir .config/ikigai/gonk/quic/clients/manager -c 'source urn:sparql:select as=text/csv'
g,n
urn:iki:browse:graph:default,…
urn:iki:ledger:graph:alpha,…
urn:iki:ledger:graph:beta,…
[uncacheable]
$ cat graphs.rq | ikigai --connect quic://127.0.0.1:1070 --cert-dir .config/ikigai/gonk/quic/clients/manager -c 'source urn:sparql:select as=text/csv graph=urn:iki:ledger:graph:alpha:deleted'
error: denied: reading <urn:iki:ledger:graph:alpha:deleted> through `urn:iki:store:graph-select` needs the grant `urn:cap:store:read:graph:urn:iki:ledger:graph:alpha:deleted`, which this capability does not hold. Every graph a scoped read names must be granted: the read is refused rather than answered over the graphs that are, because a narrower dataset than the one asked for returns rows that look right and are not. A grant names exactly one graph; holding `urn:cap:store:read` does not imply it, and is instead the authority for `urn:iki:store:select` over the whole dataset.
Reading alpha’s ledger is not reading what alpha deleted. The token that is, is part of the ledger’s strongest role:
$ ikigai-gonk grants alpha purge | grep deleted
"urn:cap:store:write:graph:urn:iki:ledger:graph:alpha:deleted",
"urn:cap:store:read:graph:urn:iki:ledger:graph:alpha:deleted"
delete writes the graveyard (a delete moves quads into it); purge reads it too, because
purging an item means finding its quads there. A role below purge sees a deleted item only as
the ledger’s own word for it, its tombstone.
Two tool lists, and a third
An agent’s tools are the action manifold under its capability, urn:kernel:actions; ikigai mcp projects exactly that list as MCP tools (Over MCP). Each identity asks for
its own, and comm compares them:
$ ikigai --connect quic://127.0.0.1:1070 --cert-dir .config/ikigai/gonk/quic/clients/alpha-agent -c 'source urn:kernel:actions' 2>/dev/null | LC_ALL=C sort > alpha.tools
$ ikigai --connect quic://127.0.0.1:1070 --cert-dir .config/ikigai/gonk/quic/clients/beta-agent -c 'source urn:kernel:actions' 2>/dev/null | LC_ALL=C sort > beta.tools
$ ikigai --connect quic://127.0.0.1:1070 --cert-dir .config/ikigai/gonk/quic/clients/manager -c 'source urn:kernel:actions' 2>/dev/null | LC_ALL=C sort > manager.tools
$ LC_ALL=C comm -3 alpha.tools beta.tools
urn:script:report-alpha-open:result
urn:script:report-alpha-open:runs
$ LC_ALL=C comm -23 alpha.tools manager.tools
urn:iki:ledger:{ledger}:append
urn:iki:ledger:{ledger}:claim
urn:iki:ledger:{ledger}:close
urn:iki:ledger:{ledger}:comment
urn:iki:ledger:{ledger}:defer
urn:iki:ledger:{ledger}:item:{id}:about
urn:iki:ledger:{ledger}:label
urn:iki:ledger:{ledger}:link
urn:iki:ledger:{ledger}:reopen
urn:iki:store:graph-update
$ LC_ALL=C comm -13 alpha.tools manager.tools | grep -c '^urn:repo:shop:'
19
Three things to read in that:
- Alpha’s and beta’s lists differ by one report. The ledger’s actions are templates over
{ledger}, declared against the family (urn:cap:ledger:write:*), so both writers are offered the same ledger tools, and the ledger checks the name each call carries. Alpha’s report is in alpha’s list and not in beta’s, because its description declares alpha’s graph: the manifold offers a published query only to a caller who could run it, which is the ikigai Book’s Authority from the text in gonk. - The manager’s list has no writer’s tools. Reading two ledgers is not writing either, so
append,claim,closeand the store’s update door are absent, not offered and refused. - The manager’s list has the repository’s. Nineteen of the browse root’s resources, which neither agent holds a token for.
So an MCP agent built on alpha’s certificate is not told what beta’s report is called, and an agent built on the manager’s cannot be talked into filing anything: the tool it would need is not in its list.
What you have now
| the question | what answered it | shown by |
|---|---|---|
| what is next for my team? | urn:iki:ledger:{ledger}:next, under a grant naming the ledger | each agent’s next, and the other’s refused |
| what may a query read? | the union of the graphs the caller holds, or the graph= it names | one text, four answers |
| what if one graph is not granted? | refused, naming the grant; at this pin, empty if the graph is only in the text | the manager’s join, and alpha’s two tries |
| can the teams’ work be joined with the review? | yes, for a caller holding all three graphs | the review join |
| who may run a published report? | whoever holds both its run grant and the graphs it reads | report-alpha-open, three runners |
| may a report read more than its publisher? | no, refused at publish | report-everyone |
| what does a signed-out caller see? | the anonymous ledgers, and a public report only over them | the HTTP door |
| who sees a deleted item? | a caller holding the graveyard’s read token (purge), or root | the counts per graph |
| what is an agent offered? | the manifold under its grant | the three tool lists |
In resource terms: the sets are the ledgers, their items and the reports; the atoms that
hold state are one named graph per ledger, one graveyard graph per ledger, browse’s graph, and the
scripts’ files; everything else is a composition asked under the caller’s capability, and every
view above is one of them. The code written for two teams was none: three client add lines,
one sealed publish, and the names of two ledgers.
Solo on one machine
One person and one agent, one machine, one gonk. The agent takes an item, does the work on a branch, gets a pull request through CI, and reports with the evidence; the person, who dispatched the work to begin with, checks the evidence and closes. Working alone you hold both roles, and the process still keeps them apart: a report is not a close, and a close is a statement that the evidence holds.
Who
| role | who | how it reaches gonk |
|---|---|---|
| the dispatcher | you | the socket, as the owner |
| the worker | you, or an agent you started | the socket, as the owner, with its own holder name |
| the model (optional) | a local model gonk mounts, for explanations and one-off review | gonk dials it; nobody else does |
Both of you are the owner over the socket (Over the socket): the socket
cannot tell your terminal from an agent’s, and the access log says principal=owner for both. What
tells you apart in the ledger is the holder name on a claim. An agent that should be told apart by
gonk itself, or held to less than everything, is the next chapter’s.
What runs where
one machine
┌───────────────────────────────────────────────────────────────┐
│ you ──┐ │
│ ├── ikigai --mount urn:gk:=…/gonk.sock ──▶ gonk ──▶ store
│ agent ┘ (the owner-only socket) │ │
│ └─▶ model (optional, mounted)
│ git: a branch per item ──push──▶ the forge: pull request + CI │
└───────────────────────────────────────────────────────────────┘
The forge (GitHub, say) is the one thing not on the machine; this page cannot reach it, and the steps that need it are marked.
The loop, step by step
0. Once: a gonk, a repository, and a model
A model is optional, and this page mounts the book’s stub, so that the one explanation below is
answered without a network: in practice it is a local model host
(Explain, with a mounted model). In a new empty directory, as
in every chapter: if the gonk and the model-stub from
Two teams, one gonk are still running, stop them first, since this gonk
takes the same port.
$ model-stub "$PWD/model.sock" | tee model.log
model-stub: urn:llm:ask and urn:llm:config on …/model.sock (model stub)
$ git init -q -b main demo
$ mkdir -p .config/ikigai
$ printf 'gonk.browse.root = "demo=%s/demo"\ngonk.mount = "prefer urn:llm:=%s/model.sock"\n' "$PWD" "$PWD" > .config/ikigai/config.toml
/// A name's initials: "Ada Lovelace" is "AL".
pub fn initials(name: &str) -> String {
name.split(' ').map(|word| &word[..1]).collect()
}
$ git -C demo add src/lib.rs
$ git -C demo -c user.name=Ada -c user.email=ada@example.com commit -q -m 'initials'
$ ikigai-gonk --config-home "$PWD/.config/ikigai" --data-home "$PWD/.ikigai" --port 1070 --no-quic --no-backup 2>&1 | tee gonk.log
ikigai-gonk 0.1.0 — holding the store at …/.ikigai/store
…
socket …/.ikigai/gonk.sock — owner only
…
1. File
The work starts as an item, with what it is for in its body. That body is the brief: it is what the worker reads, and what the evidence will be checked against.
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:append priority=1 labels=bug initials panics on an empty word'
#1 urn:iki:ledger:default:item:…
[uncacheable]
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:comment item=1 author=ada Done when initials("Ada Lovelace") is "AL" and a test says so.'
commented on #1 (urn:iki:ledger:default:comment:…)
[uncacheable]
2. Ask what is next, and claim it
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:iki:ledger:next'
1. #1 open p1 initials panics on an empty word [bug]
p1 — priority 1; last updated …
policy: priority-recency (weighs priority, recency, number)
ready: 1 excluded: 0
[uncacheable]
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:claim item=1 lease=30m agent'
#1 claimed by agent until …
[uncacheable]
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:iki:ledger:next'
nothing is ready (1 open item(s) excluded: #1 claimed by agent)
policy: priority-recency
[uncacheable]
A claim is a lease: it says who holds the item and until when. next stops offering a claimed
item, so nothing else picks it up, and a lease that runs out does not quietly free it: next
names the holder whose lease expired, and only a deliberate takeover moves it
(the ledger’s claims). A worker that is still at it renews:
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:claim item=1 renew=true lease=30m agent'
#1 renewed by agent until …
[uncacheable]
renew=true renews and never takes: if the claim had changed hands, this would be refused rather
than quietly taking the item back.
3. Read before you change
The model is optional and read-only: it explains a file once per version of its content, and every later read is the archive’s (Explain, with a mounted model).
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:repo:demo:explain:src/lib.rs'
Defines `initials`, which joins the first letter of each space-separated word of a name into one string.
[uncacheable]
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:repo:demo:explain:src/lib.rs'
Defines `initials`, which joins the first letter of each space-separated word of a name into one string.
[uncacheable]
$ cat model.log
model-stub: urn:llm:ask and urn:llm:config on …/model.sock (model stub)
model-stub: ask 1: explain
4. Branch, fix, commit
One branch per item, named for it. The fix comes with the test the item’s body asked for.
$ git -C demo switch -q -c item-1-empty-word
$ printf '/// A name'"'"'s initials: "Ada Lovelace" is "AL", and so is "Ada Lovelace".\npub fn initials(name: &str) -> String {\n name.split_whitespace().filter_map(|word| word.get(..1)).collect()\n}\n' > demo/src/lib.rs
$ git -C demo -c user.name=Agent -c user.email=agent@example.com commit -q -am 'initials: skip empty words (ledger item 1)'
$ git -C demo log --oneline
… initials: skip empty words (ledger item 1)
… initials
5. A pull request, and CI
$ git -C demo push -u origin item-1-empty-word
$ gh pr create --fill
https://github.com/you/demo/pull/7
$ gh pr checks 7 --watch
The pull request is where the change is reviewed and where CI says whether it holds. Green CI is the evidence a machine can check; nothing in this loop accepts “it works on my machine”.
6. Report, with the evidence
The worker hands the item back with the evidence as a comment, marked as a hand-back, so the
ledger knows the item now waits for its dispatcher, and then releases its claim. The URL is the one
step 5 printed (this page cannot open a real pull request, so it is the shape of one). author= is
the worker’s name for itself, a claim the socket records and does not prove:
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:comment item=1 author=agent handback=true https://github.com/you/demo/pull/7 adds the test, CI green'
commented on #1 (urn:iki:ledger:default:comment:…) awaiting-dispatcher
[uncacheable]
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'delete urn:gk:iki:ledger:claim item=1 from=agent'
#1 released
[uncacheable]
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:iki:ledger:next'
nothing is ready (1 open item(s) excluded: #1 handed back, awaiting the dispatcher)
policy: priority-recency
[uncacheable]
The release names its holder with from=, which makes it a compare-and-set: had someone taken
the item over meanwhile, it would release nothing. And next still does not offer the item to
anyone, now for the other reason: it is the dispatcher’s.
7. Close, as your own dispatcher
The dispatcher reads the evidence (the pull request merged, CI green, the test in the diff), and only then closes, with a reason and a note that says what was checked. (Had the worker kept its claim, the close would have released it in the same write and said so.)
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:close item=1 reason=done author=ada Checked: PR 7 merged with CI green; the test covers two spaces.'
closed #1 (done)
[uncacheable]
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:iki:ledger:item:1'
#1 closed p1 initials panics on an empty word [bug] (done)
iri: urn:iki:ledger:default:item:…
filed: …
updated: …
3 comment(s):
… ada
Done when initials("Ada Lovelace") is "AL" and a test says so.
… agent
https://github.com/you/demo/pull/7 adds the test, CI green
… ada
Checked: PR 7 merged with CI green; the test covers two spaces.
[uncacheable]
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:iki:ledger:doctor'
doctor: default (lifecycle kata-flight): no problems
checked: orphaned, abandoned, lease-expired, unknown-state, two-states, awaiting-closed at …
[uncacheable]
What holds it
The guides are the rules both of you work to, written where an agent reads them (an
AGENTS.md in the repository, say): no write without a claim; reproduce a reported bug before
fixing it; every change through a pull request with green CI; report with evidence; the
dispatcher closes. And the item’s body, which says what done means.
The sensors:
- The claim and its lease. A second claim on a held item is refused, naming the holder; a
lease that runs out shows up in
nextrather than freeing the work silently. - CI on the pull request, the one check that does not depend on anyone’s word.
- The hand-back, which takes the item out of
nextuntil a dispatcher answers it. - Your own evidence check at close: the weakest sensor here, because the checker is the worker. Make it a habit to open the pull request before closing, not after.
- The doctor, which reports claims nobody is working (a closed item still held, a lease long expired). Clean here: the worker let go of its claim when it handed the item back.
What it costs, and when to choose it
It costs nearly nothing: one gonk, one socket, no identities to mint, no second machine. What it does not give you is separation. The agent holds the owner’s authority unless its session seals itself to less (Over the socket), the log cannot tell the two of you apart, and the evidence check is self-review.
Choose it when one person is responsible for everything that merges and the agent works where that person can see it. Move to remote workers when an agent should run unattended or on another machine, hold less than everything, and be unable to close its own work.
With the starter
The gonk starter packages this process as a
profile, solo: its tools/new.py --process solo generates a repository (a library-lending
sample, with its specs) whose AGENTS.md holds the rules above and no others, and whose
.gonk/process.toml records the process for tools/gonk.py to read back. The book checks the
starter at one revision, held in one file (books/gonk/starter.rev) the way the gonk it
pins is. (LC_ALL=C, so the listing’s order is the same on every
machine.)
$ git clone --quiet https://github.com/ikigai-rs/ikigai-gonk-starter starter
$ git -C starter checkout --quiet c7a501a29e93b1160f67007b080df9f2dbbe48d3
$ cd starter && python3 tools/new.py ../library --process solo
generated the library-lending sample (python, process solo) in …/library: 59 files
next, in that directory:
git init, then commit everything it wrote (the one commit where every file is yours)
python3 scripts/test.py every test, as CI runs them
python3 tools/gonk.py init --ledger <name> --socket ~/.ikigai/gonk.sock --holder <you>
python3 tools/gonk.py seed file the open changes' tasks as items
python3 tools/gonk.py next what to do first, and why
AGENTS.md holds this process's rules; README.md walks through the sample.
$ LC_ALL=C ls -A library
.gitattributes
.github
.gitignore
.gonk
AGENTS.md
CLAUDE.md
README.md
artifacts
docs
hooks
justfile
openspec
scripts
tests
tools
$ grep '^## ' library/AGENTS.md
## The loop
## 1. Claim the item before you write
## 2. One agent per claimed item, one agent per working tree
## 3. Reproduce before you fix
## 4. Every change through a pull request with green CI
## 5. Report with evidence; the dispatcher closes
## 6. Never a bare `#N`
## Specs, artifacts, queries and scripts
## Optional: a hook for Claude Code
$ cat library/.gonk/process.toml
# How this repository's team works: the process profiles it was generated with, and what
# each one asks of it. Committed. Written by ikigai-gonk-starter's tools/new.py from its
# profiles/; `python3 tools/gonk.py init` and `status` report it and read nothing else to
# decide it. AGENTS.md holds the rules of exactly these profiles, so change both together.
version = 1
process = ["solo"]
hooks = ["claude:require-claim"]
checks = []
[profile.solo]
summary = "one person and their agent: you are your own dispatcher, and close after checking your own evidence"
roles = ["you: both the dispatcher and whoever does the work"]
AGENTS.md’s numbered sections are this chapter’s guides, and CLAUDE.md is a link to it. The one
hook every profile carries, claude:require-claim, is an optional Claude Code hook that refuses an
edit while the working tree holds no claim; it is off until its settings are merged. init names
the ledger, the socket and the holder this working tree claims under, then asks this page’s gonk:
$ cd library && git init -q && python3 tools/gonk.py init --ledger default --socket ../.ikigai/gonk.sock --holder agent
gonk init: …/library
ok layout openspec at openspec/; artifacts at artifacts/; queries at .gonk/queries/; scripts at .gonk/scripts/
note harness 4 harness question(s) in .gonk/queries/ are asked by `python3 tools/gonk.py harness` through gonk's store, not published: gonk runs a script under a ceiling of ledger reads, and they read the harness graph
note openspec 8 open task(s) in 2 change(s) in flight: `python3 tools/gonk.py seed` files them as ledger items (again is a no-op)
ok process solo (.gonk/process.toml)
note solo one person and their agent: you are your own dispatcher, and close after checking your own evidence
note role you: both the dispatcher and whoever does the work
note hook the Claude Code claim hook is optional and off: merge hooks/claude/settings.sample.json into .claude/settings.json to turn it on
ok ikigai ikigai 0.1.61 at …/ikigai
ok ledger `default` (.gonk/gonk.toml)
ok holder `agent` (.gonk/local.toml, this working tree only)
ok identity the socket's owner: every request over it is `owner`, so the log cannot tell this tree from any other process of yours. A machine that must be named uses QUIC
ok gonk socket …/.ikigai/gonk.sock answers
ok items `default` holds 1 item(s), 0 open
ok scripts gonk offers `urn:script:catalog`: saved queries and workflow scripts (ikigai-script)
ok scripts published 4: urn:script:default-open-items-for-artifact, urn:script:default-open-work-by-change, urn:script:default-requirements-with-open-work, urn:script:default-next-for-artifact
ok nl gonk offers `urn:nl:sparql`: drafting a query from a sentence (ikigai-nl)
agents over MCP (configure the agent with this command):
ikigai mcp --override urn:iki:ledger:=…/.ikigai/gonk.sock
ready.
$ cd library && python3 tools/gonk.py status
process: solo
no live claim: this working tree holds no claim. Claim the item first: `python3 tools/gonk.py claim <number>` (or `just claim <number>`)
init also published the sample’s four saved queries to gonk (Scripts and
sentences), as the owner may. status is the question the claim hook asks
before every edit; here it says to claim first, which is step 2 above as python3 tools/gonk.py claim <number>. The harness row is about the repository’s harness, its rules and checks as a
graph, which Harness engineering walks through.
Profiles combine: --process takes a list, because they answer different questions (who
dispatches, how the work is defined, how bugs are found), so --process solo,spec is this chapter
with spec first’s rules added. With no --process at all a repository gets
solo,workers,spec. The one pair refused is unled audits with review every
commit, two answers to the same question.
Remote workers
A dispatcher files work; unattended worker machines take it, do it and hand it back with evidence; the dispatcher checks the evidence and closes. The difference from Solo on one machine is not the steps, which are the same, but who may take which of them. A worker reaches gonk over QUIC with an identity of its own, and its grant lets it read, claim, renew, comment and release. It cannot close, and it cannot merge: whatever it says about its own work stays a claim until someone else checks it.
Who
| role | who | holds |
|---|---|---|
| the dispatcher | a person (or a coordinating agent) on the gonk machine | the owner’s socket: everything, including close |
| a worker | a machine running an agent, unattended | a certificate, and the ledger’s worker role: read, claim, comment |
| the forge | GitHub, say | the worker pushes and opens pull requests as a machine user that cannot merge |
What runs where
gonk machine worker machines (each its own bundle)
┌────────────────────────────────┐ ┌───────────────────────────────────┐
│ dispatcher ── socket ──▶ gonk ◀┼── QUIC ─────┤ worker-1: next, claim, comment, │
│ │ │ UDP 1070 │ release; a worktree per │
│ store │ │ item, an agent in it │
└────────────────────────────────┘ ├───────────────────────────────────┤
│ worker-2: the same, its own cert │
└───────────────────────────────────┘
both sides ──▶ the forge: a branch and a pull request per item; CI
This page plays every machine on one, the way A QUIC client does:
the server’s homes are in the page’s directory, and worker-1/ and worker-2/ stand in for the
two workers. On real machines only the address changes, and the bundle is moved, not copied.
The loop, step by step
0. Once: an identity per worker
In a new empty directory, as in every chapter: if the gonk
and the model-stub from Solo on one machine are still running, stop them first, since
this page’s gonk takes the same port (it needs no model).
client add … --worker <ledger> mints a certificate and enrolls it under the ledger’s worker role
(--ledger default=worker says the same):
$ mkdir -p .config/ikigai
$ ikigai-gonk client add worker-1 --worker default --port 1070 --config-home "$PWD/.config/ikigai"
client `worker-1` …/.config/ikigai/gonk/quic/clients/worker-1
fingerprint …
principal urn:iki:host:gonk:client:…
enrolled grant `worker-1` (5 scopes) in …/.config/ikigai/gonk/grants.json
…
$ ikigai-gonk client add worker-2 --worker default --port 1070 --config-home "$PWD/.config/ikigai"
client `worker-2` …/.config/ikigai/gonk/quic/clients/worker-2
…
enrolled grant `worker-2` (5 scopes) in …/.config/ikigai/gonk/grants.json
…
$ jq -r '.["worker-1"][]' .config/ikigai/gonk/grants.json
urn:cap:ledger:read:default
urn:cap:store:read:graph:urn:iki:ledger:graph:default
urn:cap:ledger:claim:default
urn:cap:ledger:comment:default
urn:cap:store:write:graph:urn:iki:ledger:graph:default
Read, claim and comment on one ledger; the store write token is the one the ledger makes its own
writes with, and gonk refuses a worker’s raw write to the ledger’s graph
(A QUIC client). No close, no write, no
delete. Each worker gets its own identity, so the log, the claims and the comments can tell them
apart.
$ ikigai-gonk --config-home "$PWD/.config/ikigai" --data-home "$PWD/.ikigai" --port 1070 --no-backup 2>&1 | tee gonk.log
ikigai-gonk 0.1.0 — holding the store at …/.ikigai/store
…
quic udp 0.0.0.0:1070 — 2 trusted certificate(s), 2 enrolled
…
Each bundle goes to its worker (here, a copy into the directory that plays it):
$ mkdir -p worker-1 worker-2
$ cp -R .config/ikigai/gonk/quic/clients/worker-1 worker-1/gonk
$ cp -R .config/ikigai/gonk/quic/clients/worker-2 worker-2/gonk
1. The dispatcher files, with a brief
An unattended worker has nothing to go on but the item, so the item carries the brief: what to do, what done means, and what evidence to bring back. The first line is the title and the rest is the body.
initials panics on an empty word
Done when initials("Ada Lovelace") is "AL" and a test says so.
Evidence: the pull request, with CI green.
Document initials in the README
One example and one sentence on what an empty name returns.
Evidence: the pull request, with CI green.
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:append priority=1 labels=worker' < brief-1.txt
#1 urn:iki:ledger:default:item:…
[uncacheable]
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:append priority=2 labels=worker' < brief-2.txt
#2 urn:iki:ledger:default:item:…
[uncacheable]
(A content-less sink in a -c batch reads its content from standard input.)
2. Workers take work
Each worker asks next and claims the top item on a lease. The claim is the fence between them:
$ ikigai --connect quic://127.0.0.1:1070 --cert-dir worker-1/gonk -c 'source urn:iki:ledger:next'
1. #1 open p1 initials panics on an empty word [worker]
p1 — priority 1; last updated …
2. #2 open p2 Document initials in the README [worker]
p2 — priority 2; last updated …
policy: priority-recency (weighs priority, recency, number)
ready: 2 excluded: 0
[uncacheable]
$ ikigai --connect quic://127.0.0.1:1070 --cert-dir worker-1/gonk -c 'sink urn:iki:ledger:claim item=1 lease=30m worker-1'
#1 claimed by worker-1 until …
[uncacheable]
$ ikigai --connect quic://127.0.0.1:1070 --cert-dir worker-2/gonk -c 'sink urn:iki:ledger:claim item=1 lease=30m worker-2'
error: conflict: #1 is already claimed by worker-1. Release it first (`delete urn:iki:ledger:claim item=1`), or take it up with them — a claim is a fence, and stealing one silently is how two workers end up in the same tree
$ ikigai --connect quic://127.0.0.1:1070 --cert-dir worker-2/gonk -c 'delete urn:iki:ledger:claim item=1'
error: denied: #1 is held by worker-1 (as urn:iki:host:gonk:client:…), and this capability acts as `urn:iki:host:gonk:client:…`: a claim taken as a principal is released by that principal, or by a dispatcher holding `urn:cap:ledger:close:default` or `urn:cap:ledger:write:default` (the override). Naming the holder does not make the claim yours
$ ikigai --connect quic://127.0.0.1:1070 --cert-dir worker-2/gonk -c 'source urn:iki:ledger:next'
1. #2 open p2 Document initials in the README [worker]
p2 — priority 2; last updated …
policy: priority-recency (weighs priority, recency, number)
ready: 1 excluded: 1
not #1: claimed by worker-1
[uncacheable]
$ ikigai --connect quic://127.0.0.1:1070 --cert-dir worker-2/gonk -c 'sink urn:iki:ledger:claim item=2 lease=30m worker-2'
#2 claimed by worker-2 until …
[uncacheable]
Two refusals, and they are different. The claim is a conflict: the item is held, and the answer
says by whom. The release is denied: a claim taken over QUIC records the claimant’s
principal, so worker-2 cannot drop worker-1’s claim by spelling its name. Over the socket in
the previous chapter there was no principal, and a holder was only a name.
3. The work, on the worker
$ git worktree add ../item-1 -b item-1-empty-word
$ cd ../item-1 && your-agent "$(ikigai --connect quic://gonk.local:1062 --cert-dir ~/gonk -c 'source urn:iki:ledger:item:1')"
$ git push -u origin item-1-empty-word && gh pr create --fill
One working tree per item, so two agents never share files; the item’s text is the agent’s brief; the push and the pull request are made as a machine user the forge lets open pull requests and not merge them. While the agent works, the worker renews its lease, which renews and never takes:
$ ikigai --connect quic://127.0.0.1:1070 --cert-dir worker-1/gonk -c 'sink urn:iki:ledger:claim item=1 renew=true lease=30m worker-1'
#1 renewed by worker-1 until …
[uncacheable]
A worker that dies stops renewing. Its lease runs out, next says so, naming it, and the item is
not handed to anyone else until a deliberate takeover (takeover=true from=worker-1).
The starter repository, ikigai-gonk-starter,
packages this loop as tools/worker.py, with the scripts that set up a worker machine’s accounts.
The loop here uses none of it, so that every step stays visible; With the
starter, at the end, shows what it generates.
4. Hand back with evidence
$ ikigai --connect quic://127.0.0.1:1070 --cert-dir worker-1/gonk -c 'sink urn:iki:ledger:comment item=1 handback=true https://github.com/you/demo/pull/8 adds the test, CI green' -c 'delete urn:iki:ledger:claim item=1 from=worker-1'
commented on #1 (urn:iki:ledger:default:comment:…) awaiting-dispatcher
[uncacheable]
#1 released
[uncacheable]
— batch: 2 commands · 2 uncacheable
$ ikigai --connect quic://127.0.0.1:1070 --cert-dir worker-1/gonk -c 'sink urn:iki:ledger:close item=1 reason=done'
error: denied: this capability holds neither `urn:cap:ledger:close:default` nor `urn:cap:ledger:write:default`, and this resource takes either. A ledger grant names exactly one ledger, and a grant for another verb (a worker's claim and comment, say) does not reach this one
$ ikigai --connect quic://127.0.0.1:1070 --cert-dir worker-2/gonk -c 'sink urn:iki:ledger:comment item=2 handback=true https://github.com/you/demo/pull/9 README example, CI green' -c 'delete urn:iki:ledger:claim item=2 from=worker-2'
commented on #2 (urn:iki:ledger:default:comment:…) awaiting-dispatcher
[uncacheable]
#2 released
[uncacheable]
— batch: 2 commands · 2 uncacheable
The hand-back rides on the one write a worker has, a comment, and makes the item wait for its dispatcher; the release lets go of the claim. The worker’s attempt to close its own work is refused at the grant. (The pull request URLs are the shape step 3 would print; this page has no forge.)
5. The dispatcher checks, and closes or sends it back
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:iki:ledger:next'
nothing is ready (2 open item(s) excluded: #2 handed back, awaiting the dispatcher; #1 handed back, awaiting the dispatcher)
policy: priority-recency
[uncacheable]
Nothing is offered: both items are the dispatcher’s. One pull request holds, and is closed; the other is red on the main branch after it merged, so the dispatcher rejects the hand-back, with the reason, and the item is ready again for whichever worker asks next:
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:close item=1 reason=done author=ada Checked: PR 8 merged, CI green, the test is in the diff.'
closed #1 (done)
[uncacheable]
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:comment item=2 rejects=true author=ada PR 9 is red on main: the README example does not compile.'
commented on #2 (urn:iki:ledger:default:comment:…) cleared-awaiting
[uncacheable]
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:iki:ledger:next'
1. #2 open p2 Document initials in the README [worker]
p2 — priority 2; last updated …
policy: priority-recency (weighs priority, recency, number)
ready: 1 excluded: 0
[uncacheable]
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:iki:ledger:item:2'
#2 open p2 Document initials in the README [worker]
iri: urn:iki:ledger:default:item:…
filed: …
updated: …
One example and one sentence on what an empty name returns.
Evidence: the pull request, with CI green.
2 comment(s):
… urn:iki:host:gonk:client:…
https://github.com/you/demo/pull/9 README example, CI green
… ada
PR 9 is red on main: the README example does not compile.
[uncacheable]
The worker’s comment is attributed to its certificate’s principal, filled in by the QUIC door;
the dispatcher’s author=ada is the owner’s own word, because the socket proves nothing more.
6. What the server saw
$ grep -E 'door=quic .*outcome=(conflict|denied)' gonk.log
… gonk:Access urn:iki:ledger:claim door=quic verb=sink outcome=conflict bytes=- dur=… principal=urn:iki:host:gonk:client:… q=-
… gonk:Access urn:iki:ledger:claim door=quic verb=delete outcome=denied bytes=- dur=… principal=urn:iki:host:gonk:client:… q=-
… gonk:Access urn:iki:ledger:close door=quic verb=sink outcome=denied bytes=- dur=… principal=urn:iki:host:gonk:client:… q=-
Every refusal above, each with the principal that asked.
What holds it
The guides: the brief in each item, which is all an unattended agent knows about what to do; the rules the agent works to (claim before writing, one working tree per item, reproduce before fixing, report with evidence), in the repository it works in; and the forge’s branch rules.
The sensors, and here they are enforced rather than habits:
- The grant. A worker cannot close, relabel, file or delete, and the door refuses it before the ledger is asked.
- Claims bound to a principal. One worker cannot release or renew another’s claim; a takeover works only on an expired lease, and only as a compare-and-set against the holder it names.
- The lease, which turns a dead worker into a visible
lease-expiredrather than a silently stuck item. - The hand-back state, which keeps finished work out of
nextuntil a dispatcher answers. - CI and the forge’s merge rule: the worker’s machine user can open a pull request and not merge it.
- The dispatcher’s evidence check, now a separate party from the worker, and the reject that sends work back with a reason.
- The access log, which names every worker on every request it made.
What it costs, and when to choose it
Each worker is a certificate to mint, a bundle to move and rotate, a machine to keep, and an agent’s model calls to pay for; and the dispatcher’s evidence check becomes the bottleneck, because every item waits for it. A worker that misreads its brief spends a lease’s worth of time before anyone notices.
Choose it when there is more work than one machine’s attention, when the work is well enough specified to be done from a brief, and when you want the machine that writes code to be unable to accept its own work. If the work is not yet specified that well, a coordinating session in front of the workers is Hub and spoke, two chapters on.
With the starter
The gonk starter’s workers profile is this
chapter packaged: tools/new.py --process workers adds a “Worker machines” section to the
repository’s AGENTS.md, carries tools/worker.py (the claim, work, report loop above, run
unattended) with its configuration’s template and its service units, and the just worker
recipes that run it. The book checks the starter at the revision in books/gonk/starter.rev
(Solo says more):
$ git clone --quiet https://github.com/ikigai-rs/ikigai-gonk-starter starter
$ git -C starter checkout --quiet c7a501a29e93b1160f67007b080df9f2dbbe48d3
$ cd starter && python3 tools/new.py ../library --process workers
generated the library-lending sample (python, process workers) in …/library: 63 files
next, in that directory:
git init, then commit everything it wrote (the one commit where every file is yours)
python3 scripts/test.py every test, as CI runs them
python3 tools/gonk.py init --ledger <name> --socket ~/.ikigai/gonk.sock --holder <you>
python3 tools/gonk.py seed file the open changes' tasks as items
python3 tools/gonk.py next what to do first, and why
AGENTS.md holds this process's rules; README.md walks through the sample.
$ grep '^## ' library/AGENTS.md
## The loop
## 1. Claim the item before you write
## 2. One agent per claimed item, one agent per working tree
## 3. Reproduce before you fix
## 4. Every change through a pull request with green CI
## 5. Report with evidence; the dispatcher closes
## 6. Never a bare `#N`
## Specs, artifacts, queries and scripts
## Worker machines
## Optional: a hook for Claude Code
$ ls library/tools library/worker
library/tools:
gonk.py
harness.py
worker.py
library/worker:
units
worker.sample.toml
$ cat library/.gonk/process.toml
# How this repository's team works: the process profiles it was generated with, and what
# each one asks of it. Committed. Written by ikigai-gonk-starter's tools/new.py from its
# profiles/; `python3 tools/gonk.py init` and `status` report it and read nothing else to
# decide it. AGENTS.md holds the rules of exactly these profiles, so change both together.
version = 1
process = ["workers"]
hooks = ["claude:require-claim"]
checks = ["worker-config"]
[profile.workers]
summary = "a dispatcher and unattended worker machines: workers claim and report, and never close or merge"
roles = ["dispatcher: marks items for workers, checks each hand-back, merges and closes, or rejects", "worker: an unattended machine running tools/worker.py; claims, works, reports"]
On a worker machine, init takes the QUIC door and the bundle client add wrote, here
worker-1’s from step 0, and asks gonk as that worker:
$ cd library && git init -q && python3 tools/gonk.py init --ledger default --quic quic://127.0.0.1:1070 --cert-dir ../worker-1/gonk --holder worker-1
gonk init: …/library
ok layout openspec at openspec/; artifacts at artifacts/; queries at .gonk/queries/; scripts at .gonk/scripts/
note harness 4 harness question(s) in .gonk/queries/ are asked by `python3 tools/gonk.py harness` through gonk's store, not published: gonk runs a script under a ceiling of ledger reads, and they read the harness graph
note openspec 8 open task(s) in 2 change(s) in flight: `python3 tools/gonk.py seed` files them as ledger items (again is a no-op)
ok process workers (.gonk/process.toml)
note workers a dispatcher and unattended worker machines: workers claim and report, and never close or merge
note role dispatcher: marks items for workers, checks each hand-back, merges and closes, or rejects
note role worker: an unattended machine running tools/worker.py; claims, works, reports
note hook the Claude Code claim hook is optional and off: merge hooks/claude/settings.sample.json into .claude/settings.json to turn it on
note worker no .gonk/worker.toml, so this machine runs no worker: a worker machine copies worker/worker.sample.toml there (`python3 tools/worker.py plan` checks it)
ok ikigai ikigai 0.1.61 at …/ikigai
ok ledger `default` (.gonk/gonk.toml)
ok holder `worker-1` (.gonk/local.toml, this working tree only)
ok identity urn:iki:host:gonk:client:… (from …/worker-1/gonk/client.crt)
ok alias older records name it urn:iki:gonk:client:…: gonk reads that spelling as this machine too, and never mints it again
ok gonk quic quic://127.0.0.1:1070 (certificates in …/worker-1/gonk) answers
ok items `default` holds 2 item(s), 1 open
ok scripts gonk offers `urn:script:catalog`: saved queries and workflow scripts (ikigai-script)
failed scripts urn:script:default-open-items-for-artifact was refused: error: denied: capability does not grant `urn:cap:script:write:*` (declared by `urn:script:default-open-items-for-artifact`; any grant under `urn:cap:script:write:` satisfies it)
failed scripts urn:script:default-open-work-by-change was refused: error: denied: capability does not grant `urn:cap:script:write:*` (declared by `urn:script:default-open-work-by-change`; any grant under `urn:cap:script:write:` satisfies it)
failed scripts urn:script:default-requirements-with-open-work was refused: error: denied: capability does not grant `urn:cap:script:write:*` (declared by `urn:script:default-requirements-with-open-work`; any grant under `urn:cap:script:write:` satisfies it)
failed scripts urn:script:default-next-for-artifact was refused: error: denied: capability does not grant `urn:cap:script:write:*` (declared by `urn:script:default-next-for-artifact`; any grant under `urn:cap:script:write:` satisfies it)
unknown nl could not ask gonk about `urn:nl:sparql`
this machine's QUIC identity, for the gonk operator to enroll (once):
…
agents over MCP (configure the agent with this command):
ikigai mcp --override urn:iki:ledger:=quic://127.0.0.1:1070 --cert-dir …/worker-1/gonk
ready.
$ cd library && python3 tools/gonk.py status
process: workers
no live claim: this working tree holds no claim. Claim the item first: `python3 tools/gonk.py claim <number>` (or `just claim <number>`)
The identity row is the name gonk’s access log gives this machine, and the lines elided after it
are the client add and grants.json an operator would use to enroll it, which step 0 already
did. The failed rows are the worker grant at work: init offers to publish the sample’s saved
queries, and a worker may not write scripts (the dispatcher’s own init, as the owner over the
socket, publishes them, as Solo shows). The unknown row is this
page’s gonk mounting no model: it binds no urn:nl:sparql (its banner says so), and the starter
reads that refusal as a gonk it could not ask. The worker row is what a worker machine still
needs, its .gonk/worker.toml; setting that machine up (its accounts, the boundary its agent runs
inside) is the starter’s worker/README.md.
Profiles combine (--process workers,spec adds spec first’s rules, and no
--process at all means solo,workers,spec); only unled audits and review every
commit refuse each other.
Review every commit
A read-only reviewer reads every file every commit touches. Each claim it makes is a pending
finding; a second model, the judge, checks the serious ones; a person declines or publishes
each finding with a reason or a reproduction; and a real one becomes a ledger item that goes
through the ordinary loop. gonk does all of that from a post-commit hook and one setting.
This is the process gonk’s review machinery was first built for, and the one its own authors turned off: The audit protocol has the measurement. This chapter shows how it works end to end, so you can decide for yourself; the last section says what it costs and when it is still the right choice. The pieces are the ones One-off review taught one at a time, and this page links there rather than teaching them again.
Who
| role | who | holds |
|---|---|---|
| the author | whoever commits: a person, or an agent | nothing of the review’s; their hook drops a request, which spends nothing and needs no grant |
| the reviewer | a model, run by gonk on each request, unattended | read every browse root, the browse graph, the model’s host; never urn:cap:annotate |
| the judge | the same mount, asked once more for each serious finding | the reviewer’s grant: it runs inside the pass |
| the triager | a person, at gonk’s Queue page or over the socket | urn:cap:annotate, the one token that publishes |
| whoever fixes it | the ordinary loop on a ledger item | the ledger’s writer |
What runs where
One machine. gonk holds the store, the queue and the reviewer; the model is a peer gonk mounts at
urn:llm:, here a stub on a socket, in practice a model host on the same machine or on a GPU box
over QUIC.
git commit ──post-commit hook──▶ ikigai-gonk review request <repo> <path>
one file into the queue; no store, no door, no network
│
gonk ─── urn:space:reviews ──▶ armed reactor: one pass at a time, under grant `reviewer`
│ urn:llm:coder:ask (the mount)
▼
pending findings ◀── the judge, once per serious finding
│
a person ─ decline with a reason │ publish with a reproduction ─▶ annotation
│
a ledger item, keyed by the finding
The loop, step by step
1. A model, a repository, and the queue
The model is this book’s stub, the one Scripts and sentences in gonk mounts. It also answers what a browse root asks a model: a fixed review of the file below, a judge’s check, and an explanation, told apart by the system prompt each ask carries. It logs a line for every ask, which is how this page shows what a commit cost. In a new empty directory, as in every chapter: if the gonk from Remote workers is still running, stop it first, since this one takes the same port.
$ model-stub "$PWD/model.sock" | tee model.log
model-stub: urn:llm:ask and urn:llm:config on …/model.sock (model stub)
A repository with one file, the one One-off review reviewed:
$ git init -q -b main demo
/// A name's initials: "Ada Lovelace" is "AL".
pub fn initials(name: &str) -> String {
name.split(' ').map(|word| &word[..1]).collect()
}
Arming the reviewer takes two facts, and neither is implicit: a grant written into
grants.json, and the word that says to use it. The first line binds the queue at
urn:space:reviews; on its own it arms nothing.
gonk.review.space = "reviews"
gonk.review.grant = "reviewer"
gonk.review.arm = true
The grant is exactly this, and gonk checks it against the review’s own contract at startup:
{
"reviewer": [
"urn:cap:browse:read:*",
"urn:cap:net:localhost",
"urn:cap:store:read:graph:urn:iki:browse:graph:default",
"urn:cap:store:write:graph:urn:iki:browse:graph:default",
"urn:cap:exec:gh"
]
}
Read every root; spend the model, whose host is localhost for a socket (for a QUIC peer it is
that peer’s host, exactly); read and write browse’s own graph, where the archive and the findings
live; and run gh, which a review of a pull request uses. What is missing is the point:
urn:cap:annotate. A pass writes pending findings and nothing else, and publishing a finding
is the only way into the annotations everyone reads, so a reviewer without that token cannot
publish. gonk refuses to start rather than arm a reviewer whose grant holds it.
2. The hook
#!/bin/sh
for f in $(git diff-tree --root --no-commit-id --name-only -r HEAD); do
ikigai-gonk review request demo "$f" --config-home ../.config/ikigai --data-home ../.ikigai
done
review request opens no store, binds no door and dials nothing: it writes one request into the
queue’s directory and exits, so a commit never waits on gonk, let alone on a model, and the hook
works while gonk is down. The two flags are this page’s scratch homes (git runs a hook in the
repository’s top directory, so .. is the page’s); with gonk’s default homes, leave them off. A
request is just (repository, path), with no commit in it, so the same file committed three times
while the queue waits is one request, and the pass reviews the file as it is when the pass runs.
$ chmod +x demo/.git/hooks/post-commit
$ printf 'gonk.browse.root = "demo=%s/demo"\ngonk.mount = "prefer urn:llm:=%s/model.sock"\n' "$PWD" "$PWD" >> .config/ikigai/config.toml
The root and the mount are written with printf and $PWD because a ~ in config.toml means
the home of whoever runs gonk (Browse roots).
3. gonk, armed
$ ikigai-gonk --config-home "$PWD/.config/ikigai" --data-home "$PWD/.ikigai" --port 1070 --no-quic --no-backup 2>&1 | tee gonk.log
ikigai-gonk 0.1.0 — holding the store at …/.ikigai/store
…
review urn:space:reviews — ARMED: a drop fires urn:iki:gonk:review:pass under grant `reviewer` (5 scopes, net localhost, no urn:cap:annotate — a pass CANNOT publish). 0 waiting now. …
…
The review line says what an operator needs the moment this is on: what runs, under which
grant, that it cannot publish, and (in the part the page elides) that passes run one at a time
and nothing bounds the wall clock.
4. Commit
$ git -C demo add src/lib.rs
$ git -C demo -c user.name=Ada -c user.email=ada@example.com commit -q -m 'initials'
5d0b19b5150eb39a67cffadd66d6191e3180dbd627c3aa2b5b8a61e39832165f
The one line is the hook’s: the request’s id, a hash of its content, which is why a repeat collapses into it. The commit is done; the review happens in gonk, in its own time. Wait for it the way an operator does, on the queue’s depth, then see what it cost:
$ until ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:iki:gonk:review:depth' 2>/dev/null | grep -q '1 handled'; do sleep 0.2; done
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:iki:gonk:review:depth'
The review queue is empty: no request is waiting to be reviewed, 1 handled. 1 pass(es) this run, 0 failed; the last ended …s ago. 2 findings minted this run, 1 serious (50%).
[uncacheable]
$ cat model.log
model-stub: urn:llm:ask and urn:llm:config on …/model.sock (model stub)
model-stub: ask 1: review src/lib.rs
model-stub: ask 2: judge one finding
Two asks for one file: the review, and the judge on the one finding the review called serious. The minor one is minted and counted, and nobody is asked about it unless they look. The depth’s last figure, the serious share, is a sensor in its own right: severity is the model’s own word, so a share that jumps with no change of model or prompt is the label inflating, not the code getting worse.
5. Triage
The findings, as anyone holding a browse read sees them:
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:repo:demo:findings:src/lib.rs as=text/plain'
--- findings (2) ---
Findings wait for a person. Nothing here blocks a commit, a push or a merge, and nothing reaches the annotation family until someone publishes it.
src/lib.rs L3 [pending] major "name.split(' ').map(|word| &word[..1]).collect()" -- Slicing the first byte panics on an empty word, so a name with two spaces in a row, or an empty name, crashes the caller.
src/lib.rs L2 [pending] minor "pub fn initials(name: &str) -> String {" -- Returning a String allocates on every call; return a &str instead.
[uncacheable]
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:repo:demo:findings:src/lib.rs' 2>/dev/null | jq -r '.[] | "\(.id) \(.severity) judge: \(.judge.verdict // "none")"'
645d521aab16830f076e0857 major judge: confirmed
1fa92632a54c273633bc10fe minor judge: none
The second line of the listing is the rule this whole process rests on: a finding is a question, not a gate. The judge’s verdict orders the Queue (confirmed first, refuted folded last) and decides nothing. Its id is a hash of the pass and the quote, so the same review of the same bytes names the same findings on any machine.
A person decides. In gonk that is the Queue page, which asks only about the serious findings and draws the judge’s order; it needs a signed-in person, so this page cannot drive it:
$ open http://localhost:1070/queue
Over the socket the owner holds urn:cap:annotate too, so the same two decisions are Sinks to
the findings. The minor one is declined with a reason from the closed set; the major one is
published, marked reproduced, with the reproduction as its note. The reproduction is the
three-line test in One-off review, and making it is the one step
of this loop no machine does for you.
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:finding:1fa92632a54c273633bc10fe decision=decline reason=no-issue A borrowed str cannot hold letters taken from several words.'
urn:iki:finding:1fa92632a54c273633bc10fe
[uncacheable]
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:finding:645d521aab16830f076e0857 decision=publish reproduced=yes initials("Ada Lovelace") panics: the empty middle word has no byte 0.'
urn:iki:annotation:645d521aab16830f076e0857
[uncacheable]
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:repo:demo:findings:src/lib.rs as=text/plain'
--- findings (0) ---
Findings wait for a person. Nothing here blocks a commit, a push or a merge, and nothing reaches the annotation family until someone publishes it.
1 declined finding on this file, on 1 distinct quote (no-issue 1)
[uncacheable]
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:repo:demo:annotations:src/lib.rs' 2>/dev/null | jq -r '.[] | "\(.iri) derived from \(.derived_from)"'
urn:iki:annotation:645d521aab16830f076e0857 derived from urn:iki:finding:645d521aab16830f076e0857
Nothing is pending on the file now. The declined finding is summarized by its reason, which is what marks the same claim when a later pass makes it again; the published one is an annotation that everyone who reads the file sees, one hop from the model’s claim it came from.
6. File it
A published finding is a fact about the code, and fixing it is work, so it goes on the ledger.
ikigai-gonk findings file <root> files a root’s published findings as ledger items, never
a pending one: the Queue decides, and the ledger holds what was decided. It speaks to the running
gonk over its owner-only socket, because reading findings takes the browse read no anonymous
caller holds. --dry-run reads for real and files nothing:
$ ikigai-gonk findings file demo --socket "$PWD/.ikigai/gonk.sock" --dry-run
would file 645d521aab16830f076e0857 Slicing the first byte panics on an empty word, so a name with two spaces in a row, or an empty…
key=urn:iki:finding:645d521aab16830f076e0857
labels=finding,repo:demo,major,reproduced
about=urn:repo:demo:file:src/lib.rs urn:iki:finding:645d521aab16830f076e0857 urn:iki:annotation:645d521aab16830f076e0857
dry run, nothing filed: 1 published finding(s) on demo: 1 would be filed, 0 already filed, 0 skipped
$ ikigai-gonk findings file demo --socket "$PWD/.ikigai/gonk.sock"
filed #1 Slicing the first byte panics on an empty word, so a name with two spaces in a row, or an empty…
1 published finding(s) on demo: 1 filed, 0 already filed, 0 skipped
$ ikigai-gonk findings file demo --socket "$PWD/.ikigai/gonk.sock"
already #1 (open) urn:iki:finding:645d521aab16830f076e0857
1 published finding(s) on demo: 0 filed, 1 already filed, 0 skipped
Each finding is one keyed append, key=urn:iki:finding:{id}: the ledger checks the key and
files in the same store update, so the second run, a timer that runs it after every triage
session, or a second person running it at the same moment, files nothing new. The declined
finding is not listed at all; it was never published. By default only the serious words
(gonk.queue.serious, critical and major unless the config says otherwise) are filed, judged
on the person’s word when they re-rated it; --severity all files every published finding,
and --reproduced only those a person marked reproduced. No priority is set: the severity is a
label, and ranking is the ledger’s next policy’s call.
The item carries the finding whole:
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:iki:ledger:item:1'
#1 open p- Slicing the first byte panics on an empty word, so a name with two spaces in a row, or an empty… [finding major repo:demo reproduced]
iri: urn:iki:ledger:default:item:…
key: urn:iki:finding:645d521aab16830f076e0857
filed: …
updated: …
about: urn:iki:annotation:645d521aab16830f076e0857
about: urn:iki:finding:645d521aab16830f076e0857
about: urn:repo:demo:file:src/lib.rs
Slicing the first byte panics on an empty word, so a name with two spaces in a row, or an empty name, crashes the caller.
- finding: urn:iki:finding:645d521aab16830f076e0857
- file: src/lib.rs line 3
- quote: "name.split(' ').map(|word| &word[..1]).collect()"
- severity: major
- reviewer: stub
- published: …, marked reproduced
- note: initials("Ada Lovelace") panics: the empty middle word has no byte 0.
- annotation: urn:iki:annotation:645d521aab16830f076e0857
- judge: confirmed
- key: urn:iki:finding:645d521aab16830f076e0857
[uncacheable]
about joins the item to the finding, the annotation its publication minted and the file, in one
graph: “what is open against this file” is a query, not a search, and browse’s page for the file
lists the item. From here the item is ordinary work: claimed, fixed on a branch with the
reproduction as its regression test, and closed with the pull request as evidence, as in Solo on
one machine.
⚠ gonk files nothing from its own review by itself. Publishing and filing are two acts on
purpose: the Queue’s publish runs under the deciding person’s grant, which holds
urn:cap:annotate and need not hold any ledger’s write token, and one click with effects in two
graphs would leave no way to publish without filing. Something has to run findings file: a
person after triage, as here, or a timer. ⚠ And file with the command only: an item filed by
hand with some other key (key=finding:<id> was this chapter’s own hand step before gonk had the
command) is not the command’s key, so the command files the finding again beside it.
The same process with another reviewer
Nothing above depends on gonk being the reviewer. A dedicated review tool that runs on every commit, such as roborev, implements the same process with its own reviewer, its own queue and its own report; what gonk adds is the ledger the findings land in. Filing roborev findings is that bridge: the tool’s completion hook files each finding as a ledger item, keyed so a re-run files nothing new, and from there triage, the claim and the close are the steps above. A team that already keeps its work in another tracker, such as kata, brings that ledger across once with Importing a kata ledger. Either way the process is the one this chapter walks: a reviewer that cannot publish, a person who decides with a reason or a reproduction, and a ledger item for what is real.
What holds it
The guides, the rules written down before anything runs:
- Nothing is published except by a person. Not a policy but a grant: the reviewer’s lacks the token, and gonk will not arm one that has it.
- Decline with a reason from a closed set (
misread,restates,no-issue,wont-fix,duplicate), because “why not” is what the next review can learn from. - Publish with a reproduction:
reproduced=yesrecords that a person made the defect happen. - The prompt is not yours to steer. The review and judge prompts are
ikigai-browse’s, and nothing gonk renders reaches a pass, so no banner or form can tell a model which severities get read.
The sensors, which say when it is going wrong:
- The judge, a second reading with the context the reviewer lacked, on every serious finding.
- The depth,
urn:iki:gonk:review:depth, and the Queue badge that polls it: a queue that is armed and not empty with nothing in flight is stuck, and says so. - The serious share of what a run minted, for label inflation.
- The keyed append, so a re-run or a second person files nothing twice.
And one thing that is deliberately not a sensor: the queue blocks nothing. A finding never fails a commit, a push or CI. If you want a gate, it is a test, and the reproduction is where it comes from.
What it costs, and when to choose it
Every changed file is a model call, plus one per serious finding for the judge. Passes run one at
a time, so a commit touching forty files occupies the reviewer for forty passes. And every serious
finding costs a person’s attention, which is the expensive part: on the authors’ own
repositories the queue passed a thousand pending findings, and measured against unled audits a
per-commit reviewer found less than half as many serious bugs per token, because a diff never shows
it a defect older than the diff (The audit protocol has the numbers and
their limits). So on the machine this book was written on the hook is installed everywhere and
gonk.review.arm is unset: requests are dropped and nothing drains them.
Choose it anyway when the arithmetic is different:
- A small team, where few commits land and someone reads every change regardless; the findings are a second reader, not a queue.
- A fast, cheap model, local and idle, where a pass costs nothing worth counting and the queue drains in seconds.
- A codebase new to the team, where a second reading of each change teaches what the code does and precision matters less than attention.
Turning it off is one line: remove gonk.review.arm = true. The hook keeps dropping requests and
nothing drains them; a person can still run one by hand over the socket (the review line of an
unarmed banner prints how), and One-off review is always there for a file
someone already suspects.
With the starter
The gonk starter’s review-every-commit
profile packages step 2: tools/new.py --process review-every-commit adds a “Review on every
commit” section to AGENTS.md and a committed hooks/git/post-commit, whose
review-on-commit.py runs the same ikigai-gonk review request <root> <path> for each file a
commit touched, detached, so a commit never waits and never fails because of it. Its settings (the
root’s name, gonk’s config file, paths never queued) are a [review] table in
.gonk/process.toml. The book checks the starter at the revision in books/gonk/starter.rev
(Solo says more):
$ git clone --quiet https://github.com/ikigai-rs/ikigai-gonk-starter starter
$ git -C starter checkout --quiet c7a501a29e93b1160f67007b080df9f2dbbe48d3
$ cd starter && python3 tools/new.py ../library --process review-every-commit
generated the library-lending sample (python, process review-every-commit) in …/library: 61 files
next, in that directory:
git init, then commit everything it wrote (the one commit where every file is yours)
python3 scripts/test.py every test, as CI runs them
python3 tools/gonk.py init --ledger <name> --socket ~/.ikigai/gonk.sock --holder <you>
git config core.hooksPath hooks/git the post-commit review hook
python3 tools/gonk.py seed file the open changes' tasks as items
python3 tools/gonk.py next what to do first, and why
AGENTS.md holds this process's rules; README.md walks through the sample.
$ grep '^## ' library/AGENTS.md
## The loop
## 1. Claim the item before you write
## 2. One agent per claimed item, one agent per working tree
## 3. Reproduce before you fix
## 4. Every change through a pull request with green CI
## 5. Report with evidence; the dispatcher closes
## 6. Never a bare `#N`
## Specs, artifacts, queries and scripts
## Review on every commit
## Optional: a hook for Claude Code
$ ls library/hooks/git
post-commit
review-on-commit.py
$ cat library/.gonk/process.toml
# How this repository's team works: the process profiles it was generated with, and what
# each one asks of it. Committed. Written by ikigai-gonk-starter's tools/new.py from its
# profiles/; `python3 tools/gonk.py init` and `status` report it and read nothing else to
# decide it. AGENTS.md holds the rules of exactly these profiles, so change both together.
version = 1
process = ["review-every-commit"]
hooks = ["claude:require-claim", "git:post-commit"]
checks = ["review"]
[profile.review-every-commit]
summary = "a read-only reviewer on every commit: a post-commit hook asks gonk to review the touched files, and a person triages the findings"
roles = ["reviewer: gonk's review pass, read-only; it cannot publish a finding", "triager: a person who decides each finding and files the ones to act on"]
# The review-every-commit profile: what hooks/git/post-commit asks gonk for. Committed, since
# every clone commits to the same repository on the same gonk.
[review]
# The name gonk serves this repository under, its `gonk.browse.root = "<name>=<path>"` line.
# Unset, it is this working tree's directory name.
# root = "my-repo"
# gonk's config.toml, when it is not in gonk's default config home: `ikigai-gonk review
# request` reads the queue's location from it.
# config = "~/.config/ikigai/config.toml"
# Paths never queued: machine-written files a review could only restate. Shell-style
# patterns matched against the whole path as git spells it, where `*` also matches `/`.
skip = ["*.lock", "package-lock.json", "*/package-lock.json"]
The hook is installed by pointing git at hooks/git (just hooks does the same), and init, the
profile’s review check, then asks this page’s gonk whether it can review this repository:
$ cd library && git init -q && git config core.hooksPath hooks/git && python3 tools/gonk.py init --ledger default --socket ../.ikigai/gonk.sock --holder triager
gonk init: …/library
ok layout openspec at openspec/; artifacts at artifacts/; queries at .gonk/queries/; scripts at .gonk/scripts/
note harness 4 harness question(s) in .gonk/queries/ are asked by `python3 tools/gonk.py harness` through gonk's store, not published: gonk runs a script under a ceiling of ledger reads, and they read the harness graph
note openspec 8 open task(s) in 2 change(s) in flight: `python3 tools/gonk.py seed` files them as ledger items (again is a no-op)
ok process review-every-commit (.gonk/process.toml)
note review-every-commit a read-only reviewer on every commit: a post-commit hook asks gonk to review the touched files, and a person triages the findings
note role reviewer: gonk's review pass, read-only; it cannot publish a finding
note role triager: a person who decides each finding and files the ones to act on
note hook the Claude Code claim hook is optional and off: merge hooks/claude/settings.sample.json into .claude/settings.json to turn it on
ok hook post-commit asks gonk to review each commit (core.hooksPath)
ok ikigai ikigai 0.1.61 at …/ikigai
ok ledger `default` (.gonk/gonk.toml)
ok holder `triager` (.gonk/local.toml, this working tree only)
ok identity the socket's owner: every request over it is `owner`, so the log cannot tell this tree from any other process of yours. A machine that must be named uses QUIC
ok gonk socket …/.ikigai/gonk.sock answers
ok items `default` holds 1 item(s), 1 open
ok scripts gonk offers `urn:script:catalog`: saved queries and workflow scripts (ikigai-script)
ok scripts published 4: urn:script:default-open-items-for-artifact, urn:script:default-open-work-by-change, urn:script:default-requirements-with-open-work, urn:script:default-next-for-artifact
ok nl gonk offers `urn:nl:sparql`: drafting a query from a sentence (ikigai-nl)
missing review gonk serves no browse root named `library`: its operator adds `gonk.browse.root = "library=<path to this repository on gonk's machine>"`, or name gonk's root for it in .gonk/process.toml's `[review] root`
agents over MCP (configure the agent with this command):
ikigai mcp --override urn:iki:ledger:=…/.ikigai/gonk.sock
1 thing(s) missing.
$ cd library && python3 tools/gonk.py status
process: review-every-commit
no live claim: this working tree holds no claim. Claim the item first: `python3 tools/gonk.py claim <number>` (or `just claim <number>`)
The one thing missing is true: this page’s gonk serves demo, not the repository just generated.
With its root added to gonk’s config.toml (step 2’s printf) and gonk restarted, init goes on
to ask whether gonk binds a review pass (a model is mounted) and whether its queue is armed, the
two things step 3’s banner shows. A tree that reaches gonk over QUIC is told instead that its
commits queue nothing: the queue is a directory on gonk’s machine, which no network door reaches.
Profiles combine (--process solo,review-every-commit is Solo’s roles with this
review), with one exception: this profile and unled audits answer the same question,
how bugs are found, and tools/new.py refuses the pair.
Hub and spoke
One coordinating session, the hub, holds a goal that spans several repositories. It splits the goal into arcs, one repository each, writes a brief for every arc, and dispatches each arc to its own agent, a spoke, working in its own tree. A spoke does its arc, merges its own pull request once CI is green, and reports with the evidence. The hub checks the evidence and closes the arc. The hub’s own changes are the exception: it asks a person before merging them.
Remote workers handed well-specified items to machines. Hub and spoke is what sits in front of that when the work is not yet items: the hub’s job is the splitting, the ordering and the checking, and a spoke may be a remote worker, an agent on the hub’s own machine, or a person.
This chapter shows the pattern with gonk’s primitives only: items, parent and blocks links,
claims with leases as the fence, and next ordering the arcs. The authors run this pattern with
private tooling of their own around it; none of that is needed, or shown, here.
Who
| role | who | does |
|---|---|---|
| the person | whoever owns the goal | sets it; answers the hub when it asks before merging its own work |
| the hub | one coordinating session (an agent, or a person) | splits, briefs, dispatches, checks evidence, closes |
| a spoke | one agent per arc, in its own working tree | claims one arc, does it, self-merges on green, hands back |
| CI | the forge | the gate a spoke’s self-merge waits on |
A spoke may merge its own work because that authority was delegated through the brief: the arc’s item says so. A session that started some other way (by hand, from a suggestion) holds no such brief, and stops at a green pull request and reports.
What runs where
┌──────────────── gonk (the ledger) ────────────────┐
│ #1 goal (claimed by the hub) │
│ ├─ #2 arc: lib ──blocks──▶ #3 arc: docs │
│ └─ #3 arc: docs │
└───────────▲──────────────▲──────────────▲─────────┘
│ │ │
hub (files, links, checks, closes) spoke-lib spoke-docs
worktree of lib worktree of docs
PR ─▶ CI ─▶ merge PR ─▶ CI ─▶ merge
This page runs one gonk and plays the hub and both spokes over its socket. Spokes on other machines reach it over QUIC with identities of their own, exactly as in Remote workers.
The loop, step by step
In a new empty directory, as in every chapter: if the gonk
and the model-stub from Review every commit are still running, stop them
first, since this gonk takes the same port.
$ ikigai-gonk --config-home "$PWD/.config/ikigai" --data-home "$PWD/.ikigai" --port 1070 --no-quic --no-backup 2>&1 | tee gonk.log
ikigai-gonk 0.1.0 — holding the store at …/.ikigai/store
…
1. The hub files the goal, and splits it into arcs
An arc is one repository’s worth of the goal, small enough for one agent to finish and one pull request to hold. Its item is the spoke’s whole brief: what to do, in which repository, what done means, what evidence to bring back, and whether the spoke may merge its own work.
initials handles every name
Two arcs: the library first, then its documentation.
lib: initials skips empty words
Repository: lib. Done when initials("Ada Lovelace") is "AL" and a test says so.
Self-merge on green CI; hand back with the pull request.
docs: document initials
Repository: docs. One example, and one sentence on what an empty name returns.
Self-merge on green CI; hand back with the pull request.
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:append priority=1 labels=goal' < goal.txt
#1 urn:iki:ledger:default:item:…
[uncacheable]
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:append priority=1 labels=arc' < arc-lib.txt
#2 urn:iki:ledger:default:item:…
[uncacheable]
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:append priority=1 labels=arc' < arc-docs.txt
#3 urn:iki:ledger:default:item:…
[uncacheable]
Then the shape, as links: both arcs are children of the goal, and the library arc blocks the documentation arc, because the docs describe what the library does once it is fixed. The hub claims the goal itself, with no lease: it holds it until the goal is done.
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:link item=2 type=parent 1' -c 'sink urn:gk:iki:ledger:link item=3 type=parent 1' -c 'sink urn:gk:iki:ledger:link item=2 type=blocks 3'
linked #2 parent #1
[uncacheable]
linked #3 parent #1
[uncacheable]
linked #2 blocks #3
[uncacheable]
— batch: 3 commands · 3 uncacheable
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:claim item=1 hub'
#1 claimed by hub
[uncacheable]
2. next orders the arcs
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:iki:ledger:next'
1. #2 open p1 lib: initials skips empty words [arc]
p1 — priority 1; last updated …
policy: priority-recency (weighs priority, recency, number)
ready: 1 excluded: 2
not #1: claimed by hub
not #3: blocked by #2
[uncacheable]
One arc is ready. The goal is the hub’s, and the docs arc waits on the library arc, with the reason
in words. The hub does not have to remember the order: it is in the links, and any spoke that asks
next gets the same answer.
3. Dispatch: one spoke, one arc, one tree
The hub starts a spoke on the ready arc, in a working tree of its own (git worktree add), so that
two agents never share files. The spoke’s first act is the claim, on a lease long enough for the
arc; the claim is the fence that keeps a second spoke off the same arc:
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:claim item=2 lease=90m spoke-lib'
#2 claimed by spoke-lib until …
[uncacheable]
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:claim item=2 lease=90m spoke-docs'
error: conflict: #2 is already claimed by spoke-lib. Release it first (`delete urn:iki:ledger:claim item=2`), or take it up with them — a claim is a fence, and stealing one silently is how two workers end up in the same tree
Over the socket a holder is a name, so this fence holds among spokes that claim honestly; spokes with identities of their own (Remote workers) make it a fence no spoke can lift for another.
4. The spoke works, and merges on green
$ git -C lib worktree add ../lib-arc-2 -b arc-2-empty-words
$ gh pr create --fill
https://github.com/you/lib/pull/12
$ gh pr checks 12 --watch
$ gh pr merge 12 --squash
The merge waits on CI and nothing else: the hub reviews when it checks the evidence, not as a gate in front of every spoke’s merge, which would put every arc back in a single line. Then the spoke hands back:
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:comment item=2 author=spoke-lib handback=true https://github.com/you/lib/pull/12 merged on green' -c 'delete urn:gk:iki:ledger:claim item=2 from=spoke-lib'
commented on #2 (urn:iki:ledger:default:comment:…) awaiting-dispatcher
[uncacheable]
#2 released
[uncacheable]
— batch: 2 commands · 2 uncacheable
5. The hub checks, closes, and the next arc opens
The hub opens the pull request, sees it merged with CI green and the test in the diff, and closes the arc with what it checked. Closing the blocker is what makes the next arc ready:
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:close item=2 reason=done author=hub Checked: lib PR 12 merged, CI green.'
closed #2 (done)
[uncacheable]
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:iki:ledger:next'
1. #3 open p1 docs: document initials [arc]
p1 — priority 1; last updated …
policy: priority-recency (weighs priority, recency, number)
ready: 1 excluded: 1
not #1: claimed by hub
[uncacheable]
The docs arc goes the same way: a spoke claims it, works in its own tree, merges on green, and hands back; the hub checks and closes.
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:claim item=3 lease=90m spoke-docs' -c 'sink urn:gk:iki:ledger:comment item=3 author=spoke-docs handback=true https://github.com/you/docs/pull/5 merged on green' -c 'delete urn:gk:iki:ledger:claim item=3 from=spoke-docs'
#3 claimed by spoke-docs until …
[uncacheable]
commented on #3 (urn:iki:ledger:default:comment:…) awaiting-dispatcher
[uncacheable]
#3 released
[uncacheable]
— batch: 3 commands · 3 uncacheable
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:close item=3 reason=done author=hub Checked: docs PR 5 merged, CI green.'
closed #3 (done)
[uncacheable]
6. The hub’s own work: ask first
Some of the goal is the hub’s to do itself: here, a line in the changelog saying what the arcs changed. The hub files and claims it like any arc, and does it. But when its own pull request is green it does not merge: a merge is the moment work becomes shared state for everyone else, and the person who owns the goal gets one beat to redirect first. The hub hands its own item back with the question:
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:append priority=1 labels=hub changelog: initials handles every name' -c 'sink urn:gk:iki:ledger:link item=4 type=parent 1' -c 'sink urn:gk:iki:ledger:claim item=4 hub'
#4 urn:iki:ledger:default:item:…
[uncacheable]
linked #4 parent #1
[uncacheable]
#4 claimed by hub
[uncacheable]
— batch: 3 commands · 3 uncacheable
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:comment item=4 author=hub handback=true https://github.com/you/lib/pull/13 is green. Merge?'
commented on #4 (urn:iki:ledger:default:comment:…) awaiting-dispatcher
[uncacheable]
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:comment item=4 author=ada Yes, merge it.'
commented on #4 (urn:iki:ledger:default:comment:…) cleared-awaiting
[uncacheable]
The person’s answer, from a caller who may close, clears the wait. The hub merges, closes its own item, and with every child done, closes the goal:
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:close item=4 reason=done author=hub Merged lib PR 13 on the answer above.' -c 'sink urn:gk:iki:ledger:close item=1 reason=done author=hub All three children closed with evidence.'
closed #4 (done), released hub
[uncacheable]
closed #1 (done), released hub
[uncacheable]
— batch: 2 commands · 2 uncacheable
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:iki:ledger:item:3'
#3 closed p1 docs: document initials [arc] (done)
iri: urn:iki:ledger:default:item:…
filed: …
updated: …
parent: urn:iki:ledger:default:item:…
Repository: docs. One example, and one sentence on what an empty name returns.
Self-merge on green CI; hand back with the pull request.
2 comment(s):
… spoke-docs
https://github.com/you/docs/pull/5 merged on green
… hub
Checked: docs PR 5 merged, CI green.
[uncacheable]
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:iki:ledger:doctor'
doctor: default (lifecycle kata-flight): no problems
checked: orphaned, abandoned, lease-expired, unknown-state, two-states, awaiting-closed at …
[uncacheable]
Each arc’s item is its whole record: the parent it belongs to, the brief, the spoke’s evidence and the hub’s check, in that order. The close released the hub’s own claims as it closed them, so the doctor has nothing to report.
What holds it
The guides:
- The brief, one per arc, in the item: the repository, what done means, the evidence to bring back, and the authority the spoke has (here, to merge its own pull request on green). It is the only channel from hub to spoke, so a vague brief is the commonest way for an arc to go wrong.
- One repository per arc, one tree per spoke. An arc that touches two repositories is two arcs
with a
blockslink between them. - The rules every spoke works to: claim before writing, reproduce a reported bug first, every change through a pull request with green CI, hand back with evidence.
- The hub asks before merging its own work. Spokes self-merge because their brief says so; the hub’s authority to merge its own work is not something it can grant itself.
The sensors:
- Claims with leases, the fence between spokes. A second claim is a
conflictnaming the holder; a spoke that dies stops renewing and shows up aslease-expiredrather than as an arc quietly never finished. nextover the links, which keeps a blocked arc from being started early and says why.- CI, the only gate on a spoke’s merge.
- The hub’s evidence check, and the hand-back state that holds every finished arc out of
nextuntil the hub has answered it. - The doctor, for claims nobody is working.
What it costs, and when to choose it
The hub is a bottleneck by design: every arc’s brief is written by it and every arc’s evidence is checked by it, so its attention bounds how many spokes can run at once. Briefs cost time, and a brief that is wrong costs a spoke’s whole arc. Spokes that self-merge put changes on main without anyone’s review before the merge; what makes that acceptable is CI that actually tests what matters, and a hub that reads the evidence after.
Choose it when a goal spans repositories, when the arcs can run in parallel once ordered, and when CI in each repository is trusted enough to be the gate. For one repository and one agent, Solo on one machine is cheaper; for work that is already items, a dispatcher and remote workers is enough.
With the starter
The gonk starter’s hub profile packages the
hub’s two acts: tools/hub.py arc files an arc whose body is its brief (shaped by
.gonk/templates/brief.md) and links it parent and blocks, as step 1 did by hand, and
tools/hub.py dispatch gives an arc its own git worktree and holder, arc-<item>, as step 3
describes; just arc and just dispatch run them. AGENTS.md gains a “Hub and spoke” section.
The book checks the starter at the revision in books/gonk/starter.rev (Solo
says more):
$ git clone --quiet https://github.com/ikigai-rs/ikigai-gonk-starter starter
$ git -C starter checkout --quiet c7a501a29e93b1160f67007b080df9f2dbbe48d3
$ cd starter && python3 tools/new.py ../library --process hub
generated the library-lending sample (python, process hub) in …/library: 61 files
next, in that directory:
git init, then commit everything it wrote (the one commit where every file is yours)
python3 scripts/test.py every test, as CI runs them
python3 tools/gonk.py init --ledger <name> --socket ~/.ikigai/gonk.sock --holder <you>
python3 tools/gonk.py seed file the open changes' tasks as items
python3 tools/gonk.py next what to do first, and why
AGENTS.md holds this process's rules; README.md walks through the sample.
$ grep '^## ' library/AGENTS.md
## The loop
## 1. Claim the item before you write
## 2. One agent per claimed item, one agent per working tree
## 3. Reproduce before you fix
## 4. Every change through a pull request with green CI
## 5. Report with evidence; the dispatcher closes
## 6. Never a bare `#N`
## Specs, artifacts, queries and scripts
## Hub and spoke
## Optional: a hook for Claude Code
$ ls library/tools library/.gonk/templates
library/.gonk/templates:
brief.md
library/tools:
gonk.py
harness.py
hub.py
$ cat library/.gonk/process.toml
# How this repository's team works: the process profiles it was generated with, and what
# each one asks of it. Committed. Written by ikigai-gonk-starter's tools/new.py from its
# profiles/; `python3 tools/gonk.py init` and `status` report it and read nothing else to
# decide it. AGENTS.md holds the rules of exactly these profiles, so change both together.
version = 1
process = ["hub"]
hooks = ["claude:require-claim"]
checks = []
[profile.hub]
summary = "a hub splits work into single-repository arcs with written briefs, each dispatched to its own working tree and holder"
roles = ["hub: writes briefs as items, links them, dispatches each arc, closes on the evidence, asks a person before merging its own work", "arc agent: works one arc from its brief in its own working tree, merges on green, reports"]
$ cd library && git init -q && python3 tools/gonk.py init --ledger default --socket ../.ikigai/gonk.sock --holder hub
gonk init: …/library
ok layout openspec at openspec/; artifacts at artifacts/; queries at .gonk/queries/; scripts at .gonk/scripts/
note harness 4 harness question(s) in .gonk/queries/ are asked by `python3 tools/gonk.py harness` through gonk's store, not published: gonk runs a script under a ceiling of ledger reads, and they read the harness graph
note openspec 8 open task(s) in 2 change(s) in flight: `python3 tools/gonk.py seed` files them as ledger items (again is a no-op)
ok process hub (.gonk/process.toml)
note hub a hub splits work into single-repository arcs with written briefs, each dispatched to its own working tree and holder
note role hub: writes briefs as items, links them, dispatches each arc, closes on the evidence, asks a person before merging its own work
note role arc agent: works one arc from its brief in its own working tree, merges on green, reports
note hook the Claude Code claim hook is optional and off: merge hooks/claude/settings.sample.json into .claude/settings.json to turn it on
ok ikigai ikigai 0.1.61 at …/ikigai
ok ledger `default` (.gonk/gonk.toml)
ok holder `hub` (.gonk/local.toml, this working tree only)
ok identity the socket's owner: every request over it is `owner`, so the log cannot tell this tree from any other process of yours. A machine that must be named uses QUIC
ok gonk socket …/.ikigai/gonk.sock answers
ok items `default` holds 4 item(s), 0 open
ok scripts gonk offers `urn:script:catalog`: saved queries and workflow scripts (ikigai-script)
ok scripts published 4: urn:script:default-open-items-for-artifact, urn:script:default-open-work-by-change, urn:script:default-requirements-with-open-work, urn:script:default-next-for-artifact
unknown nl could not ask gonk about `urn:nl:sparql`
agents over MCP (configure the agent with this command):
ikigai mcp --override urn:iki:ledger:=…/.ikigai/gonk.sock
ready.
$ cd library && python3 tools/gonk.py status
process: hub
no live claim: this working tree holds no claim. Claim the item first: `python3 tools/gonk.py claim <number>` (or `just claim <number>`)
The unknown row is this page’s gonk mounting no model, so it binds no urn:nl:sparql (its
banner says so); the starter reads that refusal as a gonk it could not ask. The profile adds no
sensor the chapter lacks: the fence is still the claim, and the arc agent’s merge on green and the
hub’s question before merging its own work (step 6) are rules in the generated AGENTS.md, held
by the forge and by whoever reads them.
Profiles combine: a hub usually works over remote workers or spec-first
tasks, so --process hub,workers,spec is a list, not a new profile. Only unled audits
and review every commit refuse each other.
Spec first
Behavior is written down before it is built. A change starts as a proposal in the repository, with
the requirements it adds or modifies and a list of tasks; once that is agreed, the tasks become
ledger items in the order the list gives them, next hands them out one at a time, each task’s box
is checked in the same pull request as its code, and when the last one is done the change is
archived and the spec says what the system now does.
The machinery is A spec in the repository: OpenSpec’s openspec/ tree,
lifted to RDF, and a sync that files and closes items from it. This chapter is the process around
that machinery, run against gonk.
Who
| role | who | does |
|---|---|---|
| the proposer | a person, or an agent asked to draft | writes the change: proposal, delta spec, tasks |
| the approver | a person | merges the change’s pull request, which is the agreement to build it |
| the seeder | a host or a script, over the socket | files one item per open task, keyed by the task, in order |
| a worker | an agent or a person | takes the next task, implements it, checks its box in the same pull request |
| whoever archives | the proposer, usually | moves the change to the archive once every box is checked |
What runs where
repository: openspec/ gonk
┌──────────────────────────────────┐ ┌─────────────────────────────────┐
│ specs/ what the system does│ │ ledger: one item per open task │
│ changes/add-undo/ │ seed ──▶│ key = the task's IRI │
│ proposal.md, specs/, tasks.md │ │ about = the task, the req. │
│ changes/archive/… │◀─ boxes ─│ 1.2 ─blocks─▶ 1.3 ─blocks─▶ 2.1│
└──────────────────────────────────┘ checked └─────────────────────────────────┘
▲ in the same PR as the code
└──── one pull request per task ────── a worker, from `next`
One machine, the socket. The lift that turns the tree into RDF is not in gonk (In gonk, and not in this page), so the seeding here is the requests a seeder sends, written out; the Rust host in the spec part sends the same ones from a query over the lifted tree.
The loop, step by step
In a new empty directory, as in every chapter: if the gonk from Hub and spoke is still running, stop it first, since this one takes the same port.
$ ikigai-gonk --config-home "$PWD/.config/ikigai" --data-home "$PWD/.ikigai" --port 1070 --no-quic --no-backup 2>&1 | tee gonk.log
ikigai-gonk 0.1.0 — holding the store at …/.ikigai/store
…
1. Propose, and agree
The change is a folder: why (proposal.md), what the requirements become (a delta spec under
specs/), and how, as tasks. A task names the requirement it implements in a trailing parenthesis,
the book’s one convention beyond OpenSpec’s (OpenSpec in one chapter). This
is the tic-tac-toe change the spec part uses:
$ git init -q -b main game
$ mkdir -p game/openspec/changes/add-undo
# Add undo
## Why
A misclick ends a game early. Players should be able to take back the last move.
# Tasks
## 1. Undo
- [x] 1.1 Keep the game's moves in order, as the history undo reads
- [ ] 1.2 Empty the last move's square and return the turn (The last move can be undone)
- [ ] 1.3 Refuse undo once the game is over (The last move can be undone)
## 2. Turns
- [ ] 2.1 Give the turn back after an undo (Players alternate)
$ git -C game add openspec
$ git -C game -c user.name=Ada -c user.email=ada@example.com commit -q -m 'Propose add-undo'
The pull request that adds this folder is the agreement. It changes no code, so it is cheap to argue about, and arguing about it is the point: a requirement changed here costs a sentence, and the same change found after the code is written costs the code.
2. Seed the tasks as items
Every open task becomes an item, keyed by the task’s IRI so that seeding again files nothing, about the task and the requirement it implements, and labeled with the change. Task 1.1 is already checked, so it is never filed.
$ printf "add-undo 1.2: Empty the last move's square and return the turn" | ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:append key=urn:openspec:change:add-undo:task:1.2 about="urn:openspec:spec:moves:requirement:the-last-move-can-be-undone urn:openspec:change:add-undo:task:1.2" labels=openspec,add-undo'
#1 urn:iki:ledger:default:item:…
[uncacheable]
$ printf "add-undo 1.3: Refuse undo once the game is over" | ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:append key=urn:openspec:change:add-undo:task:1.3 about="urn:openspec:spec:moves:requirement:the-last-move-can-be-undone urn:openspec:change:add-undo:task:1.3" labels=openspec,add-undo'
#2 urn:iki:ledger:default:item:…
[uncacheable]
$ printf "add-undo 2.1: Give the turn back after an undo" | ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:append key=urn:openspec:change:add-undo:task:2.1 about="urn:openspec:spec:moves:requirement:players-alternate urn:openspec:change:add-undo:task:2.1" labels=openspec,add-undo'
#3 urn:iki:ledger:default:item:…
[uncacheable]
$ printf "add-undo 1.2: Empty the last move's square and return the turn" | ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:append key=urn:openspec:change:add-undo:task:1.2 about="urn:openspec:spec:moves:requirement:the-last-move-can-be-undone urn:openspec:change:add-undo:task:1.2" labels=openspec,add-undo'
#1 urn:iki:ledger:default:item:… existing open
[uncacheable]
The fourth line is a seeder run twice: existing, and nothing written. Then the order, as blocks
links in the order tasks.md lists the tasks, so next offers them one at a time:
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:link item=1 type=blocks 2' -c 'sink urn:gk:iki:ledger:link item=2 type=blocks 3'
linked #1 blocks #2
[uncacheable]
linked #2 blocks #3
[uncacheable]
— batch: 2 commands · 2 uncacheable
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:iki:ledger:next'
1. #1 open p- add-undo 1.2: Empty the last move's square and return the turn [add-undo openspec]
unprioritized — no priority set, so it ranks below every item that has one; last updated …
policy: priority-recency (weighs priority, recency, number)
ready: 1 excluded: 2
not #2: blocked by #1
not #3: blocked by #2
[uncacheable]
Because each item names its requirement, the ledger can be asked by requirement: how much open work implements “The last move can be undone”?
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:iki:ledger:count about=urn:openspec:spec:moves:requirement:the-last-move-can-be-undone'
2
[uncacheable]
3. One task, one pull request, one checked box
A worker takes the next task as in Solo on one machine: claim, branch, implement. The box is checked in the same commit as the code, so the tree and the code never disagree about whether the task is done:
$ git -C game switch -q -c add-undo-1.2
$ sed -i.bak 's/- \[ \] 1.2 /- [x] 1.2 /' game/openspec/changes/add-undo/tasks.md && rm game/openspec/changes/add-undo/tasks.md.bak
$ git -C game -c user.name=Agent -c user.email=agent@example.com commit -q -am 'add-undo 1.2: empty the last square, return the turn'
$ grep '1.2' game/openspec/changes/add-undo/tasks.md
- [x] 1.2 Empty the last move's square and return the turn (The last move can be undone)
(The code itself is not on this page: add-undo is the spec part’s example change, and building
undo is not what this chapter is about.) When the pull request merges, the item closes with the reason. The key finds
it, so whatever closes it needs only the task:
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:close item=key:urn:openspec:change:add-undo:task:1.2 reason=done checked off in tasks.md'
closed #1 (done)
[uncacheable]
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:iki:ledger:next'
1. #2 open p- add-undo 1.3: Refuse undo once the game is over [add-undo openspec]
unprioritized — no priority set, so it ranks below every item that has one; last updated …
policy: priority-recency (weighs priority, recency, number)
ready: 1 excluded: 1
not #3: blocked by #2
[uncacheable]
4. Keep the spec true
When the work shows the spec was wrong (undo after a win turns out to be wanted, say), the fix is a
change to the delta spec and to tasks.md, in the pull request that discovers it, and the
seeder runs again: a new task files a new item, a removed one is closed by hand with
reason=wontfix and why. The spec is never left describing a system nobody built.
5. Archive
When every box is checked the change is archived: its folder moves under changes/archive/ with
the date (OpenSpec’s openspec archive also folds the delta into specs/, which is then what the
system does). Here the last two tasks are done in one go, and the move is a commit of its own:
$ sed -i.bak 's/- \[ \] /- [x] /' game/openspec/changes/add-undo/tasks.md && rm game/openspec/changes/add-undo/tasks.md.bak
$ git -C game -c user.name=Agent -c user.email=agent@example.com commit -q -am 'add-undo 1.3 and 2.1'
$ mkdir -p game/openspec/changes/archive
$ git -C game mv openspec/changes/add-undo openspec/changes/archive/2026-10-13-add-undo
$ git -C game -c user.name=Ada -c user.email=ada@example.com commit -q -m 'Archive add-undo'
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:close item=key:urn:openspec:change:add-undo:task:1.3 reason=done archived with add-undo on 2026-10-13' -c 'sink urn:gk:iki:ledger:close item=key:urn:openspec:change:add-undo:task:2.1 reason=done archived with add-undo on 2026-10-13'
closed #2 (done)
[uncacheable]
closed #3 (done)
[uncacheable]
— batch: 2 commands · 2 uncacheable
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:iki:ledger:next'
nothing is ready (0 open item(s) excluded: the ledger has no open items matching the filter)
policy: priority-recency
[uncacheable]
The items still say what they were about, by IRI, after the folder has moved: a later reader can ask which requirement a piece of work implemented without the tree.
What holds it
The guides: the spec itself, which says what the system does; the change’s proposal and delta,
which say what it will do; tasks.md, which says in what order; and two conventions, a task names
its requirement, and a box is checked in the same pull request as its code.
The sensors:
- The key, so seeding is safe to repeat on every commit, and two seeders at once file once.
blocksandnext, so tasks are taken in the order the change gives them.about, so “what implements this requirement” and “which open item is about a requirement a change has since removed” are queries (Whataboutbuys).- The archive, which a sync over the lifted tree turns into closes (the spec part’s walk does exactly that), so an item cannot outlive its change unnoticed.
- CI, as everywhere, on each task’s pull request.
What it costs, and when to choose it
A proposal and a delta spec are writing nobody does in the other processes, and a seeder is one more thing to run. The spec has to be kept true by people, in pull requests: a spec that drifts is worse than none, because agents read it as the truth. And the lift is not in gonk, so the seeding and the closes are a host’s or a script’s to run.
Choose it when behavior matters more than speed: a public interface, a protocol, something several people or agents will build against. It pairs well with Hub and spoke, where a change’s tasks are the arcs. For a one-line fix, file an item and fix it.
With the starter
The gonk starter’s spec profile adds a “Spec
first” section to AGENTS.md and nothing else, because what it needs is already in every
repository the starter generates: an openspec/ tree (the library-lending sample’s, with two
changes in flight) and tools/gonk.py seed, the seeder step 2 wrote out by hand. Spec first says
how work is defined, not who does it, so it has no roles of its own and is meant to be combined:
here with Solo, as --process solo,spec. The book checks the starter at the revision
in books/gonk/starter.rev (Solo says more):
$ git clone --quiet https://github.com/ikigai-rs/ikigai-gonk-starter starter
$ git -C starter checkout --quiet c7a501a29e93b1160f67007b080df9f2dbbe48d3
$ cd starter && python3 tools/new.py ../library --process solo,spec
generated the library-lending sample (python, process solo, spec) in …/library: 59 files
next, in that directory:
git init, then commit everything it wrote (the one commit where every file is yours)
python3 scripts/test.py every test, as CI runs them
python3 tools/gonk.py init --ledger <name> --socket ~/.ikigai/gonk.sock --holder <you>
python3 tools/gonk.py seed file the open changes' tasks as items
python3 tools/gonk.py next what to do first, and why
AGENTS.md holds this process's rules; README.md walks through the sample.
$ grep '^## ' library/AGENTS.md
## The loop
## 1. Claim the item before you write
## 2. One agent per claimed item, one agent per working tree
## 3. Reproduce before you fix
## 4. Every change through a pull request with green CI
## 5. Report with evidence; the dispatcher closes
## 6. Never a bare `#N`
## Specs, artifacts, queries and scripts
## Spec first
## Optional: a hook for Claude Code
$ cat library/.gonk/process.toml
# How this repository's team works: the process profiles it was generated with, and what
# each one asks of it. Committed. Written by ikigai-gonk-starter's tools/new.py from its
# profiles/; `python3 tools/gonk.py init` and `status` report it and read nothing else to
# decide it. AGENTS.md holds the rules of exactly these profiles, so change both together.
version = 1
process = ["solo", "spec"]
hooks = ["claude:require-claim"]
checks = []
[profile.solo]
summary = "one person and their agent: you are your own dispatcher, and close after checking your own evidence"
roles = ["you: both the dispatcher and whoever does the work"]
[profile.spec]
summary = "spec first: every change in behavior starts as an openspec change, seed files its tasks, next orders them"
roles = []
seed --dry-run reads the changes in flight and says what it would send, asking gonk nothing: one
item per open task, keyed by the task’s IRI, and blocks links in tasks.md order. Run without
--dry-run, each item is also about the requirement its task cites and labeled with its change,
and a second run files nothing (the key answers existing).
$ cd library && git init -q && python3 tools/gonk.py init --ledger default --socket ../.ikigai/gonk.sock --holder agent
gonk init: …/library
ok layout openspec at openspec/; artifacts at artifacts/; queries at .gonk/queries/; scripts at .gonk/scripts/
note harness 4 harness question(s) in .gonk/queries/ are asked by `python3 tools/gonk.py harness` through gonk's store, not published: gonk runs a script under a ceiling of ledger reads, and they read the harness graph
note openspec 8 open task(s) in 2 change(s) in flight: `python3 tools/gonk.py seed` files them as ledger items (again is a no-op)
ok process solo, spec (.gonk/process.toml)
note solo one person and their agent: you are your own dispatcher, and close after checking your own evidence
note role you: both the dispatcher and whoever does the work
note spec spec first: every change in behavior starts as an openspec change, seed files its tasks, next orders them
note hook the Claude Code claim hook is optional and off: merge hooks/claude/settings.sample.json into .claude/settings.json to turn it on
ok ikigai ikigai 0.1.61 at …/ikigai
ok ledger `default` (.gonk/gonk.toml)
ok holder `agent` (.gonk/local.toml, this working tree only)
ok identity the socket's owner: every request over it is `owner`, so the log cannot tell this tree from any other process of yours. A machine that must be named uses QUIC
ok gonk socket …/.ikigai/gonk.sock answers
ok items `default` holds 3 item(s), 0 open
ok scripts gonk offers `urn:script:catalog`: saved queries and workflow scripts (ikigai-script)
ok scripts published 4: urn:script:default-open-items-for-artifact, urn:script:default-open-work-by-change, urn:script:default-requirements-with-open-work, urn:script:default-next-for-artifact
unknown nl could not ask gonk about `urn:nl:sparql`
agents over MCP (configure the agent with this command):
ikigai mcp --override urn:iki:ledger:=…/.ikigai/gonk.sock
ready.
$ cd library && python3 tools/gonk.py seed --dry-run
would file add-holds 1.1: Accept a hold on a title whose copies are all out
key=urn:openspec:change:add-holds:task:1.1
would file add-holds 1.2: Keep a returned copy for the first holder
key=urn:openspec:change:add-holds:task:1.2
would file add-holds 1.3: Refuse a renewal while a hold waits
key=urn:openspec:change:add-holds:task:1.3
would file add-holds 1.4: Add a vector to openspec/vectors.json for every new scenario
key=urn:openspec:change:add-holds:task:1.4
would file add-holds 2.1: Add `library.py hold MEMBER ISBN`
key=urn:openspec:change:add-holds:task:2.1
would file add-holds 2.2: Show who a kept copy waits for in `library.py copy`
key=urn:openspec:change:add-holds:task:2.2
would file cap-late-fees 1.1: Stop a late return's fee at 1000 cents
key=urn:openspec:change:cap-late-fees:task:1.1
would file cap-late-fees 1.2: Add a vector for the capped return to openspec/vectors.json
key=urn:openspec:change:cap-late-fees:task:1.2
would link add-holds 1.1 blocks 1.2
would link add-holds 1.2 blocks 1.3
would link add-holds 1.3 blocks 1.4
would link add-holds 1.4 blocks 2.1
would link add-holds 2.1 blocks 2.2
would link cap-late-fees 1.1 blocks 1.2
seed --dry-run: 8 open task(s) would be filed, in 6 order link(s); 0 checked off
$ cd library && python3 tools/gonk.py status
process: solo, spec
no live claim: this working tree holds no claim. Claim the item first: `python3 tools/gonk.py claim <number>` (or `just claim <number>`)
The unknown row is this page’s gonk mounting no model, so it binds no urn:nl:sparql (its
banner says so); the starter reads that refusal as a gonk it could not ask. The two changes in
flight seed two chains, and next offers the head of each: two tasks that can go in parallel,
and the rest of each chain one at a time behind its head.
Any profile that says who does the work combines with this one (--process workers,spec,
hub,spec); only unled audits and review every commit
refuse each other.
Periodic unled audits
Every so often, one repository is audited: two auditors (agents, usually), each in its own copy of the repository at one pinned commit, each blind to the other and to every earlier review, with a list of files and a budget, and one rule above the rest: a bug counts only with a reproduction. Their reports are merged by root cause, and each real finding is filed as its own ledger item, under one item for the audit.
The audit protocol is why: measured against following a reviewer’s leads, unled audits found about twice as many serious bugs per token, and a second independent auditor added half again. That chapter has the numbers and what they do and do not show. This one is the how-to.
Who
| role | who | does |
|---|---|---|
| the coordinator | a person, or a hub | picks the repository, pins the commit, writes the scope and budget, merges the reports, files the findings |
| auditor A, auditor B | two agents (or people), independent | read, reproduce, report; never fix, never see each other’s report |
| whoever fixes | the ordinary loop, later | one finding at a time, each closed with its evidence |
What runs where
repository at commit C
├── worktree audit-a (detached at C) ── auditor A ── report A ─┐
└── worktree audit-b (detached at C) ── auditor B ── report B ─┤
▼
coordinator: merge by root cause, file into gonk
┌──────────────────────────────────────────────┐
│ audit item (scope, budget, what was not read)│
│ ├─ finding 1 (reproduction, both auditors) │
│ └─ finding 2 (reproduction, auditor B) │
└──────────────────────────────────────────────┘
Read-only on the code; everything written goes to the ledger. One machine is enough.
The loop, step by step
In a new empty directory, as in every chapter: if the gonk from Spec first is still running, stop it first, since this one takes the same port.
$ ikigai-gonk --config-home "$PWD/.config/ikigai" --data-home "$PWD/.ikigai" --port 1070 --no-quic --no-backup 2>&1 | tee gonk.log
ikigai-gonk 0.1.0 — holding the store at …/.ikigai/store
…
1. Pick, pin and scope
The repository to audit is chosen on a rotation, oldest audit first; one with a large recent change or a security surface moves up. Here it is a small one, two shell tools:
#!/bin/sh
# initials NAME: the first letter of each word of NAME. "Ada Lovelace" is "AL".
for word in $1; do printf '%s' "$(printf '%s' "$word" | cut -c1)"; done
echo
#!/bin/sh
# slug TITLE: a title as a URL slug. "Ada Lovelace" is "ada-lovelace".
printf '%s\n' "$1" | tr 'A-Z ' 'a-z-'
$ git -C tools init -q -b main
$ git -C tools add bin
$ git -C tools -c user.name=Ada -c user.email=ada@example.com commit -q -m 'initials and slug'
$ sh tools/bin/initials.sh 'Ada Lovelace'
AL
$ sh tools/bin/slug.sh 'Ada Lovelace'
ada-lovelace
The audit’s item is its brief: the repository, the commit, the files, the budget and the rules. The commit is pinned so that every finding is about one known state of the code:
$ printf 'audit: tools at %s\n\nFiles: bin/initials.sh, bin/slug.sh. Budget: 40 tool calls per auditor.\nRead-only. A bug counts only with a reproduction that fails at this commit because of it.\nReport what was not read, and whether the budget or the work ran out.\n' "$(git -C tools rev-parse --short HEAD)" | ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:append labels=audit key=audit:tools:2026-10-13'
#1 urn:iki:ledger:default:item:…
[uncacheable]
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:claim item=1 coordinator'
#1 claimed by coordinator
[uncacheable]
2. Two auditors, two trees, blind
Each auditor gets a detached worktree at the pinned commit, so neither can change what the other reads, and neither is shown the other’s report, the repository’s earlier findings, or any review’s:
$ git -C tools worktree add -q --detach ../audit-a HEAD
$ git -C tools worktree add -q --detach ../audit-b HEAD
$ git -C audit-a log --oneline
… initials and slug
What the auditors do with their budget is reading, thinking and probing, which this page cannot
show (an agent does it). What it can show is what they must bring back: reproductions. Auditor A
found that initials.sh leaves its argument unquoted, so the shell expands a name as a pattern. The
reproduction runs the tool in a directory with one file in it:
$ mkdir -p probe-a && touch probe-a/zebra
$ cd probe-a && sh ../audit-a/bin/initials.sh '*'
z
The initials of the name * came out as the first letter of a file name. Auditor B found the same
thing on its own, and one more: slug.sh turns every space into a dash, so two spaces make two
dashes:
$ sh audit-b/bin/slug.sh 'Ada Lovelace'
ada--lovelace
Each auditor’s report lists its findings with their reproductions, its suspicions as suspicions, what it did not read, and whether the budget or the work ran out first. Auditor A also suspected that a name passed as two arguments loses its second word; it is the documented usage that the name is one argument, so it stays a suspicion and is not filed.
3. Merge by root cause, file one item per finding
The coordinator merges the two reports by root cause, so that the glob bug, found twice, is one finding, credited to both, and files each finding as its own item: keyed so that filing it again files nothing, a child of the audit, about the file it is in, with its reproduction in the body. (The audit protocol describes the merged report as one item per repository; that is the audit’s item here, and each root cause goes under it as its own item so that each can be claimed, fixed and closed with its own evidence.)
initials.sh expands a name as a glob pattern
Found by: auditor A, auditor B. Severity: wrong output.
Reproduction: in a directory holding only `zebra`, `sh bin/initials.sh '*'` prints `z`; it should print `*`.
Cause: `for word in $1` leaves $1 unquoted.
slug.sh turns two spaces into two dashes
Found by: auditor B. Severity: wrong output.
Reproduction: `sh bin/slug.sh 'Ada Lovelace'` prints `ada--lovelace`; it should print `ada-lovelace`.
Cause: `tr` maps each space, and nothing squeezes runs of them.
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:append priority=1 labels=audit,bug key=audit:tools:2026-10-13:glob about=urn:repo:tools:file:bin/initials.sh' < finding-glob.txt
#2 urn:iki:ledger:default:item:…
[uncacheable]
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:append priority=2 labels=audit,bug key=audit:tools:2026-10-13:slug about=urn:repo:tools:file:bin/slug.sh' < finding-slug.txt
#3 urn:iki:ledger:default:item:…
[uncacheable]
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:link item=2 type=parent 1' -c 'sink urn:gk:iki:ledger:link item=3 type=parent 1'
linked #2 parent #1
[uncacheable]
linked #3 parent #1
[uncacheable]
— batch: 2 commands · 2 uncacheable
What is not a finding goes on the audit’s own item, so the next audit of this repository starts from it: the budget, what was not read, and the suspicion.
A: 23 of 40 tool calls; the work ran out. Read both files. Suspicion, not reproduced as a defect: a name given as two arguments keeps only the first (the usage says one argument).
B: 31 of 40 tool calls; the work ran out. Read both files.
Not read by either: nothing in scope. Out of scope: how the tools are installed.
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:comment item=1 author=coordinator' < audit-notes.txt
commented on #1 (urn:iki:ledger:default:comment:…)
[uncacheable]
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:iki:ledger:next'
1. #2 open p1 initials.sh expands a name as a glob pattern [audit bug]
p1 — priority 1; last updated …
2. #3 open p2 slug.sh turns two spaces into two dashes [audit bug]
p2 — priority 2; last updated …
policy: priority-recency (weighs priority, recency, number)
ready: 2 excluded: 1
not #1: claimed by coordinator
[uncacheable]
4. Close each finding with evidence
The fixing is ordinary work, scheduled from the ledger and done in a separate pull request (an audit never fixes what it reads). Each finding closes with one of three kinds of evidence, written on the item before the close: fixed (the reproduction as a regression test, and the pull request), refuted (why the reproduction was wrong), or deferred (to what).
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'sink urn:gk:iki:ledger:close item=2 reason=done author=coordinator Fixed: https://github.com/you/tools/pull/3 quotes the argument; the reproduction is its regression test.'
closed #2 (done)
[uncacheable]
$ ikigai --mount "urn:gk:=$PWD/.ikigai/gonk.sock" -c 'source urn:gk:iki:ledger:item:1'
#1 open p- audit: tools at … [audit] claimed:coordinator
iri: urn:iki:ledger:default:item:…
key: audit:tools:2026-10-13
filed: …
updated: …
Files: bin/initials.sh, bin/slug.sh. Budget: 40 tool calls per auditor.
Read-only. A bug counts only with a reproduction that fails at this commit because of it.
Report what was not read, and whether the budget or the work ran out.
1 comment(s):
… coordinator
A: 23 of 40 tool calls; the work ran out. Read both files. Suspicion, not reproduced as a defect: a name given as two arguments keeps only the first (the usage says one argument).
B: 31 of 40 tool calls; the work ran out. Read both files.
Not read by either: nothing in scope. Out of scope: how the tools are installed.
[uncacheable]
The audit’s item stays with the coordinator until its last finding is closed, and then closes with a line saying what the audit found and what came of it. Its scope, budget and notes are what the next audit of this repository starts from, and its key names the date, so a later audit is a new item, never this one reopened.
What holds it
The guides: the audit’s brief (the repository, the pinned commit, the file list, the budget); the rules (read-only, blind, a reproduction for every bug, suspicions reported as suspicions); and the three kinds of closing evidence.
The sensors:
- The reproduction. It is the whole filter: a claim that cannot be made to fail at the pinned commit is not filed as a bug, however plausible it reads. It also becomes the regression test.
- The second auditor. Independent, same protocol, and the check that the first did not miss half of what was there; where the two overlap, the overlap is a confidence, and where they do not, it is the reason to run two.
- The pinned commit and detached trees, so every finding is about one known state, and no auditor can change what another reads.
- The keys, so the coordinator filing again (or a second coordinator) files each finding once.
- The report’s own account: what was not read, and whether the budget ran out, which turns “we audited it” into a statement someone can check.
What it costs, and when to choose it
Two strong-model audits of one repository is real money in tokens, and the coordinator’s merge is a person’s hour. What it buys is measured, for the authors’ repositories and models, in The audit protocol: more serious bugs per token than following a per-commit reviewer, with every one of them reproduced. It finds bugs older than any diff, which is what a per-commit reviewer cannot. It finds nothing between audits, which is what CI and tests are for.
Choose it on a rotation for every repository that matters, and out of turn for one with a large recent change or a security surface. Choose Review every commit instead or as well where its arithmetic is different (a small team, a cheap local model, a codebase new to its readers).
With the starter
The gonk starter’s audits profile packages
step 1’s brief: tools/audit.py (and just audit) files an unled audit of the files it is given
as one item, its body .gonk/templates/audit.md with the repository, the budget and the file list
filled in, and refuses a file that does not exist. AGENTS.md gains a “Periodic audits” section.
It replaces review on every commit rather than adding to it, so asking for both is refused, with
the reason. The book checks the starter at the revision in books/gonk/starter.rev
(Solo says more):
$ git clone --quiet https://github.com/ikigai-rs/ikigai-gonk-starter starter
$ git -C starter checkout --quiet c7a501a29e93b1160f67007b080df9f2dbbe48d3
$ cd starter && python3 tools/new.py ../library --process audits,review-every-commit
new.py: --process audits,review-every-commit: per-commit review and periodic unled audits are two answers to one question, how bugs are found; the audits profile replaces the other
$ cd starter && python3 tools/new.py ../library --process audits
generated the library-lending sample (python, process audits) in …/library: 61 files
next, in that directory:
git init, then commit everything it wrote (the one commit where every file is yours)
python3 scripts/test.py every test, as CI runs them
python3 tools/gonk.py init --ledger <name> --socket ~/.ikigai/gonk.sock --holder <you>
python3 tools/gonk.py seed file the open changes' tasks as items
python3 tools/gonk.py next what to do first, and why
AGENTS.md holds this process's rules; README.md walks through the sample.
$ grep '^## ' library/AGENTS.md
## The loop
## 1. Claim the item before you write
## 2. One agent per claimed item, one agent per working tree
## 3. Reproduce before you fix
## 4. Every change through a pull request with green CI
## 5. Report with evidence; the dispatcher closes
## 6. Never a bare `#N`
## Specs, artifacts, queries and scripts
## Periodic audits
## Optional: a hook for Claude Code
$ ls library/tools library/.gonk/templates
library/.gonk/templates:
audit.md
library/tools:
audit.py
gonk.py
harness.py
$ cat library/.gonk/process.toml
# How this repository's team works: the process profiles it was generated with, and what
# each one asks of it. Committed. Written by ikigai-gonk-starter's tools/new.py from its
# profiles/; `python3 tools/gonk.py init` and `status` report it and read nothing else to
# decide it. AGENTS.md holds the rules of exactly these profiles, so change both together.
version = 1
process = ["audits"]
hooks = ["claude:require-claim"]
checks = []
[profile.audits]
summary = "periodic unled audits instead of per-commit review: one repository, a file list, a budget, a reproduction per bug, one item per finding"
roles = ["auditor: claims an audit item, reads only what it lists, files one item per reproduced bug"]
$ cd library && git init -q && python3 tools/gonk.py init --ledger default --socket ../.ikigai/gonk.sock --holder coordinator
gonk init: …/library
ok layout openspec at openspec/; artifacts at artifacts/; queries at .gonk/queries/; scripts at .gonk/scripts/
note harness 4 harness question(s) in .gonk/queries/ are asked by `python3 tools/gonk.py harness` through gonk's store, not published: gonk runs a script under a ceiling of ledger reads, and they read the harness graph
note openspec 8 open task(s) in 2 change(s) in flight: `python3 tools/gonk.py seed` files them as ledger items (again is a no-op)
ok process audits (.gonk/process.toml)
note audits periodic unled audits instead of per-commit review: one repository, a file list, a budget, a reproduction per bug, one item per finding
note role auditor: claims an audit item, reads only what it lists, files one item per reproduced bug
note hook the Claude Code claim hook is optional and off: merge hooks/claude/settings.sample.json into .claude/settings.json to turn it on
ok ikigai ikigai 0.1.61 at …/ikigai
ok ledger `default` (.gonk/gonk.toml)
ok holder `coordinator` (.gonk/local.toml, this working tree only)
ok identity the socket's owner: every request over it is `owner`, so the log cannot tell this tree from any other process of yours. A machine that must be named uses QUIC
ok gonk socket …/.ikigai/gonk.sock answers
ok items `default` holds 3 item(s), 2 open
ok scripts gonk offers `urn:script:catalog`: saved queries and workflow scripts (ikigai-script)
ok scripts published 4: urn:script:default-open-items-for-artifact, urn:script:default-open-work-by-change, urn:script:default-requirements-with-open-work, urn:script:default-next-for-artifact
unknown nl could not ask gonk about `urn:nl:sparql`
agents over MCP (configure the agent with this command):
ikigai mcp --override urn:iki:ledger:=…/.ikigai/gonk.sock
ready.
$ cd library && python3 tools/audit.py artifacts/service/lending/domain.py artifacts/cli/library.py --dry-run
Audit library: 2 file(s), 60 tool calls
Repository: `library`. Budget: 60 tool calls. Files, and nothing else:
- `artifacts/service/lending/domain.py`
- `artifacts/cli/library.py`
An **unled audit**: read the files above and find the bugs in them. No earlier findings, no
hints and no suspicions come with it, on purpose: a lead narrows what gets looked at to what
someone already suspected.
- **A reproduction for every bug**: a failing test, or a command and its output. A suspicion
without one is not reported as a bug.
- **One item per finding**: `python3 tools/gonk.py file "<title>" --labels audit-finding
--body "<the reproduction, the file and the line>"`, so each is claimed, fixed and closed on
its own.
- **Stop at the budget.** Say where you stopped and what you did not read.
- **Report** with `--note` listing the items you filed; close `--reason audit-no-change` when
you found nothing.
$ cd library && python3 tools/gonk.py status
process: audits
no live claim: this working tree holds no claim. Claim the item first: `python3 tools/gonk.py claim <number>` (or `just claim <number>`)
--dry-run prints the item and files nothing; without it the audit is an item labeled audit,
and a second audit of the same files is a second item, on purpose: an audit is periodic. The
template’s rules are this chapter’s in the starter’s own commands: gonk.py file for each
finding (step 3), then the audit’s report naming the items filed, or a close with
audit-no-change when nothing was found. The unknown row is this
page’s gonk mounting no model, so it binds no urn:nl:sparql (its banner says so); the starter
reads that refusal as a gonk it could not ask.
Every other pair of profiles combines: --process solo,audits is one person auditing their own
repository on a schedule, and --process solo,spec is Solo with spec
first’s rules.
Harness engineering
The six chapters before this one are processes, and each ended with a section called “What holds it”: the rules the work follows and the checks that catch it when it does not. This chapter is about those rules and checks themselves, around any of the six: what they are, where each one acts, which of them ever does anything, and how they get better.
The words are Birgitta Böckeler’s, from Harness engineering for coding agent users (martinfowler.com, 2 April 2026). In our words, and briefly:
- A coding agent’s harness is the set of controls around it that make what it produces more trustworthy. A control is a guide or a sensor.
- A guide acts before the agent does (feedforward): a rule in
AGENTS.md, a brief, a template it starts from. A sensor acts after (feedback): it observes what was done and refuses, warns or reports, ideally in words the agent can act on. Guides alone are rules nobody checks; sensors alone let an agent make the same mistake every time. - Each control is computational (a program decides: a test, a linter, a hook that refuses) or inferential (a model or a person decides: a review, a judge, a dispatcher reading the evidence). Computational controls are cheap and deterministic, so they can run on every change; inferential ones see what a program cannot, at a cost and without a guarantee.
- Controls act at stages of a change’s life: as the agent calls a tool, at commit, in CI, at merge, and outside any one change (a scheduled drift scan, a periodic audit). The earlier a control acts, the cheaper the mistake it stops.
- Controls regulate categories: maintainability, architecture fitness and behavior. The gonk starter adds a fourth, authority and safety, for the rules about who may do what: claim, merge, close.
- The steering loop is the person’s part. When a failure recurs, it becomes a new control or a better one, so the human’s job is to “steer the agent by iterating on the harness”.
- A harness template packages guides and sensors for one kind of project, so a new repository starts with them rather than growing them one incident at a time.
Her article ends on open questions, and two of them are what this chapter is about. How would you measure a harness’s coverage, the way code coverage measures tests? And when a sensor never fires, is that a sign the work is good, or that the sensor cannot see?
The worked example is the gonk starter, which is a harness template: every repository it generates carries its harness as a graph, logs every time a sensor fires, and asks gonk four coverage questions of both. Its loop is this chapter’s loop, so where the six chapters before it end with “With the starter”, this one is the starter from the beginning.
Who
| role | who | does |
|---|---|---|
| the person who steers | whoever owns the process | reads the coverage answers; turns a recurring failure into a new or better control |
| the agent | any agent working in the repository | reads the guides; trips the sensors; is told why |
| the sensors | the repository’s own tools and hooks | refuse or warn, and record one line each time |
| the dispatcher | the owner of the gonk the work lives in | loads the harness into gonk and asks the coverage questions |
| gonk | the same gonk as the ledger | holds the harness graph and the firings beside the ledger, and answers SPARQL over them |
What runs where
the repository (committed) this working tree (git-ignored)
┌─────────────────────────────────────┐ ┌──────────────────────────────────┐
│ AGENTS.md ───────────── the guides │ │ .gonk/state/harness/firings.log │
│ .gonk/harness/harness.ttl │ │ one line per refusal or warning │
│ every guide and sensor, as RDF │ └─────────────────▲────────────────┘
│ .gonk/harness/vocabulary.ttl │ │
│ tools/, hooks/ ──────── the sensors ├───────── each firing ─────┘
│ .gonk/queries/harness-*.rq │
└──────────────────┬──────────────────┘
│ python3 tools/gonk.py harness (the owner's socket)
▼
gonk: the named graph urn:iki:harness:graph:<ledger>, beside the ledger's own,
holding the description and every tree's firings; the four questions asked of it
Everything but gonk is files in the repository. The graph is committed, so a change to a control is a reviewed change like any other; the firing log is per working tree and never committed.
The loop, step by step
This page’s gonk, as in every chapter before it: if the one from Periodic unled audits is still running, stop it first, since this one takes the same port.
$ ikigai-gonk --config-home "$PWD/.config/ikigai" --data-home "$PWD/.ikigai" --port 1070 --no-quic --no-backup 2>&1 | tee gonk.log
ikigai-gonk 0.1.0 — holding the store at …/.ikigai/store
…
1. Generate a repository with a profile
The starter’s tools/new.py generates a repository for one process, here Solo on one
machine’s. The book checks the starter at the revision in books/gonk/starter.rev, which
carries the harness and the vocabulary’s complete definition:
$ git clone --quiet https://github.com/ikigai-rs/ikigai-gonk-starter starter
$ git -C starter checkout --quiet c7a501a29e93b1160f67007b080df9f2dbbe48d3
$ cd starter && python3 tools/new.py ../library --process solo
generated the library-lending sample (python, process solo) in …/library: 59 files
next, in that directory:
git init, then commit everything it wrote (the one commit where every file is yours)
python3 scripts/test.py every test, as CI runs them
python3 tools/gonk.py init --ledger <name> --socket ~/.ikigai/gonk.sock --holder <you>
python3 tools/gonk.py seed file the open changes' tasks as items
python3 tools/gonk.py next what to do first, and why
AGENTS.md holds this process's rules; README.md walks through the sample.
$ LC_ALL=C ls library/.gonk/harness library/.gonk/queries library/tools
library/.gonk/harness:
harness.ttl
vocabulary.ttl
library/.gonk/queries:
README.md
harness-firings-by-control.rq
harness-guides-without-sensor.rq
harness-sensors-never-fired.rq
harness-sensors-without-guide.rq
open-items-for-artifact.rq
open-work-by-change.rq
requirements-with-open-work.rq
library/tools:
gonk.py
harness.py
harness.ttl is assembled the way AGENTS.md is, from one fragment per section of the rules, so
a repository describes the controls of the process it was generated for and no others. Another
profile, or several, gives a different graph.
2. The harness as a graph
Each rule of AGENTS.md is a guide, and each place the tools enforce one is a sensor, both
with a stable IRI whose last segment never changes, so that every firing recorded under it stays
joined to it. (guide: and ctl: abbreviate urn:iki:harness:starter:guide: and
urn:iki:harness:starter:control:.) One rule, from AGENTS.md’s fifth section:
$ awk -v RS= '/^guide:report-with-evidence /' library/.gonk/harness/harness.ttl
guide:report-with-evidence a harness:Guide ;
rdfs:label "report with evidence" ;
rdfs:comment "Finished work is reported with the evidence that it is done, a pull request's URL or the reproduction that shows it is not a defect, recorded as a comment a later reader can check." ;
harness:statedIn "AGENTS.md" ; harness:section "5. Report with evidence; the dispatcher closes" ;
harness:direction harness:feedforward ; harness:execution harness:inferential ;
harness:stage harness:toolCall ; harness:category harness:behavior .
A guide is always feedforward and inferential: an agent reads it and applies it, or does not. The sensor that checks this one:
$ awk -v RS= '/^ctl:evidence-required /' library/.gonk/harness/harness.ttl
ctl:evidence-required a harness:Control ;
rdfs:label "report and close need evidence" ;
rdfs:comment "`tools/gonk.py report` and `close` refuse without `--evidence`, or with one that is not a URL, before anything reaches the ledger." ;
harness:direction harness:feedback ; harness:execution harness:computational ;
harness:stage harness:toolCall ; harness:category harness:behavior ;
harness:outcome harness:refuse ;
harness:enforcedAt "tools/gonk.py#evidence_note" ;
harness:statedBy guide:report-with-evidence ;
harness:firedBy test:test_harness:SensorsFire.test_evidence_required .
Feedback and computational, acting as the tool is called, and it refuses. enforcedAt says where
its code is, statedBy names the rule it mechanizes, and firedBy names the test that drives it
and sees the firing recorded, which is the proof that it can fire. A graph like this drifts from
the code it describes unless something holds the two together, and tools/harness.py check is that
something: it reads the control ids the code names, holds them to the graph in both directions,
the graph to its vocabulary, and each guide’s section to a heading of AGENTS.md:
$ cd library && python3 tools/harness.py check
harness check: 17 guide(s) and 14 other control(s) agree with the code
3. A rule fires
The repository is wired to this page’s gonk as in Solo. Two rows of
init matter here: harness, which says the coverage questions are asked rather than published
(a script runs under a ceiling of ledger reads, and these read the harness graph), and hook,
which says the Claude Code claim hook is off. Step 5 comes back to that one.
$ cd library && git init -q && python3 tools/gonk.py init --ledger default --socket ../.ikigai/gonk.sock --holder ada
gonk init: …/library
ok layout openspec at openspec/; artifacts at artifacts/; queries at .gonk/queries/; scripts at .gonk/scripts/
note harness 4 harness question(s) in .gonk/queries/ are asked by `python3 tools/gonk.py harness` through gonk's store, not published: gonk runs a script under a ceiling of ledger reads, and they read the harness graph
note openspec 8 open task(s) in 2 change(s) in flight: `python3 tools/gonk.py seed` files them as ledger items (again is a no-op)
ok process solo (.gonk/process.toml)
note solo one person and their agent: you are your own dispatcher, and close after checking your own evidence
note role you: both the dispatcher and whoever does the work
note hook the Claude Code claim hook is optional and off: merge hooks/claude/settings.sample.json into .claude/settings.json to turn it on
ok ikigai ikigai 0.1.61 at …/ikigai
ok ledger `default` (.gonk/gonk.toml)
ok holder `ada` (.gonk/local.toml, this working tree only)
ok identity the socket's owner: every request over it is `owner`, so the log cannot tell this tree from any other process of yours. A machine that must be named uses QUIC
ok gonk socket …/.ikigai/gonk.sock answers
note items `default` holds nothing yet, or this identity may not read it. A ledger has no create: it exists once something is filed in it (`python3 tools/gonk.py file "<title>"`)
ok scripts gonk offers `urn:script:catalog`: saved queries and workflow scripts (ikigai-script)
ok scripts published 4: urn:script:default-open-items-for-artifact, urn:script:default-open-work-by-change, urn:script:default-requirements-with-open-work, urn:script:default-next-for-artifact
unknown nl could not ask gonk about `urn:nl:sparql`
agents over MCP (configure the agent with this command):
ikigai mcp --override urn:iki:ledger:=…/.ikigai/gonk.sock
ready.
(The unknown row is this page’s gonk mounting no model, as in Periodic unled
audits.) Now an item, a claim, and a hand-back that forgets its
evidence:
$ cd library && python3 tools/gonk.py file 'Renew a loan before it is due'
#1 urn:iki:ledger:default:item:…
$ cd library && python3 tools/gonk.py claim 1
#1 claimed by ada until …
$ cd library && python3 tools/gonk.py report 1 --note 'renewal works'
gonk.py: --evidence is a URL (the pull request, or the reproduction that shows it is not a defect): a report without evidence is a claim nobody can check
That refusal is the sensor working, and its message says what to do instead, which is the part an agent reads. It also wrote one line to this working tree’s firing log:
$ cat library/.gonk/state/harness/firings.log
# ikigai-log v1
@prefix harness: <https://ikigai-rs.dev/ns/harness#> .
@prefix log: <https://ikigai-rs.dev/ns/log#> .
@name urn:log:harness:tree-…
@instance urn:iki:harness:tree:…
@level log:info
@started …
@prev genesis
# This working tree's harness firings: one line each time a sensor in this
# repository's tools refused or warned. UNSEALED: no key, and no last line is
# known. Written by tools/harness.py; `python3 tools/gonk.py harness` loads it.
… harness:Refusal urn:iki:harness:starter:control:evidence-required seq=1 cmd=sha256:…
The file is one ikigai-log segment, so ikigai-log can read it as well as the starter can. The line is a time, the kind of firing, the control’s IRI, a sequence number, and the first twelve hex digits of a digest of the command, enough to tie repeated firings of one command together. Never the command’s text, a file’s contents, a path or a name. Recording is best effort and the starter’s tests prove it: an unwritable directory or a corrupt segment leaves the refusal and its message exactly as they were. Read back, by control:
$ cd library && python3 tools/harness.py firings
1 refusal evidence-required last …
1 firing(s) in urn:log:harness:tree-…
4. Ask the four coverage questions
gonk.py harness loads the graph and this tree’s firings into gonk’s named graph
urn:iki:harness:graph:default with one SPARQL update, then asks the four questions in
.gonk/queries/harness-*.rq and prints the answers. Writing that graph needs a store grant no
ledger grant includes, and gonk’s owner-only socket holds it, so it is the dispatcher’s command,
from gonk’s machine; here this page’s gonk is the owner’s:
$ cd library && python3 tools/gonk.py harness
harness: 17 rule(s), 14 sensor(s) and 0 other control(s) in .gonk/harness/harness.ttl; 1 firing(s) in this tree
loaded: updated <urn:iki:harness:graph:default>: +324 -0 quads
harness-firings-by-control: How often has each control fired, refusing or warning, and when first and last?
control label outcome firings first…last
ctl:evidence-required report and close need evidence Refusal 1 …
harness-guides-without-sensor: Which rules does no computational control check?
guide label section
guide:a-claim-is-a-lease a claim is a lease 1. Claim the item before you write
guide:hand-back-when-you-stop hand it back when you stop 1. Claim the item before you write
guide:reproduce-before-fix reproduce before you fix 3. Reproduce before you fix
guide:last-command-exit-status a pipeline's status is its last command's 4. Every change through a pull request with green CI
guide:stage-files-by-name stage files by name 4. Every change through a pull request with green CI
guide:dispatcher-closes-on-evidence the dispatcher closes, after checking the evidence 5. Report with evidence; the dispatcher closes
guide:no-bare-numbers never a bare #N 6. Never a bare `#N`
harness-sensors-never-fired: Which sensors of this repository's tools have never fired, and has a test shown each one can?
control label proof
ctl:claim-held-by-another claim: held by another holder fired by a test
ctl:claim-lease-expired claim: an expired lease is not free fired by a test
ctl:edit-hook-not-wired claim hook: no claim can be checked fired by a test
ctl:edit-hook-unreadable-input claim hook: input it cannot read fired by a test
ctl:edit-without-live-claim claim hook: an edit needs a live claim fired by a test
ctl:one-claim-per-tree a working tree holds one claim fired by a test
ctl:reject-needs-reason a rejection says why fired by a test
ctl:takeover-refused takeover: only an expired lease, from its holder fired by a test
harness-sensors-without-guide: Which sensors state no rule, so an agent meets them only by tripping them?
sensor label execution stages
ctl:reject-needs-reason a rejection says why computational merge
Four answers:
- Firings by control. What has fired, how often, first and last. Over a real month this is the list of rules that are not landing: a sensor that fires often is doing its job, and saying that agents keep making the move its rule warns against.
- Rules with no sensor. Seven of the seventeen rules are text an agent has to apply, with nothing that notices when it does not. Some can only ever be applied by a reader; each row is a candidate for a sensor, not a defect.
- Sensors never fired. Eight of the fourteen, every one with a test that shows it can.
evidence-requiredis not on the list, because step 3 fired it. - Sensors with no rule.
reject-needs-reasonrefuses a rejection without a reason, and no section ofAGENTS.mdsays that a rejection needs one, so an agent first hears of it from the refusal. The fix is a sentence in the rules, not another sensor.
The load is idempotent: a firing’s IRI is its log and its sequence number, so loading again adds nothing, and every working tree’s firings land in the same graph beside each other’s:
$ cd library && python3 tools/gonk.py harness | grep '^loaded'
loaded: updated <urn:iki:harness:graph:default>: +0 -0 quads
5. Steer: a rule with no sensor, and a sensor that never fired
The answers are where the steering loop starts. Take one rule from the second answer:
$ awk -v RS= '/^guide:stage-files-by-name /' library/.gonk/harness/harness.ttl
guide:stage-files-by-name a harness:Guide ;
rdfs:label "stage files by name" ;
rdfs:comment "Stage the files you changed by name: `git add -A` picks up files that are not yours." ;
harness:statedIn "AGENTS.md" ; harness:section "4. Every change through a pull request with green CI" ;
harness:direction harness:feedforward ; harness:execution harness:inferential ;
harness:stage harness:preCommit ; harness:category harness:authoritySafety .
A sensor for it is easy to describe and cheap to run: a hook on the agent’s shell commands (the
same kind of hook as the claim hook) that refuses git add -A, git add ., git add -u and
git commit -a before the shell runs them, and says why: name the files. Computational, outcome
refuse, and acting at the tool call, one stage earlier than the commit the rule is about. Adding
it is one pull request with three parts: the hook, which records each refusal under a new control
id; that control in the graph, with statedBy guide:stage-files-by-name; and a test that fires it,
named in firedBy. harness.py check fails if the code names an id the graph does not describe,
or the graph describes a sensor nothing shows can fire, so a part left out is caught, and the next
coverage answer has one row fewer. Whether the sensor is worth its friction is the steering
question. Before it exists the evidence comes from outside the graph (how often review or the
dispatcher catches a stray file in a commit); once it exists, the first answer counts it.
Now take one sensor from the third answer, edit-without-live-claim: the claim hook refusing an
edit while the working tree holds no live claim. It has never fired. Is that because nobody edits
without a claim? Not here: init said the hook is off, because it is optional and nothing
merged its settings. A sensor that is not installed never fires, and its silence looks exactly
like health. The hook is a program that reads a Claude Code tool call as JSON, so it can be run
by hand the way Claude Code would run it, after the claim is released:
$ cd library && python3 tools/gonk.py release 1 --note 'stopping here'
#1 released
$ printf '{"tool_name": "Edit", "tool_input": {"file_path": "%s/library/README.md"}}' "$PWD" | python3 library/hooks/claude/require-claim.py
require-claim: refused an edit to README.md: this working tree holds no claim. Claim the item first: `python3 tools/gonk.py claim <number>` (or `just claim <number>`)
$ cd library && python3 tools/harness.py firings
1 refusal evidence-required last …
1 refusal edit-without-live-claim last …
2 firing(s) in urn:log:harness:tree-…
So the sensor works, and was silent because nothing ran it. The firedBy proof answered a
narrower question than it seems to: it shows the code can fire, not that it is wired into
anyone’s work, nor that its trigger still matches what it watches. The claim refusals in the same
answer show the third gap: tools/gonk.py recognizes the ledger’s refusal by its wording, so a
change of wording in the ledger would silence the firing while the refusal goes on (the graph’s
own comment on claim-held-by-another says so, and an integration test holds the wording).
That is as far as the starter answers the question Böckeler leaves open. “Never fired” splits
into three: cannot fire (no test shows it can; harness.py check refuses that state), is not
wired (it can fire, and nothing runs it), and is quiet (it runs and nothing trips it). Only
the last is evidence of good work, and telling it from the second takes knowing what is installed
where, which no query of the graph can know yet. A real gap, stated rather than solved.
What holds it
The harness has a harness:
tools/harness.py check, the guide-and-code agreement. A control the code names and the graph does not describe, a control the graph describes and no code names, a term the vocabulary does not define, or a guide whose section is not a heading ofAGENTS.md, each fails it by name.- One test per sensor, named in the graph’s
firedBy, that drives the sensor and sees the firing recorded; the starter’s own tests holdfiredByto the tests that exist. - Stable ids. A control’s id never changes, so its firings stay joined to it across renames, and a firing outlives the control it names (the first answer shows it with an empty label).
- Recording that cannot change a decision. A firing is written best effort, never to standard output, and the tests prove a failed write leaves the refusal unchanged, so turning the log on cannot break the tools it watches.
- The questions as files,
.gonk/queries/harness-*.rq, each with its question and what an empty answer means in its header, so a reader can run them and a reviewer can change them.
What it costs, and when to choose it
Very little to keep: the graph is a few hundred lines of Turtle the starter generated, the log is a line per firing, and the questions are four queries. What costs is the discipline it asks for when the harness changes: a new sensor is a control in the graph and a test that fires it as well as the code, in the same pull request. That is the point. A control nobody has described is a control nobody can ask about.
Choose it whenever agents do real work in a repository, because the alternative is the harness everyone already has: rules in prose, checks in scripts, and no way to ask which rules are checked, which checks have ever done anything, or which refusals the rules never warned of. Choose the starter’s template to begin with it rather than retrofit it.
The authors run a larger harness the same way, in the authors’ private tooling: a graph of the controls, a firing log, and coverage questions asked of both.
The vocabulary
The terms are in the namespace https://ikigai-rs.dev/ns/harness# (harness:), whose definition
is the starter’s .gonk/harness/vocabulary.ttl: every term, including those a generated
repository does not use. It is not served at that address yet, so the starter’s file is the
definition.
The ledger, the store and browse in your own kernel
Every part of this book has shown a piece twice: as modules in a kernel the reader builds, and as gonk. This last chapter puts the pieces together the way gonk does, in a kernel of your own, and leaves out only what makes gonk a server — the doors. What is left is short, and the part of it worth reading closely is not the composition but the capabilities: with no doors, who is asking is no longer decided by which socket a request came in on, so the host has to say it, per caller.
One dataset, three families
The ledger and browse both keep state in RDF, and gonk keeps both in one dataset, because the
question it exists to answer — what work is there about this file, and what do we know about the
file — is a join across them. Here is the whole composition, from
crates/embed-host:
/// One dataset, three families and a model.
pub fn space(root: &Path, calls: Arc<AtomicUsize>) -> Fallback {
// The store HANDS OUT its dataset to one other holder, browse — and is told where that
// holder writes. With the promise, a scoped read of any other graph (every ledger read) is
// still cacheable; without it the store would have to assume browse might write anywhere,
// and no read of anything could be cached.
let writes = SharerWrites::only_the_default_graph().and_named_graph(BROWSE_GRAPH);
let (store, dataset) =
DurableStore::in_memory_shared_declaring(writes).expect("an in-memory store opens");
// browse over the same dataset, confined to its graph: the promise above, kept.
let browse = Mount::new([("demo".to_string(), root.to_path_buf())])
.config_home(None)
.graph(NamedNode::new(BROWSE_GRAPH).expect("an IRI"))
.explain(
ExplainConfig::new(dataset)
.file_provider(PROVIDER)
.dir_provider(PROVIDER)
.review_provider(PROVIDER)
.no_judge(),
)
.space();
Fallback::new(vec![
Arc::new(ikigai_store::space(store)) as Arc<dyn Space>,
Arc::new(ikigai_ledger::space()) as Arc<dyn Space>,
Arc::new(browse) as Arc<dyn Space>,
Arc::new(stub_model(calls)) as Arc<dyn Space>,
])
}
/// The host: [`space`], a Meta renderer and the story clock.
pub fn kernel(root: &Path, calls: Arc<AtomicUsize>) -> Kernel {
Kernel::with_meta_renderer(
Arc::new(space(root, calls)),
Arc::new(ikigai_vocab::TurtleRenderer),
)
.with_clock(Arc::new(ledger_host::StoryClock::default()))
}
Three things in it are decisions rather than plumbing.
- The dataset is handed out once.
in_memory_shared_declaringgives the store’s space its dataset and returns a second handle for browse, which takes anoxigraphstore directly. A durable host does the same withopen_shared_declaring(path, …); gonk does exactly this, with RocksDB under it. - The store is told where the other holder writes. The store caches a read confined to one
graph only if nothing it does not see can change that graph. Browse writes behind the store’s
back, so without
SharerWritesthe store would have to treat every read as uncacheable — and since a resource is only as cacheable as what it reads, every ledger read with it. With the promise, ledger reads stay cached. - browse is confined to the graph that was promised.
Mount::graphis browse’s half of the same statement. A promise the sharer does not keep is the worst case — cached answers that never expire — so the two are written from one constant.
The model is the stub from the explain chapter. In a host of your own it
would be a mount of a peer serving urn:llm:, as in gonk.
Who is asking
gonk decides a caller’s capability by its door: the socket’s owner is root, a loopback browser is the anonymous grant, a QUIC client is its certificate’s grant. A kernel with no doors is asked by code, and each piece of that code should hold only what its job needs. Three callers:
/// What the agent holds: one ledger's writer (the four tokens `ikigai-gonk grants default write`
/// prints), the repository, and browse's graph through the store's narrow read door.
const AGENT: [&str; 6] = [
"urn:cap:ledger:read:default",
"urn:cap:ledger:write:default",
"urn:cap:store:read:graph:urn:iki:ledger:graph:default",
"urn:cap:store:write:graph:urn:iki:ledger:graph:default",
"urn:cap:browse:read:demo",
"urn:cap:store:read:graph:urn:iki:browse:graph:default",
];
/// An agent working the `default` ledger: it files and reads work, reads the repository and
/// browse's graph (so it can JOIN its items to what browse knows), and spends nothing.
pub fn agent() -> Capability {
Capability::scoped(AGENT)
}
/// A headless reviewer: it may spend the model on the repository and read what it derived,
/// and it can neither publish a finding nor touch the ledger.
pub fn reviewer() -> Capability {
Capability::scoped(["urn:cap:browse:read:demo", "urn:cap:net:localhost"])
}
/// A person: the agent's grants, plus spending the model and deciding about findings.
pub fn person() -> Capability {
Capability::scoped(
AGENT
.into_iter()
.chain(["urn:cap:net:localhost", "urn:cap:annotate"]),
)
}
The agent is the four-token ledger writer from the first chapter, plus reading the repository and browse’s graph. The reviewer may spend the model and nothing else. The person may do both and decide. Watch each one stay inside its lines:
#![allow(unused)]
fn main() {
extern crate embed_host;
extern crate ikigai_core;
use std::sync::atomic::AtomicUsize;
use std::sync::Arc;
use embed_host::{agent, ask_as, kernel, person, reviewer, Scratch};
use ikigai_core::{Error, Verb};
let repo = Scratch::new();
let host = kernel(repo.path(), Arc::new(AtomicUsize::new(0)));
let (agent, reviewer, person) = (agent(), reviewer(), person());
let file = "urn:repo:demo:file:src/lib.rs";
let denied = |r: Result<String, Error>| matches!(r, Err(Error::Denied(_)));
// The agent files work about a file, and reads the file.
let filed = ask_as(&host, &agent, Verb::Sink, "urn:iki:ledger:append",
&[("content", "Make initials total"), ("about", file)]).unwrap();
assert!(filed.starts_with("#1 "), "{filed}");
assert!(ask_as(&host, &agent, Verb::Source, file, &[]).unwrap().contains("pub fn initials"));
// It may not spend the model, or write another ledger.
assert!(denied(ask_as(&host, &agent, Verb::Source, "urn:repo:demo:explain:src/lib.rs", &[])));
assert!(denied(ask_as(&host, &agent, Verb::Sink, "urn:iki:ledger:acme:append",
&[("content", "Not mine")])));
// The reviewer runs a pass; it may not publish what it found, or touch the ledger.
let pass = ask_as(&host, &reviewer, Verb::Source, "urn:repo:demo:review:src/lib.rs", &[]).unwrap();
assert!(pass.contains("2 findings"), "{pass}");
let finding = "urn:iki:finding:bd771489c5a22c9a4d45f4dd";
assert!(denied(ask_as(&host, &reviewer, Verb::Sink, finding, &[("decision", "publish")])));
assert!(denied(ask_as(&host, &reviewer, Verb::Source, "urn:iki:ledger:items", &[])));
// The person decides.
ask_as(&host, &person, Verb::Sink, finding,
&[("decision", "publish"), ("reproduced", "yes")]).unwrap();
// And the store's promise holds: with browse writing into the same dataset, a ledger read is
// still answered from the cache the second time.
let items = ikigai_core::Request::new(Verb::Source,
ikigai_core::Iri::parse("urn:iki:ledger:items").unwrap());
ask_as(&host, &agent, Verb::Source, "urn:iki:ledger:items", &[]).unwrap();
assert!(host.is_cached(&items, &agent));
}
The last check is the SharerWrites promise at work. Build the same space with
DurableStore::in_memory_shared(), which hands out the dataset and promises nothing, and that
assertion fails: every read is computed again, the ledger’s included.
None of those refusals is code in this crate. Each is a resource’s declared capability, enforced by the kernel against the caller’s scopes, and each refusal names the token it wanted.
The join
Now the reason for one dataset. Which work items are about a file that has a published
annotation, and what does the annotation say? The ledger’s ledger:about and browse’s
ik:annotates both point at the same IRI, urn:repo:demo:file:src/lib.rs, so it is one query
over the two graphs — asked by the agent, through the store’s narrow door, which checks a read
token for each graph it is given:
#![allow(unused)]
fn main() {
extern crate embed_host;
extern crate ikigai_core;
use std::sync::atomic::AtomicUsize;
use std::sync::Arc;
use embed_host::{agent, ask_as, kernel, person, reviewer, Scratch};
use ikigai_core::{Error, Verb};
let repo = Scratch::new();
let host = kernel(repo.path(), Arc::new(AtomicUsize::new(0)));
let file = "urn:repo:demo:file:src/lib.rs";
ask_as(&host, &agent(), Verb::Sink, "urn:iki:ledger:append",
&[("content", "Make initials total"), ("about", file)]).unwrap();
ask_as(&host, &reviewer(), Verb::Source, "urn:repo:demo:review:src/lib.rs", &[]).unwrap();
ask_as(&host, &person(), Verb::Sink, "urn:iki:finding:bd771489c5a22c9a4d45f4dd",
&[("decision", "publish"), ("reproduced", "yes")]).unwrap();
let query = r#"
PREFIX ledger: <https://ikigai-rs.dev/ns/ledger#>
PREFIX ik: <https://ikigai-rs.dev/ns#>
PREFIX oa: <http://www.w3.org/ns/oa#>
SELECT ?item ?line WHERE {
?i ledger:about ?file ; ledger:number ?item .
?a a oa:Annotation ; ik:annotates ?file ; oa:hasSelector ?s .
?s oa:exact ?line .
}"#;
let both = "urn:iki:ledger:graph:default urn:iki:browse:graph:default";
let rows = ask_as(&host, &agent(), Verb::Source, "urn:iki:store:graph-select",
&[("query", query), ("graph", both), ("as", "text/tab-separated-values")]).unwrap();
assert_eq!(rows, "?item\t?line\n\
1\t\"name.split(' ').map(|word| &word[..1]).collect()\"\n");
// The reviewer holds no read token for the ledger's graph, so it cannot ask.
let refused = ask_as(&host, &reviewer(), Verb::Source, "urn:iki:store:graph-select",
&[("query", query), ("graph", both)]);
assert!(matches!(refused, Err(Error::Denied(_))));
}
Item 1 is about the file, and the file carries one published annotation, on the line that panics. The declined finding is not an annotation and does not appear; the pending ones never did.
What gonk adds
| your kernel | gonk | |
|---|---|---|
| dataset | in memory, in_memory_shared_declaring | RocksDB at ~/.ikigai/store, open_shared_declaring |
| model | the stub | a peer mounted at urn:llm: (--mount) |
| who is asking | a Capability the code passes | the door: socket owner, loopback or passkey, a QUIC certificate’s grant |
| cache freshness of a file read | browse declares reads live | a watcher on each root cuts the threads, so reads are cached |
| pages | none | the browser face, the review queue |
| backup, provisioning, bridges | none | backup, client add, grants, checkout, kata import, roborev file |
Nothing in the left column is a reduced version of the right. It is the same three modules with the same resources and the same capabilities; gonk is what you add when other processes, other machines and people with browsers need to reach them.
The resource map, as built
- Sets: ledgers and their items; roots and their files; findings; annotations.
- Atoms that hold state: one dataset (the ledger’s graph and browse’s graph), the repository’s files, and the model as an input.
- Compositions: every ledger action, explain and review, every finding decision, and the store’s query doors where the graphs meet.
- Views:
next, listings,explain-status,findings,annotations, and anySELECTover the union a caller holds tokens for. - Code written: one function that hands one dataset to two families with a promise, and three lists of capability tokens. No endpoint.
The ikigai Book
gonk is built from the pieces The ikigai Book teaches: resources named by URIs and resolved by a kernel, spaces composed into a host, capabilities that are declared and enforced, golden threads that say what a write invalidates, and the transports that make a kernel reachable from another process or another machine. This book uses those ideas without re-teaching them; that book is where they are taught, from the first endpoint up.
The two books are built, tested and published together, from one repository: https://github.com/ikigai-rs/ikigai-tutorial.