Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 mcp projects 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 an ikigai-markdown mapping, a change’s tasks become ledger items and next ranks 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.

kindlooks likechecked by
Runnable in the pagea cell with a Run buttonthe 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 examplea rust block, or a listing included from crates/mdbook test compiles and runs every one against the crates under crates/
A shell transcripta console block of $ lines and their outputscripts/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

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:

  1. in this page, where a kernel compiled to wasm holds it and every Run is real;
  2. in a kernel of your own, a few lines of Rust over two published modules; and
  3. 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 pageyour kernelgonk
storein memory, per page loadin memory, per processRocksDB, in the data home (~/.ikigai/store by default)
clockthe story clockthe story clock, or yoursthe system’s
callerrootroot, or a grantper door: loopback HTTP holds default’s read and write
reached byRunaskHTTP, 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>
pathwhat 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
/sparqla 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:

  1. A panel, Create a passkey for this server, asks for a label (laptop Touch ID, say).
  2. 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.
  3. 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: by curl as itself, as an image another page loads, and as another page’s link) was urn:iki:gonk:page:item:default:1; the form was urn:iki:gonk:act.
  • principal is who the door could say was asking. The form’s line says anon, 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.
  • outcome is the kernel’s, not HTTP’s. A refusal the resource makes (the /l/book page, below) is a line with outcome=denied. A refusal at the edge, before the kernel (the cross-site form, the foreign Host, 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 second act line 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 loopbacksigned in as ada
defaultread, writeread, write (anonymous’s grant, plus her own)
bookthe sign-in noticeread, write
delete, purgerefusedrefused: not in her grant
the repositoriesnoneread, once a root is configured
a write’s authornoneher 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 book reached gonk and was refused there, so it has a line. The delete has none: the declared family urn: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 gonkunder what authoritywho the log says
a browserHTTP, loopbackanonymous, or a passkey’s grantanon, or the passkey
curl, a hookHTTP, loopbackanonymousanon
ikigai on this machinethe socketthe owner’s, narrowed with cap sealowner

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 author and 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 author argument, 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 author that 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 mcp refuses 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 takes ledger as 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 gonkunder what authoritywho the log says
a browserHTTP, loopbackanonymous, or a passkey’s grantanon, or the passkey
curl, a hookHTTP, loopbackanonymousanon
ikigai on this machinethe socketthe owner’s, narrowed with cap sealowner
an agent over MCPQUIC, with its own certificateits client’s grant on the server, and its own ceiling on the clientthe 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-browse declares 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’s ikigai 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 1 is 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:

findinglineproposedthe claim
urn:iki:finding:bd771489c5a22c9a4d45f4dd3majorthe slice panics on an empty word
urn:iki:finding:1d920b0266c30789b5c61b082minorreturn 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:

reasonmeans
misreadthe claim is false: the model misunderstood the code or the domain
restatesthe code already says it: a comment that discloses a hazard, restated as a finding
no-issuetrue, but not a defect: style, “could be clearer”, an absence asserted as a problem
wont-fixreal, and deliberately left as it is
duplicatealready 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 bugstokensserious per 100k tokens
led (follow the reviewer)6756,3570.79
unled (audit blind)191,128,5831.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:

armwhat it didserious per 100k tokens
Rper-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
Uan unled audit, as in round 11.49
U2a second, independent unled audit, same protocol1.99
Dan unled audit that starts from the window’s commits, then follows the code1.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 = true in 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 handler file 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: a handler file retargeted at any other resource gets the request refused, dead-lettered into the space’s error/ 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:

  1. 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.
  2. A mapping, which is a resource, not code: a small lift profile plus SPARQL CONSTRUCT queries 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 low files them; high files 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-58 is under --repo-path, so it is made relative and named as the browse resource for that file, urn:repo:demo:file:src/retry.rs, where demo is 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 file about rather 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’s key and again as an about, 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:

katain the ledger
created_at, short_id, uidthe provenance line; the uid is also the item’s key and an about
a comment’s timeits closing note (kata comment <uid>, <created_at>)
ownerthe provenance line, not a claim
updated_at, metadata, who added a label or link and whennot carried
the event historynot carried; counted on the last line (event 1)
sync bindings, federation state, claims, purge logsnot 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 changeswhat brings it inrestart?
a repository upstreamikigai-gonk checkout --all, by hand or on a timerno: the watcher sees the fast-forward
a new repositoryikigai-gonk checkout <url> --write-configyes: roots are read at startup
kataikigai-gonk kata import <a new export>no
a roborev reviewthe hook, by itselfno

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.

doorreached byauthenticatesthe capability it grants
HTTPa browser, curl, on loopback onlythat 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 onethe anonymous grant (read and write of the ledgers gonk.http.ledger names, default unless told otherwise), plus a signed-in passkey’s grant
socketanother process of the same user (ikigai --mount)the socket file’s owner-only mode, and the peer’s user idroot (the owner can read the store’s files anyway), or what an owner process narrowed itself to with cap seal
QUICanother machine (ikigai --connect quic://…)a client certificate, pinned by fingerprint, over mutual TLS — no certificate authoritythe 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: anon on 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:

whodoorwhat they hold
the ownerthe socketeverything: root, as in Over the socket
bot, a drafting agentQUICread the ledger, draft from a sentence, save drafts in the team- namespace
reviewer, a second machineQUICread the ledger, read and run the team- scripts
worker, a remote workerQUICread, claim and comment on the ledger; run public scripts
anyone on this machineHTTP, signed outthe configured ledger, and every PUBLIC script, within a budget
ann, a personHTTP, signed in with a passkeywrite 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: read fetches a script’s source without running it, run runs it, and publish publishes, replaces and retires it. The name is ONE script (open-by-label) or a namespace, <namespace>-*: team-* covers team-report and team-a-report, never teammate or team. There is no “every script” grant below the owner. public is the one name that is not a script: --script public=run runs 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> (localhost for a socket). It is refused on a server with no gonk.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 … =publish covers.

⚠ 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 outHTTP, signed inQUIC (a machine)the socket (the owner)
run and read a PUBLIC scriptyes, within the shared budgetyeswith --script public=runyes
run and read a named scriptnoper --scriptper --scriptyes
publish, replace, retirenoper --script … =publishno: drafts onlyyes
see a draftnoits ownits ownevery draft
draft from a sentenceno, before any model callwith --nl draftwith --nl draftyes
what a run may readthe 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:

whodoorwhat they hold
the ownerthe socketeverything: root, as in Over the socket
alpha-agent, team alpha’s agentQUICwrite the alpha ledger; run the report- scripts
beta-agent, team beta’s agentQUICwrite the beta ledger; run the report- scripts
managerQUICread both ledgers and the repositories; run the report- scripts
alpha’s leadthe socket, sealed to the lead’s grantread alpha; publish report- scripts
anyone on this machineHTTP, signed outthe 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, close and 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 questionwhat answered itshown by
what is next for my team?urn:iki:ledger:{ledger}:next, under a grant naming the ledgereach 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 namesone 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 textthe manager’s join, and alpha’s two tries
can the teams’ work be joined with the review?yes, for a caller holding all three graphsthe review join
who may run a published report?whoever holds both its run grant and the graphs it readsreport-alpha-open, three runners
may a report read more than its publisher?no, refused at publishreport-everyone
what does a signed-out caller see?the anonymous ledgers, and a public report only over themthe HTTP door
who sees a deleted item?a caller holding the graveyard’s read token (purge), or rootthe counts per graph
what is an agent offered?the manifold under its grantthe 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

rolewhohow it reaches gonk
the dispatcheryouthe socket, as the owner
the workeryou, or an agent you startedthe socket, as the owner, with its own holder name
the model (optional)a local model gonk mounts, for explanations and one-off reviewgonk 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 next rather 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 next until 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

rolewhoholds
the dispatchera person (or a coordinating agent) on the gonk machinethe owner’s socket: everything, including close
a workera machine running an agent, unattendeda certificate, and the ledger’s worker role: read, claim, comment
the forgeGitHub, saythe 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-expired rather than a silently stuck item.
  • The hand-back state, which keeps finished work out of next until 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

rolewhoholds
the authorwhoever commits: a person, or an agentnothing of the review’s; their hook drops a request, which spends nothing and needs no grant
the reviewera model, run by gonk on each request, unattendedread every browse root, the browse graph, the model’s host; never urn:cap:annotate
the judgethe same mount, asked once more for each serious findingthe reviewer’s grant: it runs inside the pass
the triagera person, at gonk’s Queue page or over the socketurn:cap:annotate, the one token that publishes
whoever fixes itthe ordinary loop on a ledger itemthe 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=yes records 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

rolewhodoes
the personwhoever owns the goalsets it; answers the hub when it asks before merging its own work
the hubone coordinating session (an agent, or a person)splits, briefs, dispatches, checks evidence, closes
a spokeone agent per arc, in its own working treeclaims one arc, does it, self-merges on green, hands back
CIthe forgethe 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 blocks link 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 conflict naming the holder; a spoke that dies stops renewing and shows up as lease-expired rather than as an arc quietly never finished.
  • next over 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 next until 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

rolewhodoes
the proposera person, or an agent asked to draftwrites the change: proposal, delta spec, tasks
the approvera personmerges the change’s pull request, which is the agreement to build it
the seedera host or a script, over the socketfiles one item per open task, keyed by the task, in order
a workeran agent or a persontakes the next task, implements it, checks its box in the same pull request
whoever archivesthe proposer, usuallymoves 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.
  • blocks and next, 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 (What about buys).
  • 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

rolewhodoes
the coordinatora person, or a hubpicks the repository, pins the commit, writes the scope and budget, merges the reports, files the findings
auditor A, auditor Btwo agents (or people), independentread, reproduce, report; never fix, never see each other’s report
whoever fixesthe ordinary loop, laterone 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

rolewhodoes
the person who steerswhoever owns the processreads the coverage answers; turns a recurring failure into a new or better control
the agentany agent working in the repositoryreads the guides; trips the sensors; is told why
the sensorsthe repository’s own tools and hooksrefuse or warn, and record one line each time
the dispatcherthe owner of the gonk the work lives inloads the harness into gonk and asks the coverage questions
gonkthe same gonk as the ledgerholds 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-required is not on the list, because step 3 fired it.
  • Sensors with no rule. reject-needs-reason refuses a rejection without a reason, and no section of AGENTS.md says 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 of AGENTS.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 hold firedBy to 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_declaring gives the store’s space its dataset and returns a second handle for browse, which takes an oxigraph store directly. A durable host does the same with open_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 SharerWrites the 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::graph is 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 kernelgonk
datasetin memory, in_memory_shared_declaringRocksDB at ~/.ikigai/store, open_shared_declaring
modelthe stuba peer mounted at urn:llm: (--mount)
who is askinga Capability the code passesthe door: socket owner, loopback or passkey, a QUIC certificate’s grant
cache freshness of a file readbrowse declares reads livea watcher on each root cuts the threads, so reads are cached
pagesnonethe browser face, the review queue
backup, provisioning, bridgesnonebackup, 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 any SELECT over 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.