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

A resource-oriented computing kernel in Rust, taught from the outside in.

What ikigai is, in one paragraph

In ikigai, data and computations are resources, each named by an IRI (Internationalized Resource Identifier). To resolve a name is to ask the kernel to route a request to an endpoint, code that handles it. The answer is a representation: bytes plus a media type, such as text/plain. Requests use five verbs: Source (read), Sink (write), Exists, Delete, and Meta (describe the resource). Transreptors convert one representation into another, such as text into HTML. Golden threads record a result’s dependencies so a change can invalidate cached answers that depend on it. Capabilities specify which requests a caller may make; a caller can pass narrower permissions to the resources it calls.

That is the whole model. The rest is consequences — and the first four of them, run rather than described, are What resolution buys you.

Why bother

Because naming a thing and resolving a thing are different acts, and separating them buys you a lot at once. If a computation has a name, it can be cached, traced, substituted, authorized, converted, and moved to another machine without its caller knowing. An ordinary function call does not provide those services on its own.

The wager of this project is that a system where every step is a resolvable name is cheaper to reason about at scale than one where the steps are opaque calls — and that this matters most now that programs are being assembled by agents, which need exactly what resolution gives you: a machine-readable catalog of what can be done, an enforceable boundary on what may be done, and provenance for what was done.

What is in here

The front door needs no Rust: twenty minutes with the ikigai CLI that show the model in use: list available resources, pass results between them, change formats, reuse and invalidate cached answers, and trace a request. The REPL grammar then explains the command syntax. A REPL is an interactive prompt that reads a command, runs it, prints its result, and waits for another. Run buttons let you try commands where the page’s own kernel can answer.

Part I — Getting started builds an endpoint and links it into a kernel you compose yourself. It is the 95% case, and everything else assumes it.

Building endpoints is the rest of the builder’s toolkit, one chapter per move: a transreptor of your own and the kernel finding it, an endpoint with three verbs and a capability that is declared and enforced, the catalog queried as a graph, and an endpoint that reads the clock tested without one.

Part II — Loadable modules covers the other shape: a space() compiled separately and routed to at runtime, and the callback that makes a module something quite different from a remote peer.

Part III — Beyond one host takes the step Part II stops at. A kernel behind a socket, the authority a certificate mints when the socket becomes a network, the three honest things a mount can mean by “resolve this over there” — and then two clients, one human and one machine, that build their command surface by reading the catalog rather than being told.

Applied builds one small application end to end, the way resource-oriented design says to: name the resources first, write code only for what has to hold something, and compose the rest. The application is tic-tac-toe, in seven parts numbered on their own, I to VII. They take the game from a model on paper to atoms, lines, rules, many games at once, a board you can play in the page, the same game held and served from Python and TypeScript, and finally its arrangement written down as data, one file the kernel builds the game from. Then Time is a resource takes the first step past composition: an answer that goes out of date with nobody writing, a clock the host gives the kernel, and a page that polls it. A spreadsheet, in seven more parts, builds something a little bigger the same way: cells as atoms, a formula as code that is itself a resource, compiled by a transreptor, recalculation that is nothing but golden threads, cells that read the clock, a feed that something outside the sheet writes, the sheet as a space (a person’s scenario and the sheet as it was, each a corridor, stacked), and finally push: the page is told when a value changes instead of asking.

Polyglot tracks mirror Part I chapter for chapter from Python and from TypeScript — a decorated function served on a socket, the signature as the contract, a mount as a binding, the kernel’s cache and trace seen from outside, a scoped connect refused — and close with a notebook that queries the catalog as a graph. They say plainly what an L0 peer cannot do: compose.

Read the front door if you have not run ikigai before, then Part I — or at least Resolution and Binding. Building endpoints assumes Part I and nothing else; Part II assumes you know what a space() is and why binding is separate from defining; Part III assumes both, and leans hardest on capabilities. The tic-tac-toe parts assume Part I, and its last part assumes the polyglot tracks as well. The spreadsheet assumes the tic-tac-toe parts, and its fourth part assumes Time is a resource.

How to read this book

The published copy is at https://ikigai-rs.github.io/ikigai-tutorial/, rebuilt from main on every merge.

The book changes often. What’s new lists each change, most recent first, with the pages to read for it.

The library lists the companion books, with who each is for and where to start.

mdbook test compiles and runs the runnable Rust examples. It skips fragments marked ignore; the longer code listings are included from the crates that compile them rather than copied. A book that paraphrases its own examples is a book that will eventually be wrong about them.

# read it (mdbook serve never builds the in-page kernel, so Run is disabled there)
mdbook serve books/ikigai --open

# read it with runnable cells: builds the kernel, serves on 127.0.0.1:3000
./scripts/serve-with-drafts.sh

# check that it still tells the truth
./scripts/test-books.sh

The code lives in crates/ — one crate per part, plus crates that build and check the books.

Conventions

Shell commands assume you are at the root of the ikigai-tutorial repository. Anything marked ⚠ is a trap that has actually caught somebody.

A note from a human

This tutorial is obviously generated using agentic tools, based on their understanding of the larger project. I know this may be off-putting to some readers, but it is simultaneously a convenience and a reality that allowed me to get a reasonably comprehensive overview of the ikigai project out the door. I will be revisiting the documentation and tutorial over time with an increasingly human touch. For now I beg your patience.

An acknowledgement of the highest order

ikigai represents a vision I have had for 10 to 15 years, based predominantly on a software ecosystem I have been using for 23 years at this point: NetKernel. I was exposed to it first in 2003 and had no idea what it meant or was good for. A year later I migrated an XML processing pipeline to it in a week and picked up linear scalability in the process with virtually no effort.

NetKernel is the brainchild of 1060 Research and I owe them entire credit for the ideas, terminology, and vision. Peter Rodgers and Tony Butterfield have produced an amazing body of work. Basically what I have done is take those ideas and expand their applicability beyond the JVM (and back onto it; more on that soon) across a dramatically wider footprint, with some personal design tweaks and additions.

What’s the point?

As you go through this tutorial, I expect a common reaction will be puzzlement and confusion about what the purpose is. That’s a fair assessment, but I guarantee you there is a point, and it will be revealed in practice over time. Things are moving very quickly and ikigai is not really ready for a general audience, but enough people were asking about it that I thought it was time to start discussing it.

The purpose of the project is to benefit from the incomparable compositional power of resource-oriented design. You can think of it a bit like the Web meets Unix pipes and filters, but that is simply a reductive convenience.

NetKernel was clearly ahead of its time and so many things about where the industry has gone have only started to approximate what it has been able to do for decades. I’ve added capability-based security, standards-based linked data bones, architectural flexibility, freedom from the JVM, browser residency, and more. But I have no illusions that I am doing anything but extending what has already been laid down.

Why?

These ideas are transformative when they are embraced. I have had both success and failure in trying to get developers to think this way. I’m trying to address some of the onboarding impedance (which you may not believe from this tutorial, but with patience I hope it’ll become clearer and more true).

These ideas are also of this time. I haven’t been designing these things with AI in mind, but the synergy is natural and compelling. There are several ways LLMs are invoked from ikigai, but only when they need to be. This, too, is part of the story I hope to continue to unveil in the coming weeks.

Rust

ikigai is built in Rust for all of the reasons you would think. It is safe, fast, compiles to WebAssembly, well-supported by the crates ecosystem, and more. If you are unfamiliar with Rust, don’t be put off. The goal is that you will be able to benefit from ikigai under a variety of deployments. A lot of that is still to be built, but you can start to get a sense of using Python or TypeScript as either the source of resources or a consumer of the ikigai engine. There will be other wrappers for other languages, WebAssembly-based jails with WASI, several scripting languages, and more. There’s already support for Scheme as an embedded language. It can be digitally-signed, transrepted into RDF, queried, and other things that are likely to excite and blow your mind. There is also an emacs REPL that includes generated elisp aliases for the ikigai functionality your host projects to use within that environment. Additional Lisp variants and modalities.

Next steps

For now, if the ideas intrigue you, spend some time thinking through the concepts and I’ll continue unveiling the bigger picture in time. If you’d like an introductory session, feel free to request a chat through the “Request time” section and I’ll try to accommodate. (As a teaser, you’ll be using ikigai in the process of doing so.)

Proceed with an open mind. Cool things are coming.

Regards,
Brian Sletten

What’s new

What has changed in The ikigai Book, most recent first: the date, what was added or changed, and the pages to read for it. Smaller upkeep (moving to a newer release when nothing a reader does changes, a corrected link, a typo) is not listed.

2026-10

  • 2026-10-31: A third streams page, “Streams, III: a body read once, and its bound”: a one-shot stream is never cached, is collected under the run’s byte budget and refused with OverBudget past it, never truncated. Its cell decodes the shop’s stock with urn:stream:text and sees gzip’s bytes refused; its listings decode a stream chunk by chunk.

    Pages: Streams, III: a body read once, and its bound, Streams, II: a body you can read again

  • 2026-10-31: The first REPL instructions now recommend ikigai --plain for screen-reader users and link to the CLI’s accessibility notes.

    Pages: Twenty minutes with the CLI

  • 2026-10-30: New in “Exploring without Rust”: “When a rule is not enough”. A Lisp program, kept as a file, counts earthquakes by region over the weeks a file lists, is cached and recomputes when the list changes; then a small Python peer, mounted into the CLI, measures distances from Tokyo, and the page says what a second process changes.

    Pages: When a rule is not enough, A week of earthquakes

  • 2026-10-30: “A river of feeds” now runs in the page. Its directory, its two feed lifts, its SPARQL questions (the spliced term that breaks a query and the bound one that cannot) and the four faces of one answer are Run cells, over the example’s own files bound at the names its REPL gives them, with the committed river at urn:file:river.nt. The stand-in origin, the river’s build, the watcher and the saved searches stay REPL transcripts, and the page says why. The REPL grammar’s list and the browser appendix list the new names.

    Pages: A river of feeds, The REPL grammar, Appendix: the kernel in the browser

  • 2026-10-30: The spreadsheet chapters are now “Spreadsheet, I” to “Spreadsheet, VII”, like “Tic-tac-toe, I”, and the TypeScript track’s headings now say what each section shows, as the Python track’s do. The REPL grammar’s cache heading is now “Probing: cache”, and the live host’s second “Stage 3” now reads “Stage 3, unchanged”.

    Pages: Spreadsheet, I: cells are atoms, The TypeScript track, The REPL grammar, A space that changes while it runs, A river of feeds

  • 2026-10-29: Every exercise title now starts with what you do: “Write a strict lowerCamelCase”, “Add a second argument”, “Publish a query, and find the thread it hangs from”, “Script a model whose draft never validates” and “Add the style note ledger item 1242 left out, as a composition”, as the exercises in the other parts already did. “Tic-tac-toe, I” and the code exercises now carry the same title on the page as in the contents.

    Pages: Exercises: getting started, Exercises: code and language, Tic-tac-toe, I: the model and the atoms

  • 2026-10-29: New in “Exploring without Rust”, after the river: “Naming a composition”. The REPL’s bind gives a request with its arguments a name, kept in a names file beside the data (--names): the filled graph, the count, the strong layer and the notable earthquakes of both weeks are asked by name, with no copies in between. The page shows a fixed argument refused, as refused as a fixed argument, a name described by its target’s contract, a reference that recomputes when its question file changes, a cap read-only session that reads the names and may not change them, and a binding of a name the CLI already serves reported as shadowed. The river gets a named saved search, a name for every search term, and a polite live feed named by a template that captures a whole web address. It ends by saying what names now make plain in the chapters before it, and what is still not a name: the river’s build line. “A river of feeds” and “Process scripts are files” now point to it.

    Pages: Naming a composition, A river of feeds, Process scripts are files, A week of earthquakes

  • 2026-10-29: The book is now checked against ikigai-cli 0.2.6. “A river of feeds” shows revalidation as 0.2.6 prints it: a feed that sends only an ETag is downloaded once, the second read gets a 304 Not Modified and is served from the stored body, and the page says why both reads still count as computed. “Somebody else’s resource” was re-run on 0.2.6 against the live feed. The urn:kernel:threads readouts in the front door and “The payoff” show the three threads 0.2.6’s names layer cuts as it starts, and the pages that said named requests were not yet built now say that 0.2.6’s bind has them, which those pages do not use yet. The polyglot tracks are replayed against 0.2.6 too: list now ends with the names layer’s two names, and the verdicts are unchanged.

    Pages: The Python track, The TypeScript track, A river of feeds, Somebody else’s resource, Twenty minutes with the CLI, What resolution buys you, Process scripts are files, Reshaping JSON with jq, Giving the data names, Composites, one layer at a time, A week of earthquakes, The file workspace, The aliases, day to day, Where this actually stands

  • 2026-10-29: The polyglot tracks’ binding steps run again, and are now replayed against the book’s pinned cli with their Python and Deno peers started from the pages’ own lines. The TypeScript track’s peer starts (an import map beside the listings, examples/deno/deno.json, supplies the @std/crypto ikigai-deno now needs), each listing waits for its peer’s socket before mounting it, and the binding pipe goes Rust into TypeScript into Rust with a visible effect at each stage: shout, in both tracks, now takes a list and shouts each line, and the list comes back reversed. Both tracks now show the verdicts ikigai-cli 0.2.5 prints: it caches nothing a mounted peer answers, and the pages say so, with urn:kernel:uncached’s reason, rather than claiming the host honors the peer’s cacheable.

    Pages: The TypeScript track, The Python track, One wire, three languages

  • 2026-10-29: New in “Exploring without Rust”, after the composites: “Process scripts are files”. The composites’ copies are refreshed by a file of REPL lines replayed with ikigai --quiet <, and a replay with a failing line shows what ikigai-cli 0.2.5 does: it goes on to the next line and exits 1 at the end. A pipeline is stored as a plan, executed and executed again from the cache; urn:plan:validate and urn:plan:requires check a plan and name the authority it needs before it runs, and a session narrowed to another host is refused at the step, naming USGS’s. Then a jq query writes a script that the reader replays, and the page says what plans and .iki files do not have yet. Every shell step on the page is run against the book’s pinned cli.

    Pages: Process scripts are files, Composites, one layer at a time, A week of earthquakes

  • 2026-10-29: New in “Exploring without Rust”: “Composites, one layer at a time”. Answers built from answers: a SPARQL filter whose answer is a graph at a name, then a sort, a grouping and a join with a second recorded week that read it by that name, all as Run cells. The page asks what hangs from the context and from a question, and cuts one to see what recomputes. At a shell, your own notes, a file per earthquake, join the two weeks through a row template; and joining the live feed into a cached composite shows its lifetime fall to the feed’s minute, and where to put volatile data instead.

    Pages: Composites, one layer at a time, A week of earthquakes, Giving the data names, The REPL grammar, Appendix: the kernel in the browser

  • 2026-10-29: A new page in the Exploring part, “A river of feeds”: a directory of RSS feeds becomes a list of names, XSLT lifts each feed to RDF, and the feeds become one river you query with SPARQL kept in files, with only the feed you changed recomputed. It reads feeds politely (max_age= and a User-Agent, shown against a stand-in origin on your own machine, never a real person’s site, with what 0.2.6’s revalidation adds), binds a search term with bindings= so a quote in it stays text, and writes one answer as a page, a digest, a feed and an OPML. Its REPL steps run over the repository’s examples/rss and are replayed against the book’s pinned cli.

    Pages: A river of feeds, A week of earthquakes

  • 2026-10-29: New in “Exploring without Rust”: “Giving the data names”. A JSON-LD context gives the earthquake feed’s keys published meanings, in a small vocabulary of the book’s own at ikigai-rs.dev/ns/quakes, while each earthquake keeps USGS’s own event page as its name. The page’s kernel now runs SPARQL, so the Run cells ask the recorded week as a graph how many earthquakes it holds, which was largest and how each magnitude type counts, with each question a resource; the shell steps edit the context and watch an answer change.

    Pages: Giving the data names, A week of earthquakes, Reshaping JSON with jq, The REPL grammar, Appendix: the kernel in the browser

  • 2026-10-29: A second streams page, “Streams, II: a body you can read again”: a body is a buffer, something that opens a fresh reader every time (Replay), or a one-shot stream, and the kernel caches the first two and never the third. Its cells read byte ranges of the shop’s stock through urn:stream:range in the page, and see a write cut them; its listings write a Replay of their own and read a file through ikigai-fs as a seekable body, which refuses rather than tear when the file changes under a reader. “Streams, I” now ends by pointing to it.

    Pages: Streams, II: a body you can read again, Streams, I: bytes are a representation

  • 2026-10-29: The book’s REPL transcripts are now replayed against ikigai-cli 0.2.5, and the Exploring part’s shell steps over the recorded week are replayed transcripts too, as are the grammar’s named-argument, refusal and composition examples. “Reshaping JSON with jq” queries the .geojson file directly (0.2.5 reads it as JSON, so the .json copy is gone) and writes its jq program with sink; “A lab with names in it” shows the text tools’ 0.2.5 tallies and why their answers are not cached; the CLI tour’s cache readout includes the CLI’s own clock; and “What resolution buys you” says why the thread list can name a temporary file.

    Pages: A week of earthquakes, A lab with names in it, Reshaping JSON with jq, The REPL grammar, Twenty minutes with the CLI, What resolution buys you, The file workspace, Where this actually stands, The aliases, day to day

  • 2026-10-28: “Somebody else’s resource” now says why its live steps run in your REPL rather than in the page: the page could fetch the feed, but every Run cell is checked against a fixed answer, and a feed that changes every minute never matches.

    Pages: Somebody else’s resource

  • 2026-10-28: The CLI tour now explains the Rust/Cargo prerequisite and separates installation time from the walkthrough. The grammar clarifies which requests its tally counts, why a cache probe can cache a description, and what fan-out width reports.

    Pages: Twenty minutes with the CLI, The REPL grammar

  • 2026-10-28: The introduction defines the kernel’s basic terms before using them, points to the companion books without a stale count, and distinguishes runnable Rust examples from ignored fragments. The front door names Cargo as an installation prerequisite and defines REPL, RDF, and capability; the grammar explains its syntax markers and acknowledges a REPL still open from the CLI chapter. The CLI chapter also includes the uncacheable status printed after cache and thread listings in CLI 0.2.4 and no longer understates the catalog’s length.

    Pages: Introduction, Twenty minutes with the CLI, The REPL grammar

  • 2026-10-28: A new part, “Exploring without Rust”, after the front door: no endpoints to write, only the ikigai command, the Run buttons and files, over a week of USGS earthquakes. “A week of earthquakes” introduces it; “A lab with names in it” asks the recorded week questions with a pipe, a map and a fork, and traces, caches and cuts them; “Somebody else’s resource” reads the live feed through urn:http:get, keeps it for USGS’s sixty seconds, cuts the URL’s thread and narrows the session to one host; “Reshaping JSON with jq” asks sharper questions with urn:json:query and fills a headline template with $j. The page’s kernel now also holds that headline template, which the list in “The REPL grammar” shows.

    Pages: A week of earthquakes, A lab with names in it, Somebody else’s resource, Reshaping JSON with jq, The REPL grammar

  • 2026-10-28: The page’s kernel now holds one recorded file: a week of USGS earthquakes (178 events of M4.5 and up, 2024-01-01 to 2024-01-08) at urn:file:quakes-2024-01-01.geojson, the same name the CLI’s file workspace gives a copy you save there. “The kernel in the browser” shows it in a new section, “One recorded file”, where jq counts its events, and the list in “The REPL grammar” ends with it.

    Pages: Appendix: the kernel in the browser, The REPL grammar

  • 2026-10-28: The two front-door chapters now distinguish shell commands, REPL input, and expected output. Pipe examples visibly reverse a list, and the grammar explains named arguments, newlines, example namespaces, and fan-out reporting. Composition now shows the template before its result and passes a caller-supplied greeting to compose by name, and a misspelled argument name is shown as the error it is since CLI 0.2.4.

    Pages: Twenty minutes with the CLI, The REPL grammar

  • 2026-10-28: Four pages now say what the streams releases changed. “The REPL grammar” says when a pipe hands the next stage a body instead of a value (ikigai-engine 0.2.2), and that a stored plan still pipes by value. “Golden threads in practice” and “Twenty minutes, nothing compiled” say what the cache bound counts now: a one-shot body is never stored, a spilled one counts its full length, and a hit shares its bytes. “The file workspace” ends with files as bodies (ikigai-fs 0.2.3): seekable reads, a validator for ETag and ranges, sinks that stream, and the 64 MiB write ceiling per root.

    Pages: The REPL grammar, Golden threads in practice, Twenty minutes with the CLI, The file workspace

  • 2026-10-27: The book now builds against ikigai-core 0.2, the streams release, and every module it links moved with it. In “Tic-tac-toe, V”, a game over a store in another process now caches nothing in the host: each read asks the store again, so a mark another client writes there is on the next board, and the store contract is run against the store’s own answers. Status names core 0.2.2 and ikigai-module 0.5.1 and says what 0.2.0 changed. A plain trace no longer prints defaulted=; “A space that changes while it runs” asks for it with trace --defaults. The list in “The REPL grammar” shows the toolbox’s new urn:toolbox:path:{rest}, and the grounding in “A sentence becomes a draft” counts 252 vocabulary terms.

    Pages: Tic-tac-toe, V: a board you can play, Where this actually stands, A space that changes while it runs, The REPL grammar, A sentence becomes a draft

  • 2026-10-27: The other books now lists Running ikigai, a fifth book for operators: the ikigai binary and what it takes to run kernels for real.

    Pages: The other books

  • 2026-10-26: In “A space that changes while it runs”, “Compare with” is now one list, above Before and After, instead of a second row of a dozen buttons under the tabs that was hard to tell from them on a phone. It offers the stages before the open tab, a stage of your own among them, and starts at the stage just before; opening a tab puts it back there. When a tab’s step has moved nothing yet, After now says so plainly: the stage your kernel is still at, and that the tab’s step has moved nothing yet.

    Pages: A space that changes while it runs

  • 2026-10-26: The stages of “A space that changes while it runs” now hold still: below the tabs, the open stage’s picture sits beside the stage before it, Before and After, with what changed from one to the other in words. “Compare with” puts any earlier stage in Before. Each picture is the one your kernel drew if that tab’s step ran, or the chapter’s own, said so, in a dashed frame. On a phone the two stack; long commands and answers scroll sideways instead of wrapping, and on a wide screen the page widens so the longest command fits on one line. The arrow keys on the tabs now move between tabs only; they used to open the next or previous chapter as well.

    Pages: A space that changes while it runs

  • 2026-10-25: A new part on streams begins with “Streams, I: bytes are a representation”: a representation is a media type and bytes, and binary is as ordinary as text. Its cells gzip the shop’s stock and read it back in this page’s kernel, which now links ikigai-compress, and show the decompression bound refusing rather than truncating. It ends with what a representation cannot do yet, as one whole buffer, which the pages that follow take up. The list in “The REPL grammar” shows the four new gzip and zlib names.

    Pages: Streams, I: bytes are a representation, The REPL grammar

  • 2026-10-25: In “A space that changes while it runs”, the whole host drawn above the tabs now follows your kernel: every step redraws it (from the cache when the step cut nothing), so it is always the kernel as it is now. Going back to a tab you have passed shows that stage again, not your kernel’s later state. The book now builds against ikigai-core 0.1.112: Status says what 0.1.111 and 0.1.112 changed, a trace shows a face the caller left out as defaulted, and the grounding in “A sentence becomes a draft” counts the larger vocabulary.

    Pages: Where this actually stands, A space that changes while it runs, A sentence becomes a draft

  • 2026-10-25: The stages of “A space that changes while it runs” now work as one box: each tab holds its step, the cell to run and what it answers, scrolling in place, and one picture of the space sits right below the tabs. Before you run a tab the picture is your host as it is now; run it and watch the picture change, with what changed marked. Switching back to a tab shows what its step drew, from the cache, and the chapter explains why no picture is drawn twice. The whole host is drawn above the tabs, and the Rust listings for every stage now follow them. A new page, The other books, links the gonk Book, Building ikigai UIs, The ikigai Architecture and the library; “Where to go next” now includes the Architecture book and the library too.

    Pages: A space that changes while it runs, The other books, Where to go next

  • 2026-10-25: In “A space that changes while it runs”, the picture of the space now appears right under the Run button of the cell you just ran, drawn by your host after that cell, with what changed. A row of stage buttons above it shows any stage’s picture in the same place, so you can compare two stages without scrolling.

    Pages: A space that changes while it runs

  • 2026-10-25: A space that changes while it runs now shows its dynamic space in four numbered stages, one tab each: empty, four doors, three doors, and a view. Each tab draws the space as it was (light and dark), says what changed since the stage before in words, and keeps the cells that made it; a refused change makes no stage. The host gains urn:iki:tutorial:live:arrangement, the space in words, and the whole host is drawn once, before anything is declared.

    Pages: A space that changes while it runs

  • 2026-10-24: A new appendix, The kernel in the browser, shows how the page builds the kernel behind every Run button, compares what the browser gives a kernel with what a native host gives one, and explains why a crate takes its clock and timers from its host. It covers the bridge that lets the kernel await browser promises, and what fails at run time rather than at build time. Its two Run cells show a page with no clock.

    Pages: Appendix: the kernel in the browser

  • 2026-10-23: A new applied chapter, Short names in a space, declares a prefix table (json: and shop:) as data in a dynamic space carried as a corridor, composes a shop’s CSV stock into a one-line summary with $j markers inside it, lists the stock with urn:iki:fn:each, adds a prefix while it runs, and has a table under a reserved scheme refused. Its seven Run cells run in the page.

    Pages: Short names in a space, A space that changes while it runs

  • 2026-10-23: Transreption has a new section, A format that is not RDF: a CSV table read as JSON through ikigai-json’s text/csv transreptor and selected with jq (urn:json:query), cached, and recomputed when a pipe through urn:json:to-csv writes the table back. Its three Run cells run in the page, whose kernel now links ikigai-json: that grows the page’s kernel from 1.22 MB to 1.91 MB gzipped.

    Pages: Transreption

  • 2026-10-23: Tic-tac-toe, V’s template language now names the two shorthands ikigai-fn 0.6 adds: $j{<src> <program>}, which is $h of a urn:json:query selection, and urn:iki:fn:each, the collection case, with a listing that fills both spellings of one $j to the same text.

    Pages: Tic-tac-toe, V: a board you can play

  • 2026-10-23: Datasets and who may read them shows ikigai-store 0.2.25: a scoped update whose WITH names another graph is now refused before it runs, as an invalid argument naming the graph, where one of the two such updates used to succeed with +0 -0. The same move adds one store action alice may call, so the grounding in A sentence becomes a draft counts 27 actions for her.

    Pages: A query’s authority is its dataset, A sentence becomes a draft

  • 2026-10-23: The introduction links the library, a new page listing all three books with who each is for, what to read first and where to start.

    Pages: Introduction

  • 2026-10-22: Where to go next points to the third book, Building ikigai UIs, which turns the cache, golden threads and arrangement this book prints as text into live pages.

    Pages: Where to go next

  • 2026-10-22: Every Run now says, beside its cache verdict, whether it printed what the chapter shows (“as the chapter shows”) or not (“differs from the chapter”), so a refusal the chapter means to show no longer reads as a broken cell. A run that differs while cells above it have not run says so, and the cells of a game, a scenario or the live host in A space that changes while it runs carry a Run all above button that runs them first. The expected outputs of the fork and list cells in The REPL grammar, and of the trace in What resolution buys you, are now what the page prints when its cells run in order.

    Pages: A space that changes while it runs, The REPL grammar, What resolution buys you

  • 2026-10-22: In A space that changes while it runs, the view is now written as an s-expression, (exact “urn:iki:tutorial:live:picture” “urn:diagram:kernel” :resolve-at root), shown beside the Turtle it reads as, and the page’s Run cell writes it that way. ikigai-sexpr 0.2.4 keeps a view’s resolve-at-root through the round trip, so Views are Turtle only is gone from the chapter’s known limits.

    Pages: A space that changes while it runs

  • 2026-10-22: Checking a resource now runs in the page. Three Run cells read a player’s Turtle face with and without the scope, check both players against the closed shapes, and show a narrowed caller refused before any shapes are read. Two buttons open the RDF Toolbox itself in place, composed by the page’s own kernel from a toolbox build published with the book; its 8.3 MB engine loads only when you press one. Without the kernel, each is a link that opens the toolbox in a new tab on published copies of the Turtle faces.

    Pages: Checking a resource

  • 2026-10-22: All five steps of A space that changes while it runs now run in the page. Two new Run cells follow the first three in the same live host: a declaration naming an endpoint the host never registered, refused while the old doors keep answering from the cache, and a view onto the host’s picture, which a caller with a narrowed capability is refused through in the same words as directly. The picture is redrawn after each.

    Pages: A space that changes while it runs

  • 2026-10-22: A space that changes while it runs now runs in the page. Its first three steps are Run cells in a live host of their own: the empty space and its status, a write that adds four doors, and a door removed along with its cached answer. The picture of the host is redrawn after each one, with a description that changes with it. The chapter also states two more known limits: a rebuild loop under a narrowed capability can miss a write, and the status door is not in the picture.

    Pages: A space that changes while it runs

  • 2026-10-22: A new chapter in Building endpoints, Checking a resource, follows The graph face. Any resource with a Turtle face can be checked: the RDF Toolbox reads it under your own capability, and closed SHACL shapes turn a property nobody declared into a finding. It ends on how a check differs from urn:kernel:validate, which checks a request before it runs. The repositories page now lists ikigai-toolbox.

    Pages: Checking a resource, The repositories

  • 2026-10-22: A new chapter in Applied, A space that changes while it runs, follows Tic-tac-toe VII. It names an empty dynamic space, adds four endpoints the book already has by writing a declaration, and watches the space’s picture redraw. A removed door takes its cached answers with it, a refused change keeps the old doors, and a view gives another name to something the host serves without granting anything. A spreadsheet, VII adds the session space beside it, and Where this actually stands now names ikigai-core 0.1.110.

    Pages: A space that changes while it runs, Spreadsheet, VII: push, Where this actually stands

  • 2026-10-21: Contribute a module now explains declared failure classes, the RECOMPUTE check added in conformance 0.9 and the opt-in FAILURES check in 0.10. The your-endpoints crate runs the suite for word-count, and the page explains why a 0.10.0 pin does not receive 0.11’s new default.

    Pages: Contribute a module

  • 2026-10-21: The install notes now name ikigai-cli 0.1.68 as the first release fixing the TLS advisory. The second getting-started exercise also checks that an explicitly empty argument overrides its declared default.

    Pages: Twenty minutes with the CLI, Running it, Exercises: getting started

  • 2026-10-21: A correction: since ikigai-core 0.1.105 the kernel fills a declared default in before your endpoint runs, and a description that marks a defaulted input required is refused. The second getting-started exercise and Contribute a module said the opposite; the exercise now shows it in a block that runs on every build.

    Pages: Exercises: getting started, Contribute a module

  • 2026-10-21: The book now builds against ikigai-core 0.1.109. Where this actually stands names what 0.1.105, 0.1.108 and 0.1.109 changed (defaults filled in, failure classes, a run’s deadline, dynamic spaces), Preferential resolution says a prefer-mount now also falls through when the peer says to retry later (ikigai-throttle 0.2.0), Who is asking says the handshake offers a range of wire versions, and the install steps say a release fixing a TLS advisory is coming. A sentence becomes a draft now counts 229 vocabulary terms in the grounding, where it counted 199.

    Pages: Where this actually stands, Preferential resolution, Who is asking, Twenty minutes with the CLI, Running it, A sentence becomes a draft

  • 2026-10-19: The book now builds against ikigai-core 0.1.108, which a newer ikigai-browse requires; Where this actually stands says so, and that every listing and cell runs unchanged on the five kernel releases since 0.1.103.

    Pages: Where this actually stands

  • 2026-10-18: Notes from a first run on WSL2: Twenty minutes, nothing compiled says the default CLI build has no QUIC and when to add –features quic, the REPL grammar says which cells only this page’s kernel can run and why its list prints more than the expected eleven names, the introduction links the front door, and the getting-started exercises name three things their hints left out (ArgSpec’s .default field, ArgRef::Content behind the catch-all arm, and the clock your-endpoints’ kernel does not have).

    Pages: Twenty minutes with the CLI, The REPL grammar, Introduction, Running it, Exercises: getting started

  • 2026-10-17: The shell examples are now replayed against ikigai-cli 0.1.64. The aliases page says its two HTTP rows are the pre-0.2 spelling recorded on 0.1.41, and names the current urn:http:head and urn:http:delete.

    Pages: The aliases, day to day, Where this actually stands

  • 2026-10-17: The version stamps agree: the kernel-since-the-stamp section now says the chapters build against ikigai-core 0.1.103 and that its list stops at 0.1.95, and the Python track says its refusal line carries a later kernel’s wording than the 0.1.18 CLI its listings were first run against. The polyglot and tic-tac-toe pages that start a server now say which earlier server may still be running.

    Pages: Where this actually stands, The Python track, One wire, three languages, The notebook: the catalog as a graph

  • 2026-10-17: The comparison table in Tic-tac-toe, VII now names its first column (“what”), which was blank, so a screen reader no longer announces an empty header cell.

    Pages: Tic-tac-toe, VII: the game’s space is a file

  • 2026-10-15: A new page, What’s new, lists what has changed in the book, most recent first, with the pages to read for each change.

    Pages: Introduction

  • 2026-10-15: A query’s authority is its dataset now ends with a pointer to the gonk Book’s Two teams, one gonk, which runs the same questions through a server’s doors with an agent per team and a manager over both.

    Pages: A query’s authority is its dataset

  • 2026-10-15: A new chapter, A query’s authority is its dataset, runs the same SPARQL text for three callers over two tenants’ graphs in one store, refuses a join missing one grant rather than narrowing it, and tries every escape route a query has, each as real output.

    Pages: A query’s authority is its dataset, Who is asking, Who a script runs as

  • 2026-10-14: A refused argument is now followed by that argument’s own description from the endpoint’s contract, so the fix sits next to the complaint; Tic-tac-toe, II explains where that line comes from, and the spreadsheet and tic-tac-toe pages show it.

    Pages: Tic-tac-toe, II: lines, aliases and the board, Spreadsheet, I: cells are atoms, Tic-tac-toe, III: the rules as resources, Tic-tac-toe, VII: the game’s space is a file

  • 2026-10-13: The aliases, day to day drops its warning that a mount removes local functions: a mount only adds, so a peer’s function sits beside the local one of the same name.

    Pages: The aliases, day to day

  • 2026-10-12: Who a script runs as shows the kernel’s refusal now saying what passes a family floor, and explains why that only gets a run as far as the exact check inside.

    Pages: Who a script runs as

  • 2026-10-11: A sentence becomes a draft explains what happens when the model repeats itself: the repeat is recorded as such, not checked again, and the draft’s mark says drafting stopped because of it.

    Pages: A sentence becomes a draft

  • 2026-10-11: Tracing a resolution now explains each authority mark a trace draws: unchanged, narrowed with the scopes it held, or refused, so a refused hop no longer reads as authorized.

    Pages: Tracing a resolution

  • 2026-10-11: A sentence becomes a draft now shows the check warning on an empty preview, naming the patterns that matched nothing, the drafter repairing on it, the grounding stating what kind of value each predicate takes, and a draft’s author told plainly that it is a draft. Your first script explains why even a hello world names the graph it reads, and one of the code exercises now asks for a different check.

    Pages: A sentence becomes a draft, Your first script, Exercises: code and language, Who a script runs as

  • 2026-10-11: A gentle opener, Your first script, walks four small steps on a shelf of books: a hello script with a parameter, a script over data, one grant line, and a sentence drafted and published.

    Pages: Your first script, Scripts are resources

  • 2026-10-11: A new section, Code and language as resources, publishes SPARQL scripts into a running host at a name, works out whose authority a run holds, and lets a language model draft a script that a person checks and publishes, with an exercises page to follow.

    Pages: Scripts are resources, Who a script runs as, A sentence becomes a draft, Exercises: code and language, The repositories

  • 2026-10-10: The REPL now has an exists command, a single quoted token on a sink line is unquoted, and a content-less sink under -c waits two seconds for stdin; The file workspace and the REPL grammar replay each case. Who is asking and the glossary explain that a principal now travels with the request as a capability scope, Tracing a resolution draws a refused request as a tree, and Golden threads in practice says a write that ran and failed still cuts.

    Pages: The file workspace, The REPL grammar, Who is asking, Tracing a resolution, Golden threads in practice, Glossary, Where this actually stands

  • 2026-10-10: Where this actually stands is brought up to date: a new rundown of every kernel release since its last stamp, each pointing to the chapter that teaches it, plus what a failed module callback now does and the answer deadline the XSLT module enforces.

    Pages: Where this actually stands

  • 2026-10-10: Running it and What resolution buys you now show what the catalog command really prints, and The graph face lists every endpoint its string-input query returns, with a note on why the list is that long.

    Pages: The graph face, What resolution buys you, Running it

  • 2026-10-10: The file workspace’s Try it section is now a checked REPL transcript that shows the quoting trap on a sink line and the content= form that avoids it, and the REPL grammar says that a sink’s content is taken verbatim.

    Pages: The file workspace, The REPL grammar

  • 2026-10-10: The file workspace now spells out what the jail actually checks: paths opened one name at a time, symbolic links refused, one spelling per file, Windows refused outright, and a workspace created private. The Beyond one host exercises add a hint for running the dead-peer exercise under a narrowed capability.

    Pages: The file workspace, Exercises: beyond one host

  • 2026-10-10: Every self-description in the book now shows its per-verb contract named by a digest of its content, and What resolution buys you explains that shape, why the book writes the digest as an ellipsis, and the stable per-door name the tool list uses instead.

    Pages: What resolution buys you, Tic-tac-toe, I: the model and the atoms, Spreadsheet, II: a formula is code as a resource, Tic-tac-toe, III: the rules as resources

  • 2026-10-10: The front door’s section on cut threads now explains why a file’s cut count varies: the CLI watches its workspace and cuts a file’s thread for every change event the operating system reports, so a Mac and a Linux machine print different numbers for the same lines.

    Pages: Twenty minutes with the CLI

  • 2026-10-09: Twenty minutes, nothing compiled corrects its cut section: cutting a pure function’s name now invalidates its cached answer, because every cacheable read hangs from a thread named after its own target. The cache readout shown has gained state and chain columns, and the trace that follows is now computed rather than served.

    Pages: Twenty minutes with the CLI

  • 2026-10-09: Tracing a resolution gains a dry run that shows where a request would go and whether it would be let in without sending it, and Golden threads in practice shows how to ask what a cut would recompute before making it. Who is asking now explains that a mount carries your narrowed capability to the peer and shows a test that checks a module’s published grant list against its declarations; the REPL grammar describes the sandboxed Lisp, and Configuration gives the passkey flags that replaced an environment variable.

    Pages: Tracing a resolution, Golden threads in practice, Who is asking, The REPL grammar, The editor as a client, Configuration

  • 2026-10-09: A new page, The aliases, day to day, is the practical half of the Emacs client: setting it up, how a resource name becomes a function, finding the functions you have, calling them, where results land, how refusals show up, and when to refresh.

    Pages: The aliases, day to day, The editor as a client

  • 2026-10-09: Who is asking adds a note on a session’s principal: a name the transport stamps on every request for logs and authorship, never authority, which stays with the capability alone.

    Pages: Who is asking

  • 2026-10-07: A kernel behind a socket now uses gonk as its example of a server that links only what it serves, with a catalog listing you can run to prove it, and contrasts its mounted model with the retired dev server’s linked one; Preferential resolution and the repository lists follow the same move.

    Pages: A kernel behind a socket, Preferential resolution, The repositories, Where to go next

  • 2026-10-06: Where to go next introduces The gonk Book, a second book about the work-ledger and repository-browser host built from the pieces this book teaches, starting with a ledger of your own that runs in the page.

    Pages: Where to go next

2026-09

Twenty minutes with the CLI

You can try ikigai in two places. The book’s Run buttons use a kernel running in your browser, compiled from Rust to WebAssembly. Here, a kernel is the part of ikigai that handles requests for named resources. Running it explains the browser version.

This chapter uses the ikigai command on your machine. It has its own kernel and cache, separate from the page’s. You will list resources, pass results between them, request a different format, reuse a cached answer, invalidate it, and inspect a request’s execution. You do not need to write Rust.

0. Install

You need Rust and Cargo, Rust’s package manager. If cargo --version reports command not found, install the toolchain with rustup, then open a new terminal. You do not need to know Rust to follow this walkthrough.

Cargo compiles the CLI from source, which can take several minutes on the first installation. Allow about twenty minutes for the walkthrough after installation.

cargo install ikigai-cli --locked
ikigai --version

⚠ The crate is ikigai-cli; only the binary is called ikigai. cargo install ikigai does not fail — it fetches an unrelated crate by another author. --locked builds the dependency versions the release was tested with rather than whatever the registry resolves today.

The other side of --locked: it also holds you to that release’s flaws. ikigai-cli 0.1.68 is the first release whose lockfile fixes the rustls-webpki advisory (RUSTSEC-2026-0382), using 0.103.16 instead of 0.103.15. Install 0.1.68 or later.

The default build supports the commands below. Remote QUIC connections need the optional quic feature. The HTTP server, serve --http, needs web. The layers HTTPS server, --layers-https, needs both. To include both features, install with:

cargo install ikigai-cli --locked --features quic,web

Feature flags select code at build time. Reinstalling with these flags rebuilds the binary; it does not add a plug-in to the existing executable. Current releases list which features were compiled in at the bottom of ikigai --help.

The shell examples use -c to run a command and exit. For interactive use, ikigai opens the full-screen REPL (read-evaluate-print loop): an interactive prompt that runs each command you enter. ikigai --plain selects the line-by-line REPL. If you use a screen reader, start ikigai --plain to avoid full-screen redraws, as recommended in the CLI accessibility notes. A REPL keeps the process running between commands, so it keeps the cache too. 5. A cache hit needs that shared session.

In a transcript, ikigai> is the prompt the program displays. Type only the command after it. The other lines are output, not more commands. Inside the REPL, omit the shell wrapper ikigai --plain -c '…' as well.

1. Everything resolvable

ikigai --plain -c 'source urn:kernel:catalog'

This prints the catalog: descriptions of the endpoints available in this host, assembled into one graph. An endpoint is code that handles requests for a resource. Each endpoint supplies its own description. Turtle is a text format for Resource Description Framework (RDF) graphs. Each statement links a subject to an object through a predicate that names their relationship. The catalog is long; you do not need to read every line now.

2. Everything you may do

ikigai --plain -c 'source urn:kernel:actions'

This prints one resource name per line, limited to endpoints your current permissions allow you to invoke. The book calls this list the manifold. The session starts with root authority, which allows nearly everything. A capability specifies which requests a caller may make. Narrow the session’s capability and the list gets shorter. An agent can use the same list to discover its available tools.

The names are IRIs, Internationalized Resource Identifiers. A URL is one kind of identifier; the urn:… names here use the URN scheme. They identify resources without requiring a web address. Ikigai routes each name to the endpoint that handles it.

3. A pipe

ikigai --plain -c 'source urn:demo:split "a,b" | urn:iki:fn:toUpper | urn:iki:fn:reverseList'
B
A
[3 computed]

split turns a,b into two lines. The pipe passes that whole result to toUpper, producing A and B on separate lines. The next pipe passes those lines to reverseList, which reverses their order. It reverses newline-separated items, so a single line such as a b would stay in the same order.

[3 computed] reports three pipeline stages whose answers were computed. The source verb means “read this resource”; after each pipe it is implied. demo is part of the name of a bundled example endpoint. It is not a special word in the command grammar.

4. Another format

ikigai --plain -c 'describe urn:iki:fn:toUpper text/turtle'

describe uses the Meta verb to request the endpoint’s description, including its arguments and supported operations. text/turtle asks for that description as an RDF graph written in Turtle. Try text/plain for a human-readable summary. Both are text; they organize the description differently. The trailing media type is shorthand for as=text/turtle. The available formats depend on what the endpoint provides and what the kernel’s format converters, called transreptors, can produce.

5. A cache hit

This one needs the REPL, because the cache lives in the process and a -c run exits. Start ikigai --plain, then enter these commands, one line at a time:

sink urn:file:notes.txt remember the milk
source urn:file:notes.txt
source urn:file:notes.txt
cache urn:file:notes.txt

Here is the session with prompts and expected output:

ikigai> sink urn:file:notes.txt remember the milk
wrote 17 bytes to notes.txt
[uncacheable]
ikigai> source urn:file:notes.txt
remember the milk
[computed]
ikigai> source urn:file:notes.txt
remember the milk
[cached]
ikigai> cache urn:file:notes.txt
cached

sink writes a real file named notes.txt in the CLI’s workspace, by default ~/.ikigai/workspace/. [uncacheable] describes the write operation: it must run each time you ask. It does not prevent the file’s contents from being cached when read.

The first source reads the file. The second reuses the cached answer without opening it. cache only checks whether a valid cached answer is available; it neither reads the file nor adds an answer to the cache.

6. Cut a thread

Still in the REPL. A golden thread records a dependency that the kernel can invalidate. The cached file contents depend on the thread named urn:file:notes.txt. “Cutting” that thread tells the kernel that answers depending on it are out of date. Use sink to send the thread’s name to the kernel’s cut endpoint:

ikigai> sink urn:kernel:cut urn:file:notes.txt
cut urn:file:notes.txt
[uncacheable]
ikigai> cache urn:file:notes.txt
not cached

The file still exists, but its cached answer is now invalid. Any cached result computed from that answer becomes invalid too. Reading the file again computes and caches a fresh answer. Cutting does not switch caching off.

Writing through the file endpoint cuts the same thread automatically. The CLI also watches for filesystem changes, so another program’s edit can invalidate the cache. A host you build yourself must arrange that notification for external changes.

Now the same cut on a pure function, which declares no thread at all:

ikigai> source urn:iki:fn:toUpper in="a b"
A B
[computed]
ikigai> sink urn:kernel:cut urn:iki:fn:toUpper
cut urn:iki:fn:toUpper
[uncacheable]
ikigai> cache urn:iki:fn:toUpper in="a b"
not cached

This answer is invalidated too. toUpper depends only on its arguments, so it declares no changing external dependency. Even so, the kernel automatically gives every cacheable source result a dependency on a thread named after its target. Cutting urn:iki:fn:toUpper invalidates all cached reads through that name, including ones with different arguments. You can cut a name even if nothing currently depends on it.

Golden threads in practice explains this rule’s history. To see the entries the cuts left behind, read the cache:

ikigai> source urn:kernel:cache
cache
  entries  4 / 4096 (2 stale)
  size     343 B / 64.0 MB
  urn:file:notes.txt  cut      text/plain                     17 B  1 thread    root
  urn:iki:fn:toUpper  live     application/json              318 B  1 thread    root
  urn:iki:fn:toUpper  cut      text/plain                      3 B  1 thread    root
  urn:time:now        live     text/plain                      5 B  1 thread    root
[uncacheable]

The first two lines show the cache’s limits: 4096 entries and 64 MiB by default. The default policy removes the least recently used entry when either limit is exceeded. Golden threads in practice shows how a host configures the cache, and what the size counts now that an answer can be a stream (core 0.2.0: a one-shot body is never stored, a spilled one counts its full length, a hit shares its bytes). Each row lists an entry’s resource name, state, format, size, dependency count, and resolution chain. root means the host’s own space.

The file contents and toUpper’s answer say cut. They remain in memory but are no longer usable, which is why the earlier probes said not cached. A cut increments a version counter, called a generation. It does not immediately remove every affected entry. The next read detects the old generation and replaces the stale answer. A cache probe only reports that state.

The live row is toUpper’s description, which the engine read to route its arguments. It depends on the host’s bindings, because changing a binding can change the endpoint’s description. Cutting the thread for toUpper’s results leaves this description valid.

The last row is one you did not ask for. The CLI reads its own clock, urn:time:now, once a second (the full-screen REPL shows it in its tab bar), and the answer, the time as HH:MM, is cached until the minute changes. So it is in every session that has been open longer than a second (CLI 0.2 and later). Read the cache just as a minute turns and the row may say expired instead, with one entry stale, until the next second’s read replaces it.

7. Which threads have been cut

The relevant rows from this session are shown below. Other file threads may appear, including temporary files the CLI used while writing. … marks omitted rows.

ikigai> source urn:kernel:threads
threads (cut generations)
…
  urn:file:notes.txt  gen 2
  urn:iki:fn:toUpper  gen 1
  urn:iki:host:names  gen 1
  urn:iki:space:dynamic:names  gen 1
  urn:kernel:bindings  gen 1
[uncacheable]

This lists the threads cut during this kernel’s lifetime and their generation counters. We cut toUpper’s once by hand. The file’s thread was cut at least twice, once by the write and once by hand. The last three rows were there before you typed anything: since CLI 0.2.6 the CLI starts its names layer (it binds the names in urn:iki:host:names, which the REPL’s bind writes), and starting it cuts each of these threads once, even when there are no names to bind.

The CLI’s filesystem watcher can add more cuts, including for the CLI’s own writes. One edit can produce several operating-system events, with different timing on different platforms. Your file’s generation may therefore be higher than 2. It counts cuts, not file edits.

A cut increments the counter even when nothing depends on the thread. When the kernel next looks up a cached entry, it compares the generations recorded with the entry to the current ones. A mismatch means the answer is stale.

8. A trace

ikigai> trace urn:iki:fn:toUpper in="a b"
trace  urn:iki:fn:toUpper
  client      ikigai repl  ·  capability: root (full authority)
  transport   embedded · in-process

urn:iki:fn:toUpper   toUpper · computed · main · 0ms   → 3b  A B

trace executes the request and records how it ran. The header identifies the client, its permissions, and the connection used. Here, embedded means the kernel runs in the CLI’s own process.

The result was computed because Cut a thread invalidated the cached answer. main is the execution thread, not a golden thread. 0ms is the duration displayed in whole milliseconds, and 3b is the size of A B in bytes. When an endpoint requests other resources, the trace shows those requests underneath it.

What you have seen

You have listed available resources and permitted actions, passed results between endpoints, requested another format, reused a cached result, invalidated dependent results, and traced a request. The invalidation shown here reacts to a dependency changing; it does not need to wait for a timeout. Resolution explains how these operations fit together. Part I shows how to build an endpoint that uses them.

CI checks this page’s resource names against books/ikigai/cli-vocabulary.txt and probes that vocabulary against a real CLI. It also replays the REPL transcripts against ikigai-cli 0.2.6, pinned in books/ikigai/ikigai-cli.version. Contributors can run these checks with ./scripts/test-transcripts.sh. That pin identifies the replayed version; a newer CLI may print different output. The replay exists because, for a while, it did not run in CI, and section 6 went on saying a cut left a pure function’s answer cached long after core 0.1.73 changed it. If a command here fails on a newer CLI, the replay is where to look first.

This replay covers the blocks with ikigai> prompts. It does not execute shell command blocks or prompt-free REPL examples.

The REPL grammar

The same command syntax works in the ikigai REPL, in a shell with ikigai -c, in this book’s runnable cells, and in the browser demo. Each uses its own host, which determines the available resource names. A command with a Run button works in this page. The CLI examples below run inside ikigai --plain unless they start with ikigai, in which case run them in your shell. If your REPL from Twenty minutes with the CLI is still open, you can keep using it.

The syntax blocks use <…> for a value you supply, […] for an optional part, and … for repetition. Do not type those markers. In the pipe, map, and fork syntax blocks, a, b, and c stand for resource names.

A resolution

source <iri> [input]
source <iri> key=value …

source is the verb, meaning “read.” An IRI (Internationalized Resource Identifier) names the resource to read. Everything after it supplies arguments to that request. These are command parts, not the subject, predicate, and object of a Resource Description Framework (RDF) statement.

key=value names an argument. An unnamed, or positional, value fills a declared argument that you have not supplied by name. The engine reads the endpoint’s description to find its argument names. For example, toUpper declares one argument called in:

RESOURCE ORIENTED COMPUTING
[computed]
RESOURCE ORIENTED COMPUTING
[cached]

Two spellings of the same request — and the second answers [cached], because the kernel keys the cache on the request, not the text you typed. Edit the input and run again: a different request, computed afresh.

For an endpoint with two arguments, name one and supply the other positionally. These three commands all ask the bundled greet endpoint for Hello, Joe (type what follows ikigai>, the REPL’s prompt, here and below):

ikigai> source urn:demo:greet greeting=Hello name=Joe
Hello, Joe
[computed]
ikigai> source urn:demo:greet greeting=Hello Joe
Hello, Joe
[cached]
ikigai> source urn:demo:greet Hello name=Joe
Hello, Joe
[cached]

They are one request, so the second and third are answered from the cache.

Use describe urn:demo:greet text/plain to see the declared names, greeting and name. The engine checks every name against that description, so a typo is an error, not a wrong answer. This line is refused:

ikigai> source urn:iki:fn:toUpper inn=abc
error: `urn:iki:fn:toUpper` declares no argument `inn` (its arguments: `in`). To send `inn=abc` as text, name the argument it fills: in="inn=abc"

As the error says, text that contains = goes to an argument by name: in="a=b". Text whose part before the = is not a name, such as 2+2=4, is still ordinary positional text. (CLI 0.2.4 and later. Earlier releases sent a misspelled key=value on as positional text, so inn=abc came back as INN=ABC.)

demo is a namespace used for the bundled examples. The host binds the full name urn:demo:greet to an endpoint. Replacing demo with another word does not create a new binding. Later chapters show how to bind your own names.

Quoting

Wrap a word in "…" to keep |, .., (, ), ; or a space literal inside an IRI or an input; \" is a literal quote and \\ a literal backslash. in="a b" is one argument; in=a b is one argument and one stray positional.

Ikigai’s quotes do not turn \n into a newline. In Bash or Zsh, the shell’s $'…' quoting can insert an actual newline before passing the command to ikigai:

ikigai --plain -c $'source urn:iki:fn:toUpper in="a\nb" | urn:iki:fn:reverseList'

Fish has no $'…' quoting. There, end the single quotes, write \n unquoted, and reopen them:

ikigai --plain -c 'source urn:iki:fn:toUpper in="a'\n'b" | urn:iki:fn:reverseList'

Either line prints B then A, on separate lines. The quoting belongs to the shell; do not type it inside the ikigai REPL. The split endpoint in the next example makes a newline list without needing a literal newline in your command.

The pipe: |

source a [input] | b | c

The whole output of one stage becomes the next stage’s unnamed argument. Each stage after the first is written as its IRI alone. The source verb is implied.

How the output gets there depends on the stage that receives it (ikigai-engine 0.2.2, ikigai-cli 0.2.3 and later). An argument whose contract declares it reads a body (ik:readsBody true, ArgSpec::reads_body in Rust) is handed the upstream as a body and reads it as it arrives, so source urn:file:big.log | sink urn:file:copy.log copies the file a chunk at a time rather than holding it. Any other argument gets the output by value, as before: the kernel collects it whole, under the run’s byte budget, and notes it in that step’s trace (collected). An output that is already one buffer goes by value either way. A stored plan (execute, urn:plan:eval) still pipes by value, whatever the receiving argument declares. Streams, I: bytes are a representation is where a body begins.

B
A
[3 computed]

split produces two lines, a and b. toUpper changes them to A and B. reverseList reverses the lines, so B comes first. A space does not separate list items: reverseList would leave the single line A B unchanged.

The tally reports the cache status of each requested stage or mapped item. It excludes argument-description fetches and requests made inside an endpoint.

Cacheability flows down the pipe: a stage over an uncacheable input is no more cacheable than that input, and cutting the input’s thread invalidates the transformed result too.

The map: ..

source a [input] .. b

Run b once per newline-separated item of a’s output, and rejoin the results. Newline lists are the convention every list-shaped endpoint in the ecosystem follows, which is what makes .. compose with all of them.

A
B
C
[4 computed]

Four reported results: one split and three calls to toUpper, one per item. The CLI’s --plain REPL and -c mode also report the fan-out width supported by the scheduler. [fan-out 3 → 1 wide] means three items with capacity for one at a time; 3 → 3 wide allows up to three at a time. The width is calculated before dispatch, rather than measuring how much the work overlapped. The full-screen REPL and this page’s cells omit that extra line.

The fork: ( a ; b )

source a [input] | ( b ; c )

Send the whole input to each branch, then join the branches’ outputs in order. Here, toUpper receives all three lines and returns A, B, C. reverseList receives the original lower-case lines and returns c, b, a.

A
B
C
c
b
a
[1 cached · 2 computed]

The tally reads 1 cached · 2 computed because the map cell above already resolved the split, and the engine counts what it served as well as what it ran. Run this before that cell, on a fresh page, and it reads 3 computed.

Composition: urn:iki:fn:compose

compose is an endpoint that fills in a text template. The book calls that template a shape. A marker such as $a{urn:data:about} means “read this resource and insert its contents here.” This inclusion is called transclusion. If the included text contains more $a{…} markers, compose expands those too.

The CLI comes with a template named urn:data:page. Read the template before composing it:

ikigai> source urn:data:page
ikigai compose demo — one pull, recursively assembled

  toUpper : $a{urn:iki:fn:toUpper?in="resource oriented computing"}
  wrap    : $a{urn:demo:wrap?text=hello}
  greet   : $a{urn:demo:greet?greeting=Hi&name=World}
  nested  : $a{urn:data:about}

literal marker (escaped, not expanded): $$a{urn:iki:fn:toUpper?in=x}
[computed]

The text after ? supplies arguments to the resource named inside the marker. & separates arguments. So the greeting uses greeting=Hi and name=World, both written directly into this template. They are fixed inputs, not defaults that a caller can override. $$a{…} escapes a marker so it appears literally in the result.

Now ask compose to read that template and fill it in. src identifies the template:

ikigai> source urn:iki:fn:compose src=urn:data:page
ikigai compose demo — one pull, recursively assembled

  toUpper : RESOURCE ORIENTED COMPUTING
  wrap    : [hello]
  greet   : Hi, World
  nested  : a shape within a shape: COMPOSED WITHIN A COMPOSED SHAPE

literal marker (escaped, not expanded): $a{urn:iki:fn:toUpper?in=x}
[computed]

For the nested line, urn:data:about contains:

ikigai> source urn:data:about
a shape within a shape: $a{urn:iki:fn:toUpper?in="composed within a composed shape"}
[computed]

That second marker explains the uppercase text at the end. The composed result depends on every resource read along the way. Their golden threads carry through to the result, so changing one dependency invalidates the cached composition too.

Make a template and pass it data

In the CLI’s REPL, write your own template as a file resource:

ikigai> sink urn:file:greeting.txt Hello, $h{{name}}!
wrote 18 bytes to greeting.txt
[uncacheable]

This creates greeting.txt in the workspace. $h{{name}} inserts a caller-supplied argument named name, escaping it for HTML. The template is separate from the data.

compose declares src and accepts any other name as a template argument, because the template decides which names it uses. Pass name beside src, in the same REPL:

ikigai> source urn:iki:fn:compose src=urn:file:greeting.txt name=Joe
Hello, Joe!
[computed]
ikigai> source urn:iki:fn:compose src=urn:file:greeting.txt name=Alice
Hello, Alice!
[computed]

Both use the same template file; only the argument changes. This example runs in the CLI, which has the file workspace; the kernel in this page does not provide one. (CLI 0.2.4 and later. Earlier releases refused name=Joe here; the Lisp form described below could pass it: (invoke 'source "urn:iki:fn:compose" "src" "urn:file:greeting.txt" "name" "Joe").)

The legacy Lisp form prints [uncacheable] for its outer evaluation; the inner composition result can still be cached.

Writing: sink

sink <iri> [key=value …] <content>
source a | sink <iri>

Leading key=value pairs name declared arguments; the rest of the line is the content, verbatim — spaces, JSON, Turtle and quote marks kept — with one exception (ikigai-cli 0.1.46 and later): content that is exactly one quoted token is unquoted by the same rules as content="…", so sink <iri> "a note" stores a note, and sink <iri> "\"a note\"" is how to store the quote marks. Only " quotes; 'a note' is stored as typed, and so is "a" and "b", which is not one token. The file workspace replays each case. The pipe form stores the upstream value; with no pipe and no content on the line, a sink under -c reads stdin, waits at most two seconds for its first byte, and on silence sends no content at all rather than an empty one (it says so on stderr). A sink the kernel dispatched cuts the golden thread named after the target, whether it succeeded or (since core 0.1.95) failed, which is how a write invalidates every cached read that declared that thread, and cached source and exists answers for that name: since core 0.1.73 every cacheable source or exists answer hangs from the thread named after its own target, declared or not, as the previous chapter showed on a pure function.

The urn:iki:tutorial:* names in the cell below are this book’s own, bound by the page’s kernel and by the tutorial’s host; a stock ikigai CLI does not bind them, and answers error: no endpoint resolved for urn:iki:tutorial:title. Run the cell here, or in a host of your own from Binding.

ok
[uncacheable]
aTitleFromTheGrammarChapter
[computed]

Describing: describe

describe <iri> [type]

describe uses the Meta verb to ask for a resource’s description. The media type defaults to text/turtle, an RDF graph in Turtle format; text/plain requests a human-readable summary. A transreptor converts representations between formats, so other media types work when the host has a conversion path to them.

reverseList — Reverse list
Reverses the order of newline-separated items in the `in` argument.
verbs: Source, Meta
  input in [argument]: newline-separated items
outputs: text/plain;charset=utf-8
[computed]

Asking: exists

exists <iri> [key=value …]

The Exists verb (ikigai-cli 0.1.46 and later): is the resource there, asked without reading it. The answer is the endpoint’s, by convention true or false. Arguments route as they do for source, and a pipe is refused: the question is about one resource. The file workspace asks it of a file it just wrote and of one that is not there.

Probing: cache

cache <iri> [args]

Checks whether the requested source result is currently cached, without running that operation. To route arguments, it may first resolve and cache the endpoint’s description.

not cached
x
[computed]
cached

Tracing: trace

trace <iri> [args]

Resolve once, for real, and show the tree: who asked, under what authority, over what transport, then each invocation with its cache verdict, thread and duration. A composite shows its sub-resolutions as children; What resolution buys you has one with a child.

Authority: cap

cap                  show the session capability
cap <scope…>         narrow it to these scopes
cap reset            back to the session's identity

The session starts as root. Narrow it and every resolution after runs under the narrower authority — enforced by the kernel, per verb, against what each endpoint declared:

ikigai> cap read-only
narrowed — capability: urn:cap:fs:read:/Users/you/.ikigai/workspace
ikigai> sink urn:file:notes.txt nope
error: denied: capability does not grant `urn:cap:fs:write:*` (declared by `urn:file:notes.txt`; any grant under `urn:cap:fs:write:` satisfies it)
ikigai> cap reset
reset to identity — capability: root (full authority)

read-only is a profile the CLI’s host defines — a name for a set of scopes. A bare scope IRI works too. Multi-verb endpoints is where you declare one on your own endpoint and watch the same refusal.

Listing: list

list

Every name bound in the current space — pattern, endpoint id, and where it is served from when a mount is involved. The kernel’s own urn:kernel:* resources are intercepted ahead of the bindings rather than bound; current hosts list them first all the same, and source reaches them.

urn:kernel:actions                        → kernel-actions
urn:kernel:aliases                        → kernel-aliases
urn:kernel:cache                          → kernel-cache
urn:kernel:cached                         → kernel-cached
urn:kernel:catalog                        → kernel-catalog
urn:kernel:constraint                     → kernel-constraint
urn:kernel:cut                            → kernel-cut
urn:kernel:dependents                     → kernel-dependents
urn:kernel:explain                        → kernel-explain
urn:kernel:scheduler                      → kernel-scheduler
urn:kernel:threads                        → kernel-threads
urn:kernel:topology                       → kernel-topology
urn:kernel:uncached                       → kernel-uncached
urn:kernel:validate                       → kernel-validate
urn:iki:fn:toUpper                        → toUpper
urn:iki:fn:reverseList                    → reverseList
urn:iki:fn:compose                        → compose
urn:iki:fn:conditional                    → conditional
urn:iki:fn:each                           → each
urn:demo:wrap                             → wrap
urn:demo:split                            → split
urn:demo:greet                            → greet
urn:demo:echo/{message}                   → echo
urn:iki:tutorial:camel-case               → camel-case
urn:iki:tutorial:title                    → title
urn:iki:tutorial:camel-title              → camel-title
urn:iki:tutorial:ttt:cell:{x}:{y}         → ttt-cell
urn:iki:tutorial:ttt:cells:{list}         → ttt-cells
urn:iki:tutorial:ttt:board                → ttt-board
urn:iki:tutorial:ttt:checkset:{x}:{y}     → ttt-checkset
urn:iki:tutorial:ttt:winner               → ttt-winner
urn:iki:tutorial:ttt:turn                 → ttt-turn
urn:iki:tutorial:ttt:move:{x}:{y}         → ttt-move
urn:iki:tutorial:ttt:reset                → ttt-reset
urn:iki:tutorial:ttt:template:{name}      → ttt-template
urn:iki:tutorial:ttt:view:board           → composeOver
urn:iki:tutorial:ttt:view:square:{x}:{y}  → composeOver
urn:iki:tutorial:ttt:view:status          → composeOver
urn:iki:tutorial:ttt:view:reply           → composeOver
urn:iki:tutorial:ttt:view:game:{game}     → composeOver
urn:iki:tutorial:ttt:view:play:{x}:{y}    → ttt-view-play
urn:iki:tutorial:ttt:view:reset           → ttt-view-reset
urn:iki:tutorial:ttt:stored:{x}:{y}       → ttt-stored
urn:iki:tutorial:time:now                 → time-now
urn:iki:tutorial:time:today               → time-today
urn:iki:tutorial:time:instant             → time-instant
urn:iki:tutorial:time:event:{name}        → time-event
urn:iki:tutorial:time:until:{name}        → time-until
urn:iki:tutorial:time:template:{name}     → time-template
urn:iki:tutorial:time:view:clock          → composeOver
urn:iki:tutorial:sheet:input:{ref}        → sheet-input
urn:iki:tutorial:sheet:feed:{name}        → sheet-feed
urn:iki:tutorial:sheet:tick:{name}        → sheet-tick
urn:iki:tutorial:sheet:compile            → sheet-compile
urn:iki:tutorial:sheet:formula:{ref}      → sheet-formula
urn:iki:tutorial:sheet:refs:{ref}         → sheet-refs
urn:iki:tutorial:sheet:precedents:{ref}   → sheet-precedents
urn:iki:tutorial:sheet:range:{from}:{to}  → sheet-range
urn:iki:tutorial:sheet:cell:{ref}         → sheet-cell
urn:iki:tutorial:sheet:template:{name}    → sheet-template
urn:iki:tutorial:sheet:view:cell:{ref}    → composeOver
urn:iki:tutorial:sheet:view:grid          → composeOver
urn:iki:tutorial:sheet:view:cached:{ref}  → composeOver
urn:iki:tutorial:sheet:view:cost:{ref}    → composeOver
urn:iki:tutorial:sheet:view:cost          → composeOver
urn:iki:tutorial:sheet:view:edit          → sheet-view-edit
urn:iki:tutorial:push:listener:{name}     → push-listener
urn:iki:tutorial:push:carries             → push-carries
urn:iki:tutorial:ttt:arrangement:{name}   → ttt-arrangement
urn:iki:tutorial:ttt:build                → ttt-build
urn:sexpr:arrangement-to-rdf              → sexpr-arrangement-to-rdf
urn:sexpr:arrangement-from-rdf            → sexpr-arrangement-from-rdf
urn:iki:tutorial:check:player:x           → player-x
urn:iki:tutorial:check:player:o           → player-o
urn:iki:tutorial:check:shapes             → player-shapes
urn:iki:tutorial:check:to-turtle          → to-turtle
urn:toolbox:page                          → toolbox-page
urn:toolbox:none                          → toolbox-none
urn:toolbox:text:{message}                → echo
urn:toolbox:path:{rest}                   → toolbox-path
urn:toolbox:input:{iri}                   → toolbox-input
urn:toolbox:check:{iri}                   → toolbox-check
urn:toolbox:{name}                        → toolbox-file
urn:shacl:validate                        → shacl-validate
urn:iki:toolbox:dist:{path}               → book-toolbox-dist
urn:iki:tutorial:shop:stock               → shop-stock
urn:iki:tutorial:shop:prefixes            → live-declaration
urn:iki:tutorial:shop:template:summary    → shop-template-summary
urn:iki:tutorial:shop:template:row        → shop-template-row
urn:iki:tutorial:shop:template:records    → shop-template-records
urn:iki:tutorial:shop:summary             → shop-summary
urn:iki:tutorial:shop:records             → shop-records
urn:json:query                            → json-query
urn:json:from-csv                         → json-from-csv
urn:json:to-csv                           → json-to-csv
urn:compress:gzip                         → compress-gzip
urn:decompress:gzip                       → decompress-gzip
urn:compress:zlib                         → compress-zlib
urn:decompress:zlib                       → decompress-zlib
urn:file:quakes-2024-01-01.geojson        → quakes-window
urn:quakes:template:headline              → quakes-headline
urn:file:quakes.context.jsonld            → quakes-context
urn:quakes:template:graph                 → quakes-graph-template
urn:quakes:graph                          → quakes-graph
urn:file:quakes-2024-01-08.geojson        → quakes-window-2024-01-08
urn:quakes:template:graph:2024-01-08      → quakes-graph-template-2024-01-08
urn:quakes:graph:2024-01-08               → quakes-graph-2024-01-08
urn:quakes:strong                         → quakes-strong
urn:quakes:question:count                 → quakes-question-count
urn:quakes:question:biggest               → quakes-question-biggest
urn:quakes:question:by-magtype            → quakes-question-by-magtype
urn:quakes:question:strong                → quakes-question-strong
urn:quakes:question:strongest-first       → quakes-question-strongest-first
urn:quakes:question:week-after            → quakes-question-week-after
urn:rdf:union                             → rdf-union
urn:rdf:diff                              → rdf-diff
urn:rdf:transrept                         → rdf-transrept
urn:sparql:select                         → sparql-select
urn:sparql:ask                            → sparql-ask
urn:sparql:describe                       → sparql-describe
urn:sparql:construct                      → sparql-construct
urn:file:directory.opml                   → rss-directory.opml
urn:file:feeds.xsl                        → rss-feeds.xsl
urn:file:feeds-local.xsl                  → rss-feeds-local.xsl
urn:file:items.xsl                        → rss-items.xsl
urn:file:feeds/alice.example.com.xml      → rss-feeds-alice.example.com.xml
urn:file:feeds/bob.example.org.xml        → rss-feeds-bob.example.org.xml
urn:file:feeds/carol.example.com.xml      → rss-feeds-carol.example.com.xml
urn:file:feeds/dave.example.org.xml       → rss-feeds-dave.example.org.xml
urn:file:queries/from.rq                  → rss-queries-from.rq
urn:file:queries/in.rq                    → rss-queries-in.rq
urn:file:queries/lines.rq                 → rss-queries-lines.rq
urn:file:queries/per-feed.rq              → rss-queries-per-feed.rq
urn:file:queries/river.rq                 → rss-queries-river.rq
urn:file:queries/search.rq                → rss-queries-search.rq
urn:file:queries/search-bound.rq          → rss-queries-search-bound.rq
urn:file:queries/search-feeds.rq          → rss-queries-search-feeds.rq
urn:file:queries/since.rq                 → rss-queries-since.rq
urn:file:views/digest.xsl                 → rss-views-digest.xsl
urn:file:views/feed.xsl                   → rss-views-feed.xsl
urn:file:views/opml.xsl                   → rss-views-opml.xsl
urn:file:views/page.xsl                   → rss-views-page.xsl
urn:file:river.nt                         → rss-river.nt
urn:xslt:transform                        → xslt-transform
urn:text:wc                               → wc
urn:text:head                             → head
urn:text:tail                             → tail
urn:text:grep                             → grep
urn:text:sort                             → sort
urn:text:uniq                             → uniq
urn:text:nl                               → nl
urn:text:rev                              → rev

That is everything the kernel in this page binds. The kernel’s own urn:kernel:* rows come first; then Part I’s twelve names, in the order they were bound: ikigai-fn‘s nine, then this book’s three chained on top, exactly as Binding wrote them. After those come the Applied parts’ tic-tac-toe, time and spreadsheet names, the names “Checking a resource” uses, and the shop and ikigai-json names that “Transreption” and “Short names in a space” use, the gzip and zlib names of Streams, I, then the recorded weeks, the headline template and the quakes context, graphs, questions and layer of Exploring without Rust (the kernel in the browser says why the page binds those files), the RDF and SPARQL names Giving the data names asks with, and last the example’s files, XSLT and the text tools that A river of feeds runs on. A host built from Part I alone lists only Part I’s share of it. A full CLI lists a few hundred, many served over a socket from another process — which is Part III.

And one more: (

A line that starts with ( is a Lisp form, evaluated by urn:lisp:eval, whose builtins are the five verbs. It is a Scheme (Steel underneath), but not the whole of one: a program sees an allowlist (ikigai-lisp 0.2.0), and the verbs are its only way out. On the list are lambda, define, let and if; numbers, lists and strings; read on a string port the program is handed; with-handler; and the verbs with their general form invoke. Every other global is rebound to a refusal, so display, the environment and the host’s own stdin are out of reach, and the forms that would leave the evaluation, require, defmacro, are refused before compiling. A refusal is an error the program can catch, and a typed Denied if it does not.

A week of earthquakes

The parts before this one wrote endpoints in Rust. This part writes none. Everything in it is done with the ikigai command, the Run buttons on these pages, and files: a recorded download, a jq program, a template. If you can use a shell, curl and jq, you have what it needs.

It follows one set of data the whole way through: the U.S. Geological Survey’s earthquake feeds, which publish every earthquake on Earth as GeoJSON, with no key and no account. We call it Quakes. Two copies of it run through the part:

  • A recorded week: the 178 earthquakes of magnitude 4.5 and up between 2024-01-01 and 2024-01-08, the largest the M7.5 Noto Peninsula earthquake in Japan on New Year’s Day. It never changes, so every answer the book prints from it is the answer you get. The kernel in this page holds a copy, at urn:file:quakes-2024-01-01.geojson, so its Run buttons work with nothing installed. Composites, one layer at a time adds the week after it, recorded the same way, at urn:file:quakes-2024-01-08.geojson.
  • The live feed: the same kind of document for the past seven days, which USGS updates every minute. Only the ikigai command can reach it, because this page cannot reach the network.

The data is USGS’s, and so are the names of the earthquakes in it: each one has its own page at earthquake.usgs.gov. Any name this part makes up for itself goes under urn:quakes:, never under USGS’s domain.

What you will do

  • A lab with names in it: ask the recorded week questions in the REPL’s grammar (a pipe, a map, a fork), and watch the kernel describe, trace and cache what you asked.
  • Somebody else’s resource: read the live feed through urn:http:get, see how long USGS says an answer stays fresh, and narrow what the session may reach to one host.
  • Reshaping JSON with jq: ask sharper questions with urn:json:query, jq as a resource, and fill a template with the answers.
  • Giving the data names: give the feed’s keys published meanings with a JSON-LD context, keep USGS’s own address for each earthquake as its name, and ask the result questions in SPARQL, each question a resource.
  • Composites, one layer at a time: build answers from answers, a filter, a sort, a grouping and a join of two weeks with notes of your own, each a name the next reads; see what a cut recomputes, and what joining the live feed costs.
  • Process scripts are files: keep REPL lines in a file and replay them, store a pipeline as a plan, ask what authority it needs before it runs, and write a script with a script.
  • A river of feeds: turn a directory of RSS feeds into one graph with XSLT, read feeds by their publishers’ rules, and keep questions about it, and the pages they make, as files.
  • Naming a composition: bind a request with its arguments to a name, so the composites and the river’s questions are asked by name, and see what a name fixes, what it follows, who may change it and when a name is somebody else’s.
  • When a rule is not enough: write a Lisp program, as a file, that decides which weeks to read, see it cached and recomputed when a file it read changes, and see what its sandbox can reach; then mount a few lines of Python as a peer for what the sandbox cannot do, and see what changes when the answer comes from another process.

Later in this part, a page built from them is served over HTTP.

What you need

For the Run buttons, nothing. For the shell steps, the ikigai command at 0.2.4 or later (cargo install ikigai-cli --locked; Twenty minutes with the CLI has the details), because 0.2.4 is the first release whose command can run jq, and 0.2.5 the first that reads a .geojson file as JSON. The shell steps over the recorded week are replayed against ikigai-cli 0.2.6, the release the book is checked against, every time the book changes. The steps over the live feed cannot be, because the feed moves; each page says which release printed them, and when.

Work in a directory of your own, so nothing here touches the CLI’s default workspace:

mkdir -p ~/quakes
cd ~/quakes
curl -sO https://raw.githubusercontent.com/ikigai-rs/ikigai-tutorial/main/crates/quakes/quakes-2024-01-01.geojson
ikigai --plain --files ~/quakes

--files makes ~/quakes the file workspace, so urn:file:quakes-2024-01-01.geojson names the file you just downloaded, as it does in this page. If you still have a REPL open from the front door, quit it first: it is using the default workspace, ~/.ikigai/workspace/.

A lab with names in it

A laboratory notebook works because every sample has a label: you can point at it, ask about it, and know later which answer came from which sample. The REPL is that kind of lab. Every sample, and every question you ask about one, has a name, and the kernel keeps track of which answers came from what.

This chapter’s sample is the recorded week of earthquakes. Every cell below runs in this page’s kernel, so there is nothing to install: press Run. The commands are the same ones the ikigai REPL takes, and the last section runs a few of them at a shell.

1. The sample has a name

urn:file:quakes-2024-01-01.geojson is the recorded week. Before reading it, ask what it is. describe asks a resource to describe itself, and text/plain asks for the description in words:

quakes-window — A recorded week of earthquakes
USGS earthquakes of M4.5 and up from 2024-01-01 to 2024-01-08, 178 events, as GeoJSON, recorded once and never changed: the window "Exploring without Rust" runs on.
verbs: Source, Meta
[computed]

It answers Source (it can be read) and Meta (it can describe itself), and nothing else: in this page the recorded week cannot be changed. The last line is the verdict: the kernel computed this description rather than finding it in its cache.

Reading the whole thing would print 125,765 bytes of GeoJSON, which is a lot of text to scroll past. So the next section asks a question instead.

2. A question is a resource too

urn:json:query is a resource that answers a question about another resource. It takes the name of the thing to read (src) and a question written in jq (q). This chapter uses only small questions; Reshaping JSON with jq is about the language. How many earthquakes does the week hold, and what is the first one in the file?

178
[computed]
M 5.1 - 77 km ESE of Fangale’ounga, Tonga
[computed]

USGS lists the newest first, so the first is the last earthquake of the week, in Tonga. as=text/plain asks for the answer as plain text rather than as JSON, so the title comes back without quotation marks.

Nothing about the question is special to this file. src is any name the kernel can read, and the query reads it through the kernel, with the same permissions you have.

3. A pipe

| hands one answer to the next resource as its input. Here the place of the first earthquake goes to urn:iki:fn:toUpper, which capitalizes text:

77 KM ESE OF FANGALE’OUNGA, TONGA
[2 computed]

Two resolutions: the question, then toUpper over its answer. toUpper knows nothing about earthquakes or JSON. It takes text, and the pipe gives it text.

4. A map

Many resources in ikigai treat their input as a list, one item per line. .. runs the next resource once for each line and joins the answers. This question asks for the places of the first three earthquakes, one per line (join("\n") is jq for “one per line”; inside the quoted program, \" is a quotation mark), and maps toUpper over them:

77 KM ESE OF FANGALE’OUNGA, TONGA
BALLENY ISLANDS REGION
BALLENY ISLANDS REGION
[2 cached · 2 computed]

Look at the tally: four resolutions, the question and three calls to toUpper, and two of them were cached. Two earthquakes in a row were in the Balleny Islands region, so the map asked toUpper the same question twice, and the second time the kernel already had the answer. And the Tonga place is the one the pipe above capitalized, so that answer was already there too. That is the cache at work with no instruction from you: an answer is kept under the name and the arguments that produced it, and the same request finds it.

5. A fork

( a ; b ) sends the same input to each branch and joins the answers in order:

77 KM ESE OF FANGALE’OUNGA, TONGA
BALLENY ISLANDS REGION
BALLENY ISLANDS REGION
Balleny Islands region
Balleny Islands region
77 km ESE of Fangale’ounga, Tonga
[1 cached · 2 computed]

reverseList reverses the order of the lines. The question itself was cached by the cell above, which is why this tally counts it as cached.

6. What a question read

trace resolves a request and shows what it did: who asked, with what authority, and every resource it read on the way, each with its own verdict. Ask a question this page has not asked yet, the earliest earthquake of the week (the last in the file):

trace  urn:json:query
  client      ikigai repl  ·  capability: root (full authority)
  transport   embedded · in-process
 
urn:json:query   json-query · computed · ThreadId(1) · — · answered-by=urn:iki:space:json   → 54b  "M 4.7 - 272 km N of Saipan, Northern Ma…
└─ urn:file:quakes-2024-01-01.geojson   quakes-window · cached · ThreadId(1) · —

The query was computed, and the line under it is the recorded week, which the query read and found in the cache. The — is where a duration would go; this page’s kernel has no clock (the kernel in the browser says why). No one told the kernel that this question depends on that file. It saw the query read it, and it remembers. Trace a question that is already cached and the tree stops at the top: nothing was read.

7. What hangs from the sample

That memory has a name too. Every cached answer hangs from one or more threads: named dependencies the kernel can cut. Reading the recorded week hangs the answer from the thread named after the file, and anything computed from that answer hangs from it as well. urn:kernel:dependents lists what hangs from a thread:

dependents of urn:file:quakes-2024-01-01.geojson  (cut 0 times)
  10 live cached entries hang from it — a cut makes them stale, and the next read of each recomputes it
  urn:file:quakes-2024-01-01.geojson  application/geo+json       122.8 KB  1 thread    root
  urn:iki:fn:reverseList              text/plain                     81 B  3 threads   root
  urn:iki:fn:toUpper                  text/plain                     22 B  3 threads   root
  urn:iki:fn:toUpper                  text/plain                     35 B  3 threads   root
  urn:iki:fn:toUpper                  text/plain                     81 B  3 threads   root
  urn:json:query                      application/json                3 B  2 threads   root
  urn:json:query                      application/json               54 B  2 threads   root
  urn:json:query                      text/plain                     35 B  2 threads   root
  urn:json:query                      text/plain                     43 B  2 threads   root
  urn:json:query                      text/plain                     81 B  2 threads   root
[uncacheable]

Every question this chapter asked is there, and so is every toUpper answer computed from one: they all depend on the file, whether they read it directly or through another answer.

Now cut the thread. A cut says “this changed”: every answer that hangs from it becomes stale, and the next request for it is computed again. cache asks whether an answer is in the cache without computing anything:

cached
cut urn:file:quakes-2024-01-01.geojson
[uncacheable]
not cached
178
[computed]

The answer is the same, 178, because the recorded week did not really change. At a shell it can: write to the file and the write cuts the thread for you, or edit it with another program, and the CLI notices and cuts it. Golden threads in practice is the chapter on threads.

8. The same lab at a shell

Everything above works in the ikigai REPL too, against the copy you downloaded in A week of earthquakes. The REPL also has resources this page does not, such as the text tools under urn:text:, which work like their Unix namesakes. USGS’s file puts each earthquake on a line of its own, so grep and wc can already count them. In the REPL you started with --files ~/quakes, type the lines after each ikigai> (the prompt is the REPL’s, not yours to type). Each answer is printed with its tally:

ikigai> source urn:file:quakes-2024-01-01.geojson | urn:text:wc
178
[1 computed · 1 uncacheable]
ikigai> source urn:file:quakes-2024-01-01.geojson | urn:text:grep pattern=Japan | urn:text:wc
62
[1 cached · 2 uncacheable]
ikigai> source urn:file:quakes-2024-01-01.geojson | urn:text:grep pattern="Noto Peninsula" | urn:text:wc
1
[1 cached · 2 uncacheable]

The book’s checks replay this block against ikigai-cli 0.2.6, over the same file the download fetches, so these answers are the ones you get.

178 lines, one per earthquake. 62 of the lines mention Japan, most of them aftershocks of the New Year’s Day earthquake, and one line names the Noto Peninsula itself: the main shock. Counting lines is a crude way to ask about JSON (a place name in another field would count too), and Reshaping JSON with jq asks the same questions properly.

The 1 computed in the first tally is the file, read once and kept: the later lines found it in the cache, which is their 1 cached. None of the text tools’ answers were kept, which is every uncacheable. The kernel says why, if you ask:

source urn:kernel:uncached

It printed this, on ikigai-cli 0.2.5:

uncached (why a computed read was not cached; the last 64, most recent first)
  urn:text:wc        ×1  one-shot content b3:236f4cfad7f59de75883fed22dcbc1c0c8a7982e0fc989f1d526f0271e335199; upstream  [root]
  urn:text:grep      ×1  one-shot content b3:4b04300f5127c249fc59806380cb1a6ab4e2a4d6c9f2f5997dcc33e09b0be210  [root]
  urn:text:wc        ×1  one-shot content b3:aedbd75b64018f949e9dabc1a83829e1bc9559c7c1877715b2a1eb17e893a1e9; upstream  [root]
  urn:text:grep      ×1  one-shot content b3:4939de7f64f2a335e38e45e1a998737dc6e20fa39b99171c1c8321e1397d7696  [root]
  urn:text:wc        ×1  one-shot content b3:f06db695a3cfc70b1fd99f5e02b5b3528bc5d8589feb3d7403b9fd8a5662232d  [root]

[uncacheable]

The pipe hands each text tool what came before it as a stream, read as it arrives, under a content id made up for that one hand-over (the b3: names, which are new every time, so this block is not replayed with the one above). An answer built on input that can be read only once is never stored (one-shot content), and a wc after a grep was handed a stream that was itself one-shot (upstream). The same tools over a short value typed on the line are cached as usual. This is a gap in the text tools, recorded as ledger #2530, not something to work around: for now, the questions worth caching go through urn:json:query, as the page’s do. (On ikigai-cli 0.2.4 the counts were the same, but a text tool reading the file itself was tallied as computed, and the reason given for it was the cache’s policy.)

Next, the same questions about data that is not yours and not still: Somebody else’s resource.

Somebody else’s resource

The recorded week is yours: a file in your workspace, which changes only when you change it. The live feed is USGS’s. They decide what it says, they change it every minute, and they say how long a copy of it stays good. This chapter reads it through the kernel and shows what the kernel does with each of those facts.

This page’s kernel does not reach the network, so every step here is for the ikigai REPL you started in A week of earthquakes. That is a choice, not a limit of the browser: USGS lets any page read this feed, and ikigai-http runs in a browser over its fetch. But every Run cell in this book is checked against the answer printed beside it, and a feed that changes every minute would never match, so the live steps stay in your REPL. The answers below are what ikigai-cli 0.2.6 printed on 2026-10-29. Yours will have different numbers, because the feed has moved on since.

1. A URL is a name

urn:http:get reads a URL and answers what the server sent. The past week of earthquakes of magnitude 4.5 and up is https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_week.geojson. Reading it prints about 130 KB, so pipe it to urn:text:wc, which counts lines:

source urn:http:get url=https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_week.geojson | urn:text:wc
187
[2 computed]

The feed uses the same layout as the recorded week, one earthquake to a line, so this week had 187.

2. How long a copy stays good

Ask the cache about the same request:

cache urn:http:get url=https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_week.geojson
cached

The kernel kept the answer, because USGS said it could. Ask the server directly, with curl, and it says for how long:

curl -sI https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_week.geojson | grep -i cache-control
cache-control: public, max-age=60

Sixty seconds. urn:http:get reads that header and gives its answer the same lifetime. Wait a minute and ask again:

cache urn:http:get url=https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_week.geojson
source urn:http:get url=https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_week.geojson | urn:text:wc
not cached
187
[2 computed]

The copy expired on USGS’s schedule, and the next read fetched the feed again. The count was computed again too, though nobody asked for that: an answer is only as fresh as what it was computed from. Anything you build on the live feed is good for a minute at most, and anything built on the recorded week is good until the file changes. That difference comes back later in this part, when a page joins the two.

3. The URL is a thread

In A lab with names in it, the answers computed from the recorded week hung from a thread named after the file. A URL works the same way: the thread is the URL itself. Ask what hangs from it now:

source urn:kernel:dependents thread=https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_week.geojson
dependents of https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_week.geojson  (cut 0 times)
  2 live cached entries hang from it — a cut makes them stale, and the next read of each recomputes it
  urn:http:get  application/json           128.1 KB  2 threads   root
  urn:text:wc   text/plain                      3 B  3 threads   root

[uncacheable]

The feed and the count computed from it, which is the read you just made.

You do not have to wait out the minute. If you know the feed has changed (an earthquake was just in the news), cut the thread, and both answers go stale at once:

sink urn:kernel:cut https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_week.geojson
cache urn:http:get url=https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_week.geojson
cut https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_week.geojson

[uncacheable]
not cached

4. Keeping a copy

A copy you keep is yours again. Pipe the feed into a file in the workspace:

source urn:http:get url=https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_week.geojson | sink urn:file:week.json
wrote 131126 bytes to week.json
[1 computed · 1 uncacheable]

The write is uncacheable (it has to happen every time you ask), and the read before it was computed, because the cut above made the cached copy stale. urn:file:week.json now holds the week as it was a moment ago. It no longer expires after a minute; it changes when you write it again. Reshaping JSON with jq asks it questions. The name ends in .json on purpose, which that chapter explains.

5. Only the hosts you allow

urn:http:get can read any URL the session’s authority allows, and the session starts with all of it: root. A session can give that up. cap narrows the session’s capability, and urn:cap:net:earthquake.usgs.gov is the authority to reach that one host and nothing else:

cap urn:cap:net:earthquake.usgs.gov
cache urn:http:get url=https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_week.geojson
source urn:http:get url=https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_week.geojson | urn:text:wc
source urn:http:get url=https://example.com/
cap reset
narrowed — capability: urn:cap:net:earthquake.usgs.gov
not cached
187
[2 computed]
error: denied: capability does not grant `GET` to `example.com/`
reset to identity — capability: root (full authority)

USGS still answers, and any other host is refused before a request is sent. The refusal names what was asked for and what the session lacks.

The not cached on the second line is not a mistake. The feed had just been read, but by a session with different authority, and the cache keeps answers apart by the authority that computed them. A narrower session cannot be handed an answer it might not have been allowed to compute.

cap reset gives the session its full authority back. The narrowing lasts until you reset it, so reset it before going on.

Next, the questions get sharper: Reshaping JSON with jq.

Reshaping JSON with jq

A lab with names in it asked the recorded week small questions through urn:json:query. This chapter is about the questions. They are written in jq, the language of the jq command: if you have used jq at a shell, you already know it, and if you have not, every program here is short and explained.

urn:json:query runs jq as a resource. It has three arguments:

  • src, the name of the JSON to read, read through the kernel with your authority;
  • q, the jq program;
  • as, the form of the answer (JSON by default).

The Run cells use the recorded week in this page’s kernel. The last two sections do the same at a shell, where the questions and templates become files.

1. Paths, filters and reductions

A GeoJSON feed is an object whose features array holds one object per earthquake. Each has an id, a geometry (where), and properties (what): mag, place, time, magType, title, and the url of USGS’s page for it. A path picks a value out, and {mag, place, url} builds a smaller object from the fields it names:

{"mag":5.1,"place":"77 km ESE of Fangale’ounga, Tonga","url":"https://earthquake.usgs.gov/earthquakes/eventpage/us6000m2di"}
[computed]

max_by finds the largest by a field, select keeps what passes a test, and [ … ] collects the results into one array:

M 7.5 - 2024 Noto Peninsula, Japan Earthquake
[computed]
["M 6.2 - 8 km SW of Anamizu, Japan","M 7.5 - 2024 Noto Peninsula, Japan Earthquake"]
[computed]

Two earthquakes of magnitude 6 or more that week, both in Japan, on the same day: the Noto Peninsula earthquake and an aftershock near Anamizu, a town on the peninsula.

Sorting and grouping work the same way. The five largest magnitudes, and how many earthquakes each magnitude type measured (mb, body-wave magnitude, is the usual one for smaller and distant earthquakes; the mw types are moment magnitudes):

[7.5,6.2,5.8,5.7,5.6]
[computed]
[{"type":"mb","count":143},{"type":"mwb","count":1},{"type":"mwr","count":7},{"type":"mww","count":27}]
[computed]

2. One answer, or many

A jq program yields a stream of results, and a resource answers once. So urn:json:query says how the stream becomes one answer, and as picks the rule:

  • leave it out (application/json), and one result is the answer, while several become one JSON array;
  • as=application/x-ndjson gives one result per line, which is the list shape .. maps over;
  • as=text/plain gives one result as plain text, and refuses anything else, because a template splicing text needs exactly one value.
["us6000m2di","us6000m2d7"]
[computed]
"us6000m2di"
"us6000m2d7"
[computed]
error: invalid argument `as`: as=text/plain answers exactly one scalar result, and the program yielded 2; leave `as` out for JSON
       `as`: the answer's media type (one of: application/json, application/x-ndjson, text/plain)

The refusal says what went wrong and what to do instead. It is not cached: the next attempt asks again, so a fixed program gets a fresh answer.

3. Every answer remembers the file

Run a question twice and the second answer is cached. Run a different question over the same file and it is computed, but the file it reads is not read again:

2
[computed]
2
[cached]
trace  urn:json:query
  client      ikigai repl  ·  capability: root (full authority)
  transport   embedded · in-process
 
urn:json:query   json-query · computed · ThreadId(1) · — · answered-by=urn:iki:space:json   → 95b  ["M 4.9 - 8 km SE of Seldovia Village, A…
└─ urn:file:quakes-2024-01-01.geojson   quakes-window · cached · ThreadId(1) · —

tsunami is a flag USGS sets to 1 on larger earthquakes in oceanic regions; it does not say whether a tsunami happened. Two that week: the Noto Peninsula earthquake, and a small one near Seldovia, Alaska. The trace shows the second question computed, over a file already in the cache. Each answer hangs from the file’s thread, as every answer in A lab with names in it did, so a change to the file recomputes exactly these.

4. A template that asks questions

urn:iki:fn:compose fills a template, and its $j{<src> <program>} marker splices the answer to a jq question into the text, as plain text. This page holds one template, at urn:quakes:template:headline:

The week of 2024-01-01: $j{urn:file:quakes-2024-01-01.geojson .features|length} earthquakes of M4.5 and up; the largest, $j{urn:file:quakes-2024-01-01.geojson .features | max_by(.properties.mag) | .properties.title}.
[computed]
The week of 2024-01-01: 178 earthquakes of M4.5 and up; the largest, M 7.5 - 2024 Noto Peninsula, Japan Earthquake.
[computed]

Each marker is a source and a program, separated by a space; the program runs to the end of the marker, so it can hold pipes. $j is shorthand: each marker is the request urn:json:query with as=text/plain, which is why the program must answer one value. The template is a resource too, so its answer hangs from the file’s thread and the template’s.

5. At a shell: the type comes from the name

The REPL runs the same questions. Over the copy of the recorded week you downloaded in A week of earthquakes, type the line after ikigai>:

ikigai> source urn:json:query src=urn:file:quakes-2024-01-01.geojson q=".features|length"
178
[computed]

A file has no type of its own. The CLI’s file workspace takes one from the name’s extension: .json is JSON, and .geojson is GeoJSON’s registered type, application/geo+json, which is JSON too, so the query reads it as the page’s copy is read. (ikigai-cli 0.2.5 and later. On 0.2.4 the workspace did not know .geojson and served the file as plain text, which the query refused, is text/plain, and nothing this kernel mounts converts it to application/json; ledger #2529. There, copy it to a name ending in .json first: source urn:file:quakes-2024-01-01.geojson | sink urn:file:quakes-2024-01-01.json.)

The answers in sections 5 and 6 over the recorded week are replayed against ikigai-cli 0.2.6 by the book’s checks, over the same file the download fetches. The ones over the live week cannot be: they are what ikigai-cli 0.2.4 printed over the feed as it was on 2026-10-28, and yours will differ. The live week you saved in Somebody else’s resource is week.json:

source urn:json:query src=urn:file:week.json q=".features | max_by(.properties.mag) | .properties.title" as=text/plain
M 7.0 - 191 km ENE of Nagqu, China
[computed]

6. At a shell: programs and templates are files

A jq program you will use again belongs in a file, and a file is a resource. Write one with sink; the quotation marks make the program one value, so its | is part of the program and not a pipe, and the marks themselves are not stored. Then pipe the file into the query: a piped value fills the one argument you did not name, here q.

ikigai> sink urn:file:queries/biggest.jq ".features | max_by(.properties.mag) | .properties.title"
wrote 55 bytes to queries/biggest.jq
[uncacheable]
ikigai> source urn:file:queries/biggest.jq | urn:json:query src=urn:file:quakes-2024-01-01.geojson as=text/plain
M 7.5 - 2024 Noto Peninsula, Japan Earthquake
[2 computed]

The sink made the queries directory too. The file is an ordinary one, ~/quakes/queries/biggest.jq, so an editor or jq -f reads it as well. The same program over the live week:

source urn:file:queries/biggest.jq | urn:json:query src=urn:file:week.json as=text/plain
M 7.0 - 191 km ENE of Nagqu, China
[1 cached · 1 computed]

One program, two weeks. The program’s own file is a thread as well: edit it and both answers go stale.

A template is a file the same way. Write the headline from the page as one, over the live week, and fill it:

sink urn:file:headline.txt This week: $j{urn:file:week.json .features|length} earthquakes of M4.5 and up; the largest, $j{urn:file:week.json .features | max_by(.properties.mag) | .properties.title}.
source urn:iki:fn:compose src=urn:file:headline.txt
wrote 171 bytes to headline.txt
[uncacheable]
This week: 189 earthquakes of M4.5 and up; the largest, M 7.0 - 191 km ENE of Nagqu, China.
[computed]

The headline is as fresh as the copy it reads: save the feed to week.json again and the next compose recomputes it. Pointing the template straight at urn:http:get with its url needs a request with arguments to be named as one resource. Named requests (ledger #2447) do that, with the REPL’s bind, from ikigai-cli 0.2.6; this page was written before that release and does not use them yet, and the page this part builds later is where it matters.

Next, the questions get names of their own under urn:quakes:, and the data becomes a graph that keeps USGS’s names for each earthquake: Giving the data names. Later in this part, the headline grows into a page.

Giving the data names

The questions so far knew what mag means because you read USGS’s documentation. Nothing in the file says so: to the file, mag is three letters, and another feed’s mag could mean anything. This chapter gives the recorded week names that say what they mean, and then asks it questions in SPARQL, the query language for data named that way.

Two kinds of name are involved, and they belong to different people:

  • The earthquakes are USGS’s. Each one already has a page of its own: its id after https://earthquake.usgs.gov/earthquakes/eventpage/. That is a real address, and USGS describes the earthquake there. So it is the earthquake’s name here too.
  • The words are ours. mag, place and magType need names that someone publishes and anyone can look up. USGS publishes no such vocabulary, so this book does, under https://ikigai-rs.dev/ns/quakes#, on the project’s own domain. A word made up under USGS’s domain would be one USGS never wrote and nobody could look up.

The Run cells use the recorded week in this page’s kernel. The last section does the same at a shell, where the context is a file you edit.

1. The context is data

A JSON-LD context is a JSON document that says what another JSON document’s keys mean. Here is the one for USGS’s feed. It is a resource like the week, at urn:file:quakes.context.jsonld:

{
  "@context": {
    "@version": 1.1,
    "@base": "https://earthquake.usgs.gov/earthquakes/eventpage/",
    "qk": "https://ikigai-rs.dev/ns/quakes#",
    "geojson": "https://purl.org/geojson/vocab#",
    "schema": "https://schema.org/",
    "xsd": "http://www.w3.org/2001/XMLSchema#",
    "id": "@id",
    "type": "@type",
    "FeatureCollection": "geojson:FeatureCollection",
    "Feature": "geojson:Feature",
    "Point": "geojson:Point",
    "earthquake": "qk:Earthquake",
    "features": {"@id": "geojson:features", "@container": "@set"},
    "geometry": "geojson:geometry",
    "coordinates": {"@id": "geojson:coordinates", "@container": "@list"},
    "properties": "@nest",
    "mag": {"@id": "qk:mag", "@type": "xsd:double"},
    "magType": "qk:magType",
    "place": "qk:place",
    "time": "qk:time",
    "felt": "qk:felt",
    "title": "schema:name",
    "url": {"@id": "schema:url", "@type": "@id"}
  }
}
[computed]

Read it from the top:

  • @base is where a bare id is resolved, so the id us6000m0xl names https://earthquake.usgs.gov/earthquakes/eventpage/us6000m0xl, USGS’s page for it.
  • qk, geojson, schema and xsd are prefixes: qk:mag is short for https://ikigai-rs.dev/ns/quakes#mag.
  • type, Feature, features, geometry, Point and coordinates are GeoJSON’s own structure, and they keep the names the GeoJSON-LD vocabulary already gives them. coordinates is an @list because its order is its meaning: longitude, latitude, then depth in kilometers.
  • properties is @nest. GeoJSON puts an earthquake’s facts in an object of their own, and @nest says they are facts about the earthquake itself, not about that object. One of them is USGS’s "type": "earthquake", and the context maps the word earthquake to qk:Earthquake, so each one says what it is.
  • mag, magType, place, time and felt are ours, under qk:. mag is typed as a floating-point number (xsd:double), so that a magnitude of 5 and one of 5.1 are the same kind of value.
  • title and url mean what schema.org’s name and url already mean, so they are those.

The vocabulary says what each of our words carries and in what unit. It is the file books/ikigai/ns/quakes.ttl in this book’s repository, which https://ikigai-rs.dev/ns/quakes serves, and it says there why each word that is ours is not borrowed from somewhere else. A check in the book’s build holds the context and the vocabulary to each other: every word the context maps to qk: is defined there, and every word defined there is one the context maps.

A key the context does not name means nothing to it, and it is left out. Each earthquake’s properties has 26 keys; this context names eight of them.

2. The week, with its context

USGS’s file does not mention the context, and it is not ours to change. So a template puts the two together, the way Reshaping JSON with jq filled a headline. This page holds it at urn:quakes:template:graph:

{"@context": $r{urn:json:query?src=urn:file:quakes.context.jsonld&q=.[]}, "@graph": [$r{urn:file:quakes-2024-01-01.geojson}]}
[computed]

$r{…} splices a resource in as it is. The first marker asks jq for the context out of its file (.[] is the one value inside {"@context": …}), and the second is the whole week. Filled, the template is the week as JSON-LD: GeoJSON with a context.

urn:sparql:describe reads a graph and answers everything it says about one name. Its graph argument is the name of a graph to read, so the filled template needs a name of its own: in this page it is urn:quakes:graph. Ask about the Noto Peninsula earthquake, by USGS’s name for it:

<https://earthquake.usgs.gov/earthquakes/eventpage/us6000m0xl> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <https://ikigai-rs.dev/ns/quakes#Earthquake> .
<https://earthquake.usgs.gov/earthquakes/eventpage/us6000m0xl> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <https://purl.org/geojson/vocab#Feature> .
<https://earthquake.usgs.gov/earthquakes/eventpage/us6000m0xl> <https://ikigai-rs.dev/ns/quakes#felt> "422"^^<http://www.w3.org/2001/XMLSchema#integer> .
<https://earthquake.usgs.gov/earthquakes/eventpage/us6000m0xl> <https://ikigai-rs.dev/ns/quakes#mag> "7.5"^^<http://www.w3.org/2001/XMLSchema#double> .
<https://earthquake.usgs.gov/earthquakes/eventpage/us6000m0xl> <https://ikigai-rs.dev/ns/quakes#magType> "mww" .
<https://earthquake.usgs.gov/earthquakes/eventpage/us6000m0xl> <https://ikigai-rs.dev/ns/quakes#place> "2024 Noto Peninsula, Japan Earthquake" .
<https://earthquake.usgs.gov/earthquakes/eventpage/us6000m0xl> <https://ikigai-rs.dev/ns/quakes#time> "1704093009476"^^<http://www.w3.org/2001/XMLSchema#integer> .
<https://earthquake.usgs.gov/earthquakes/eventpage/us6000m0xl> <https://purl.org/geojson/vocab#geometry> _:b681 .
<https://earthquake.usgs.gov/earthquakes/eventpage/us6000m0xl> <https://schema.org/name> "M 7.5 - 2024 Noto Peninsula, Japan Earthquake" .
<https://earthquake.usgs.gov/earthquakes/eventpage/us6000m0xl> <https://schema.org/url> <https://earthquake.usgs.gov/earthquakes/eventpage/us6000m0xl> .
_:b681 <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <https://purl.org/geojson/vocab#Point> .
_:b681 <https://purl.org/geojson/vocab#coordinates> _:b682 .
_:b682 <http://www.w3.org/1999/02/22-rdf-syntax-ns#first> "137.271"^^<http://www.w3.org/2001/XMLSchema#double> .
_:b682 <http://www.w3.org/1999/02/22-rdf-syntax-ns#rest> _:b683 .
_:b683 <http://www.w3.org/1999/02/22-rdf-syntax-ns#first> "37.4874"^^<http://www.w3.org/2001/XMLSchema#double> .
_:b683 <http://www.w3.org/1999/02/22-rdf-syntax-ns#rest> _:b684 .
_:b684 <http://www.w3.org/1999/02/22-rdf-syntax-ns#first> "10"^^<http://www.w3.org/2001/XMLSchema#integer> .
_:b684 <http://www.w3.org/1999/02/22-rdf-syntax-ns#rest> <http://www.w3.org/1999/02/22-rdf-syntax-ns#nil> .
[computed]

Each line is one statement: a subject, a property, a value. Every earthquake fact is about <https://earthquake.usgs.gov/earthquakes/eventpage/us6000m0xl>, and every property is a full IRI, ours or someone else’s, that you can look up. The _: names are blank nodes, things the document did not name: the point, and the list of its coordinates, which RDF writes as a chain of rdf:first and rdf:rest. 422 people told USGS they felt this one.

The statement’s schema:url and its subject are the same IRI. USGS already gave every earthquake its name, in url; the context only had to say so.

3. What the graph holds

What the context does not name is not in the graph. Ask for every property the graph uses:

property
http://www.w3.org/1999/02/22-rdf-syntax-ns#first
http://www.w3.org/1999/02/22-rdf-syntax-ns#rest
http://www.w3.org/1999/02/22-rdf-syntax-ns#type
https://ikigai-rs.dev/ns/quakes#felt
https://ikigai-rs.dev/ns/quakes#mag
https://ikigai-rs.dev/ns/quakes#magType
https://ikigai-rs.dev/ns/quakes#place
https://ikigai-rs.dev/ns/quakes#time
https://purl.org/geojson/vocab#coordinates
https://purl.org/geojson/vocab#features
https://purl.org/geojson/vocab#geometry
https://schema.org/name
https://schema.org/url
[computed]

Thirteen, all of them named by the context, or by RDF itself for types and lists. sig, tsunami, status and the rest of USGS’s keys are not there. FROM <urn:quakes:graph> asks about this one graph: urn:sparql:select loads each graph it is given under its own name, beside a vocabulary of its own that the question would otherwise read too.

4. The questions are resources

A question you will ask again belongs at a name, like the program in Reshaping JSON with jq. This page holds three, at urn:quakes:question:count, …:biggest and …:by-magtype. Read one, then pipe it into urn:sparql:select: a piped value fills the one argument you did not name, here query. as=text/csv asks for the rows as CSV:

PREFIX qk: <https://ikigai-rs.dev/ns/quakes#> SELECT (COUNT(?quake) AS ?quakes) WHERE { ?quake a qk:Earthquake }
[computed]
quakes
178
[1 cached · 1 computed]

The 178 earthquakes of the week, counted by what they are rather than by how many lines the file has. The question was cached already, because the line above it read it; the answer was computed.

The largest, named by its USGS page:

quake,mag,place
https://earthquake.usgs.gov/earthquakes/eventpage/us6000m0xl,7.5,"2024 Noto Peninsula, Japan Earthquake"
[2 computed]

And how many each magnitude type measured, the answer the jq chapter’s group_by gave:

magType,quakes
mb,143
mwb,1
mwr,7
mww,27
[2 computed]

ORDER BY and GROUP BY are SPARQL’s sort and group. The questions themselves are in crates/quakes/queries/, one file each.

5. What hangs from the context

The context is a thread like the week. Everything read through it hangs from it:

dependents of urn:file:quakes.context.jsonld  (cut 0 times)
  8 live cached entries hang from it — a cut makes them stale, and the next read of each recomputes it
  urn:file:quakes.context.jsonld  application/ld+json           918 B  1 thread    root
  urn:json:query                  application/json              746 B  2 threads   root
  urn:quakes:graph                application/ld+json        123.6 KB  5 threads   root
  urn:sparql:describe             application/n-triples        2.1 KB  6 threads   root
  urn:sparql:select               text/csv                       13 B  7 threads   root
  urn:sparql:select               text/csv                       46 B  7 threads   root
  urn:sparql:select               text/csv                      123 B  7 threads   root
  urn:sparql:select               text/csv                      526 B  6 threads   root
[uncacheable]

The context itself; the jq answer inside the template; the graph, filled from the template; and every question asked of the graph. Cut the context’s thread, the way a change to the file would, and the graph goes stale with everything computed from it:

cut urn:file:quakes.context.jsonld
[uncacheable]
not cached
quakes
178
[1 cached · 1 computed]

The graph was filled again and the question asked of it again, and the count is still 178, because the context did not really change. The question’s own text did not depend on the context, so it was still cached. At a shell, the context is a file, and it can change.

6. At a shell

The REPL has the same resources. In the REPL you started over ~/quakes, download the context beside the recorded week:

cd ~/quakes
curl -sO https://raw.githubusercontent.com/ikigai-rs/ikigai-tutorial/main/books/ikigai/ns/quakes.context.jsonld

The steps below are replayed against ikigai-cli 0.2.6 by the book’s checks, over the same two files. Write the template from the page as a file of your own, fill it, and keep the filled copy as quakes.jsonld. This page uses no name like urn:quakes:graph at a shell: it was written before a request with arguments could be named as one resource, which the REPL’s bind does from ikigai-cli 0.2.6 (named requests, ledger #2447), and graph takes a name. So the copy is a file:

ikigai> sink urn:file:graph.txt {"@context": $r{urn:json:query?src=urn:file:quakes.context.jsonld&q=.[]}, "@graph": [$r{urn:file:quakes-2024-01-01.geojson}]}
wrote 125 bytes to graph.txt
[uncacheable]
ikigai> source urn:iki:fn:compose src=urn:file:graph.txt | sink urn:file:quakes.jsonld
wrote 126539 bytes to quakes.jsonld
[1 computed · 1 uncacheable]

The file workspace gives quakes.jsonld its type from its name, application/ld+json, so SPARQL reads it as JSON-LD. Write a question as a file too, and ask it:

ikigai> sink urn:file:queries/by-magtype.rq PREFIX qk: <https://ikigai-rs.dev/ns/quakes#> SELECT ?magType (COUNT(?quake) AS ?quakes) WHERE { ?quake qk:magType ?magType } GROUP BY ?magType ORDER BY ?magType
wrote 161 bytes to queries/by-magtype.rq
[uncacheable]
ikigai> source urn:file:queries/by-magtype.rq | urn:sparql:select graph=urn:file:quakes.jsonld as=text/csv
magType,quakes
mb,143
mwb,1
mwr,7
mww,27
[2 computed]

Now change what the week means. Open quakes.context.jsonld in an editor and delete the magType line, or let jq do it, which is what the book’s check does. Then fill the template again, and ask again:

ikigai> source urn:json:query src=urn:file:quakes.context.jsonld q="del(.[].magType)" | sink urn:file:quakes.context.jsonld
wrote 736 bytes to quakes.context.jsonld
[1 computed · 1 uncacheable]
ikigai> source urn:iki:fn:compose src=urn:file:graph.txt | sink urn:file:quakes.jsonld
wrote 126516 bytes to quakes.jsonld
[1 computed · 1 uncacheable]
ikigai> source urn:file:queries/by-magtype.rq | urn:sparql:select graph=urn:file:quakes.jsonld as=text/csv
magType,quakes
[1 cached · 1 computed]

The header, and no rows. USGS’s file still says "magType": "mww" for the Noto Peninsula earthquake, but the context no longer gives magType a meaning, so the graph has no magnitude types to group. The question did not change, and it was cached; the answer was computed again, because writing quakes.jsonld cut its thread. (jq wrote the context back on one line; an editor would have kept your layout.) Download the context again to put the line back.

The questions now have names, and the earthquakes keep USGS’s. Next, answers built from answers, one layer at a time: Composites, one layer at a time.

Composites, one layer at a time

A composite is a resource computed from other resources. The graph in Giving the data names was one already: a template, filled from the context and the week. This chapter builds taller ones, a layer at a time, and each layer is a name the next one reads. Then it asks the kernel what hangs from what, and what a composite costs when one of the things it reads changes every minute.

The Run cells use this page’s kernel and the recorded weeks, so their answers are the ones printed beside them. The shell steps that write files are replayed against ikigai-cli 0.2.6 by the book’s checks. The live feed’s steps are not, because the feed moves: they are what the REPL printed on 2026-10-29, and they say so.

1. Filter: a question whose answer is a graph

The questions in the last chapter answered rows. A layer that another layer reads has to answer something that can be read the same way the graph was: a graph. SPARQL’s CONSTRUCT does that. This one keeps the earthquakes of magnitude 5.5 and up, with the facts the next layers need:

PREFIX qk: <https://ikigai-rs.dev/ns/quakes#> CONSTRUCT { ?quake a qk:Earthquake ; qk:mag ?mag ; qk:magType ?magType ; qk:place ?place ; qk:time ?time } WHERE { ?quake a qk:Earthquake ; qk:mag ?mag ; qk:magType ?magType ; qk:place ?place ; qk:time ?time FILTER(?mag >= 5.5) }
[computed]

FILTER(?mag >= 5.5) is the filter; everything else copies statements across. Because mag is typed as a number by the context, >= compares numbers, not text.

This page holds the question’s answer at a name of its own, urn:quakes:strong: the question read through the kernel and asked of urn:quakes:graph, whenever someone reads the name. So the next layer can ask its questions of it, exactly as the last chapter asked them of the graph. The count question from that chapter, unchanged, now counts the strong ones:

quakes
12
[2 computed]
cached

Twelve of the week’s 178, and the filtered graph is now cached under its name. The count question did not change: a qk:Earthquake matched because the filter’s CONSTRUCT kept each earthquake’s type.

In this page the questions are fixed, because they are compiled into it. At a shell each one is a .rq file you write and edit, and section 6 writes one.

2. Sort: biggest first

The second layer reads the first by name. ORDER BY DESC(?mag) puts the biggest first, and ?place after it breaks ties, so the order is the same every time:

quake,mag,place
https://earthquake.usgs.gov/earthquakes/eventpage/us6000m0xl,7.5,"2024 Noto Peninsula, Japan Earthquake"
https://earthquake.usgs.gov/earthquakes/eventpage/us6000m0xm,6.2,"8 km SW of Anamizu, Japan"
https://earthquake.usgs.gov/earthquakes/eventpage/us6000m0xj,5.8,"42 km NE of Anamizu, Japan"
https://earthquake.usgs.gov/earthquakes/eventpage/us6000m1ld,5.7,"125 km W of Houma, Tonga"
https://earthquake.usgs.gov/earthquakes/eventpage/us6000m0ya,5.6,"54 km NE of Anamizu, Japan"
https://earthquake.usgs.gov/earthquakes/eventpage/us6000m0xt,5.6,"7 km N of Anamizu, Japan"
https://earthquake.usgs.gov/earthquakes/eventpage/us6000m2cv,5.6,Balleny Islands region
https://earthquake.usgs.gov/earthquakes/eventpage/us6000m26y,5.6,Carlsberg Ridge
https://earthquake.usgs.gov/earthquakes/eventpage/us6000m0y9,5.5,"56 km NE of Anamizu, Japan"
https://earthquake.usgs.gov/earthquakes/eventpage/us6000m1cg,5.5,"68 km WNW of Vinchina, Argentina"
https://earthquake.usgs.gov/earthquakes/eventpage/us6000m2d7,5.5,Balleny Islands region
https://earthquake.usgs.gov/earthquakes/eventpage/us6000m27f,5.5,southern East Pacific Rise
[2 computed]

Seven of the twelve are the Noto Peninsula earthquake and its aftershocks, near Anamizu.

3. Group and count: by magnitude type

The last chapter’s by-magtype question, asked of the strong layer instead of the whole week:

magType,quakes
mb,4
mwb,1
mww,7
[2 computed]

Over the whole week, mb (body-wave magnitude) measured 143 of 178. Among the strong ones, mww (moment magnitude, the measure USGS prefers for large earthquakes) measured most. The question is one resource, read by two layers; nothing was copied.

4. A join: the week after

USGS’s service gives any window you ask for. This page holds a second one, recorded the same way as the first: the week after, 2024-01-08 to 2024-01-15, 105 earthquakes, at urn:file:quakes-2024-01-08.geojson, and filled through the same context at urn:quakes:graph:2024-01-08.

urn:sparql:select loads every graph it is given, so a question can read the strong layer and the second week together. This one asks, for each region where the first week had a strong earthquake, how many earthquakes the second week had there. The region is the end of USGS’s place, after its last comma, and the two weeks are told apart by time: 1704672000000 is 2024-01-08T00:00Z in USGS’s milliseconds.

region,strongest,weekAfter
Japan,7.5,6
Tonga,5.7,9
Balleny Islands region,5.6,0
Carlsberg Ridge,5.6,0
Argentina,5.5,2
southern East Pacific Rise,5.5,0
[2 computed]

Six more in Japan, after the aftershocks of the first week; nine in Tonga; none on the ridges and the Balleny Islands. The OPTIONAL is the join: the second week’s earthquakes are matched to the first week’s strong ones on the region they share, and GROUP BY ?region counts them. The question is urn:quakes:question:week-after, a file like the others, in crates/quakes/queries/.

5. What hangs from what

Every layer read its sources through the kernel, so every answer hangs from the threads of what it read. The context is under everything:

dependents of urn:file:quakes.context.jsonld  (cut 0 times)
  10 live cached entries hang from it — a cut makes them stale, and the next read of each recomputes it
  urn:file:quakes.context.jsonld  application/ld+json           918 B  1 thread    root
  urn:json:query                  application/json              746 B  2 threads   root
  urn:quakes:graph                application/ld+json        123.6 KB  5 threads   root
  urn:quakes:graph:2024-01-08     application/ld+json         73.9 KB  5 threads   root
  urn:quakes:strong               text/turtle                  4.5 KB  8 threads   root
  urn:sparql:construct            text/turtle                  4.5 KB  6 threads   root
  urn:sparql:select               text/csv                       12 B  10 threads  root
  urn:sparql:select               text/csv                       36 B  10 threads  root
  urn:sparql:select               text/csv                      158 B  13 threads  root
  urn:sparql:select               text/csv                     1.1 KB  10 threads  root
[uncacheable]

Both graphs, the strong layer, the CONSTRUCT behind it, and each answer above. The join is the one with 13 threads: it read both weeks.

A question is a thread too. Ask what hangs from the filter’s:

dependents of urn:quakes:question:strong  (cut 0 times)
  6 live cached entries hang from it — a cut makes them stale, and the next read of each recomputes it
  urn:quakes:question:strong  application/sparql-query      275 B  1 thread    root
  urn:quakes:strong           text/turtle                  4.5 KB  8 threads   root
  urn:sparql:select           text/csv                       12 B  10 threads  root
  urn:sparql:select           text/csv                       36 B  10 threads  root
  urn:sparql:select           text/csv                      158 B  13 threads  root
  urn:sparql:select           text/csv                     1.1 KB  10 threads  root
[uncacheable]

The filter’s answer and everything read from it: the count, the sort, the grouping and the join. The graphs do not hang from it, and neither does the CONSTRUCT (the layer handed it the question’s text, not its name). Cut the question’s thread, the way a change to the .rq file would, and ask the grouping again:

cut urn:quakes:question:strong
[uncacheable]
not cached
cached
magType,quakes
mb,4
mwb,1
mww,7
[1 cached · 1 computed]

The strong layer was stale and was computed again; the graph under it was still cached, because the question is not one of its sources. The grouping’s own question was cached, and its answer recomputed, from the recomputed layer. A cut reaches exactly what hangs from the thread.

6. At a shell: your own notes

The REPL has none of this page’s urn:quakes: names, so at a shell each layer is a file. In the REPL you started over ~/quakes, download the week after beside the first:

cd ~/quakes
curl -sO https://raw.githubusercontent.com/ikigai-rs/ikigai-tutorial/main/crates/quakes/quakes-2024-01-08.geojson

Fill both weeks as JSON-LD, as the last chapter did for one (if you still have graph.txt and quakes.jsonld from it, the first two lines are done already):

ikigai> sink urn:file:graph.txt {"@context": $r{urn:json:query?src=urn:file:quakes.context.jsonld&q=.[]}, "@graph": [$r{urn:file:quakes-2024-01-01.geojson}]}
wrote 125 bytes to graph.txt
[uncacheable]
ikigai> source urn:iki:fn:compose src=urn:file:graph.txt | sink urn:file:quakes.jsonld
wrote 126539 bytes to quakes.jsonld
[1 computed · 1 uncacheable]
ikigai> sink urn:file:graph-2024-01-08.txt {"@context": $r{urn:json:query?src=urn:file:quakes.context.jsonld&q=.[]}, "@graph": [$r{urn:file:quakes-2024-01-08.geojson}]}
wrote 125 bytes to graph-2024-01-08.txt
[uncacheable]
ikigai> source urn:iki:fn:compose src=urn:file:graph-2024-01-08.txt | sink urn:file:quakes-2024-01-08.jsonld
wrote 75659 bytes to quakes-2024-01-08.jsonld
[1 computed · 1 uncacheable]

The notes are yours: a file for each earthquake you have something to say about, named after USGS’s id for it, under notes/. Write two, and the one that stands in for a note not written yet:

ikigai> sink urn:file:notes/us6000m0xl.txt Felt in Kanazawa. The tsunami warning came first.
wrote 49 bytes to notes/us6000m0xl.txt
[uncacheable]
ikigai> sink urn:file:notes/us6000m2jp.txt Mindanao again.
wrote 15 bytes to notes/us6000m2jp.txt
[uncacheable]
ikigai> sink urn:file:notes/none.txt no note yet
wrote 11 bytes to notes/none.txt
[uncacheable]

Now the earthquakes the notes are about: the ones of magnitude 6 and up in either week, with each one’s id. A question with both graphs reads both weeks; its answer goes to a file, because this page gives an answer with arguments no name of its own (the REPL’s bind can, from ikigai-cli 0.2.6: named requests, ledger #2447, which this part has not taken up yet), and urn:json:query turns the CSV into the JSON list the next step reads:

ikigai> sink urn:file:queries/notable.rq PREFIX qk: <https://ikigai-rs.dev/ns/quakes#> SELECT ?id ?mag ?place WHERE { ?quake qk:mag ?mag ; qk:place ?place FILTER(?mag >= 6) BIND(STRAFTER(STR(?quake), "eventpage/") AS ?id) } ORDER BY DESC(?mag)
wrote 202 bytes to queries/notable.rq
[uncacheable]
ikigai> source urn:file:queries/notable.rq | urn:sparql:select graph="urn:file:quakes.jsonld urn:file:quakes-2024-01-08.jsonld" as=text/csv | sink urn:file:notable.csv
wrote 216 bytes to notable.csv
[2 computed · 1 uncacheable]
ikigai> source urn:json:query src=urn:file:notable.csv q=. | sink urn:file:notable.json
wrote 299 bytes to notable.json
[1 computed · 1 uncacheable]

The join with your notes is a template for one row, filled once for each earthquake by urn:iki:fn:each. {id} is the row’s id, so urn:file:notes/{id}.txt is that earthquake’s note, and || names what to splice when there is none:

ikigai> sink urn:file:row.txt <li>M$h{{mag}} $h{{place}}: $h{urn:file:notes/{id}.txt || urn:file:notes/none.txt}</li>
wrote 87 bytes to row.txt
[uncacheable]
ikigai> source urn:iki:fn:each src=urn:file:notable.json template=urn:file:row.txt
<li>M7.5 2024 Noto Peninsula, Japan Earthquake: Felt in Kanazawa. The tsunami warning came first.</li><li>M6.7 93 km SE of Sarangani, Philippines: Mindanao again.</li><li>M6.4 45 km SSW of Jurm, Afghanistan: no note yet</li><li>M6.2 8 km SW of Anamizu, Japan: no note yet</li>
[computed]

One answer from three kinds of source: two recorded weeks of USGS’s, read as a graph; a template of yours; and your notes, one file each. (The rows come out on one line, as HTML list items: a line typed at the prompt cannot hold a line break, so the template cannot end with one.)

Ask what hangs from a note, and from a note that does not exist yet:

ikigai> source urn:kernel:dependents thread=urn:file:notes/us6000m0xl.txt
dependents of urn:file:notes/us6000m0xl.txt  (cut 2 times)
  2 live cached entries hang from it — a cut makes them stale, and the next read of each recomputes it
  urn:file:notes/us6000m0xl.txt  text/plain                     49 B  1 thread    root
  urn:iki:fn:each                text/plain                    276 B  8 threads   root

[uncacheable]
ikigai> source urn:kernel:dependents thread=urn:file:notes/us6000m31m.txt
dependents of urn:file:notes/us6000m31m.txt  (cut 0 times)
  1 live cached entry hangs from it — a cut makes it stale, and the next read of each recomputes it
  urn:iki:fn:each  text/plain                    276 B  8 threads   root

[uncacheable]

The list hangs from the note it spliced (its eight threads are the four notes it looked for, the fallback, the template, the JSON and its own name), and also from the note it looked for and did not find: the Jurm earthquake’s, us6000m31m. Write that note, and the list is stale; the CSV and the JSON under it are not, because no note is one of their sources:

ikigai> sink urn:file:notes/us6000m31m.txt East of Herat.
wrote 14 bytes to notes/us6000m31m.txt
[uncacheable]
ikigai> cache urn:iki:fn:each src=urn:file:notable.json template=urn:file:row.txt
not cached
ikigai> cache urn:json:query src=urn:file:notable.csv q=.
cached
ikigai> source urn:iki:fn:each src=urn:file:notable.json template=urn:file:row.txt
<li>M7.5 2024 Noto Peninsula, Japan Earthquake: Felt in Kanazawa. The tsunami warning came first.</li><li>M6.7 93 km SE of Sarangani, Philippines: Mindanao again.</li><li>M6.4 45 km SSW of Jurm, Afghanistan: East of Herat.</li><li>M6.2 8 km SW of Anamizu, Japan: no note yet</li>
[computed]

The files in the middle are where this differs from the page. notable.json is a copy: it does not hang from the weeks, and it changes only when you write it again. In the page, urn:quakes:strong is a name, not a copy, and a cut of what it read reaches it.

7. What a composite costs

A composite is cached for as long as everything it read is. The recorded weeks and your notes change only when someone writes them. The live feed from Somebody else’s resource is good for 60 seconds, because USGS says so. Put the feed into a composite and the composite is good for 60 seconds too.

These steps reach the network, so the book’s checks cannot replay them. They are what ikigai-cli 0.2.5 printed on 2026-10-29. Make the list a page fragment of its own, then join the live feed into it. A template can splice the feed by its URL, through urn:http:get; these steps count the answer’s lines with urn:text:wc rather than print 130 KB:

sink urn:file:report.txt <ul>$r{urn:iki:fn:each?src=urn:file:notable.json&template=urn:file:row.txt}</ul>
sink urn:file:report.txt <ul>$r{urn:iki:fn:each?src=urn:file:notable.json&template=urn:file:row.txt}</ul> $r{urn:http:get?url=https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_week.geojson}
source urn:iki:fn:compose src=urn:file:report.txt | urn:text:wc
cache urn:iki:fn:compose src=urn:file:report.txt
wrote 80 bytes to report.txt
[uncacheable]
wrote 176 bytes to report.txt
[uncacheable]
196
[2 computed]
cached

A minute later:

cache urn:iki:fn:compose src=urn:file:report.txt
cache urn:iki:fn:each src=urn:file:notable.json template=urn:file:row.txt
source urn:kernel:uncached
not cached
cached
uncached (why a computed read was not cached; the last 64, most recent first)
  urn:json:from-csv      ×1  policy  [root]

[uncacheable]

The report expired with the feed. The list inside it did not: it is a name of its own, and it read no feed. Everything that reads the report now expires every minute too, however expensive it is to compute.

urn:kernel:uncached says nothing about it: the one line there is from the CSV conversion at the shell step before. It logs computations that were never stored, and the report was stored; it was stored for a minute. Nothing in the kernel’s readouts says how long an entry has left, so the way to see the lifetime is the one above: ask cache again after it.

The fix is to join volatile data where it costs nothing to recompute: into a resource that is cheap, or already uncacheable, never into the expensive cached one. Put the report back as it was, and splice it by name into a small template of its own that adds the feed:

sink urn:file:report.txt <ul>$r{urn:iki:fn:each?src=urn:file:notable.json&template=urn:file:row.txt}</ul>
sink urn:file:live.txt $r{urn:iki:fn:compose?src=urn:file:report.txt} $r{urn:http:get?url=https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_week.geojson}
source urn:iki:fn:compose src=urn:file:live.txt | urn:text:wc
source urn:iki:fn:compose src=urn:file:report.txt
wrote 80 bytes to report.txt
[uncacheable]
wrote 142 bytes to live.txt
[uncacheable]
196
[2 computed]
<ul><li>M7.5 2024 Noto Peninsula, Japan Earthquake: Felt in Kanazawa. The tsunami warning came first.</li><li>M6.7 93 km SE of Sarangani, Philippines: Mindanao again.</li><li>M6.4 45 km SSW of Jurm, Afghanistan: East of Herat.</li><li>M6.2 8 km SW of Anamizu, Japan: no note yet</li></ul>
[cached]

The report was cached already: filling live.txt read it by name, and that read kept it. A minute later:

cache urn:iki:fn:compose src=urn:file:report.txt
cache urn:iki:fn:compose src=urn:file:live.txt
source urn:iki:fn:compose src=urn:file:live.txt | sink urn:file:page.html
cached
not cached
wrote 137620 bytes to page.html
[1 computed · 1 uncacheable]

Only the small template expired. The report it splices was still cached, so the next read computed one splice and one fetch, not the list. The write at the end is uncacheable anyway, which is the last place volatile data can go: a write happens every time it is asked for.

What each layer costs is what it reads. A layer that reads only recorded data and your files lasts until one of them is written; one that reads the feed lasts a minute; and a layer that reads another by name pays only for its own work when it is recomputed. Later in this part, a page built from them is served over HTTP.

Next, the copies in the middle get refreshed by a file of REPL lines instead of by hand, and a pipeline becomes a plan you can question before it runs: Process scripts are files.

Process scripts are files

Composites, one layer at a time ended with copies: quakes.jsonld, notable.csv, notable.json, each written by a REPL line, and each as old as the last time you typed that line. Typing them again by hand is the part a computer should do. This chapter keeps the lines in files and hands the files to ikigai: first as a script it replays line by line, then as a plan, a pipeline stored as data that can be checked before it runs, asked what authority it needs, and run again from the cache. Last, a script whose answer is another script.

What you need

The ~/quakes directory from the last chapter, and a shell in it. Every step here is a shell command, not a line at the REPL prompt; quit the REPL from the last chapter first, or use another terminal.

cd ~/quakes

The book’s checks run every $ line on this page against ikigai-cli 0.2.6, in order, in a fresh directory holding the two recorded weeks and the context, so the answers are the ones you get. $PWD in an answer stands for your own directory. Each ikigai command below starts a new REPL, so nothing is cached from one $ line to the next.

1. A file of REPL lines

A script is a text file with one REPL line on each line. This one holds what the composites are made from: the two fill templates, the question, the row and report templates, and the stand-in for a note not written yet. If you worked through the last chapter, you have all of them, and replaying it writes the same bytes again. If you are starting here, it is how you catch up. Save it as chapter5.iki:

sink urn:file:graph.txt {"@context": $r{urn:json:query?src=urn:file:quakes.context.jsonld&q=.[]}, "@graph": [$r{urn:file:quakes-2024-01-01.geojson}]}
sink urn:file:graph-2024-01-08.txt {"@context": $r{urn:json:query?src=urn:file:quakes.context.jsonld&q=.[]}, "@graph": [$r{urn:file:quakes-2024-01-08.geojson}]}
sink urn:file:queries/notable.rq PREFIX qk: <https://ikigai-rs.dev/ns/quakes#> SELECT ?id ?mag ?place WHERE { ?quake qk:mag ?mag ; qk:place ?place FILTER(?mag >= 6) BIND(STRAFTER(STR(?quake), "eventpage/") AS ?id) } ORDER BY DESC(?mag)
sink urn:file:row.txt <li>M$h{{mag}} $h{{place}}: $h{urn:file:notes/{id}.txt || urn:file:notes/none.txt}</li>
sink urn:file:report.txt <ul>$r{urn:iki:fn:each?src=urn:file:notable.json&template=urn:file:row.txt}</ul>
sink urn:file:notes/none.txt no note yet

ikigai --quiet with a file on its standard input is the REPL with nobody at the keyboard: no greeting, no prompt, each line read and answered in turn, and the answers printed exactly as they would be at the prompt:

$ ikigai --quiet --files . < chapter5.iki
wrote 125 bytes to graph.txt
[uncacheable]
wrote 125 bytes to graph-2024-01-08.txt
[uncacheable]
wrote 202 bytes to queries/notable.rq
[uncacheable]
wrote 87 bytes to row.txt
[uncacheable]
wrote 80 bytes to report.txt
[uncacheable]
wrote 11 bytes to notes/none.txt
[uncacheable]

--files . makes this directory the file workspace, as --files ~/quakes did when you started the REPL in A week of earthquakes.

The refresh is the other half: the lines that read those sources and write the copies. Save it as refresh.iki:

source urn:iki:fn:compose src=urn:file:graph.txt | sink urn:file:quakes.jsonld
source urn:iki:fn:compose src=urn:file:graph-2024-01-08.txt | sink urn:file:quakes-2024-01-08.jsonld
source urn:file:queries/notable.rq | urn:sparql:select graph="urn:file:quakes.jsonld urn:file:quakes-2024-01-08.jsonld" as=text/csv | sink urn:file:notable.csv
source urn:json:query src=urn:file:notable.csv q=. | sink urn:file:notable.json
source urn:iki:fn:compose src=urn:file:report.txt | sink urn:file:report.html

Replay it, and ask the shell how it went:

$ ikigai --quiet --files . < refresh.iki; echo "exit $?"
wrote 126539 bytes to quakes.jsonld
[1 computed · 1 uncacheable]
wrote 75659 bytes to quakes-2024-01-08.jsonld
[1 computed · 1 uncacheable]
wrote 216 bytes to notable.csv
[2 computed · 1 uncacheable]
wrote 299 bytes to notable.json
[1 computed · 1 uncacheable]
wrote 243 bytes to report.html
[1 computed · 1 uncacheable]
exit 0
$ cat report.html
<ul><li>M7.5 2024 Noto Peninsula, Japan Earthquake: no note yet</li><li>M6.7 93 km SE of Sarangani, Philippines: no note yet</li><li>M6.4 45 km SSW of Jurm, Afghanistan: no note yet</li><li>M6.2 8 km SW of Anamizu, Japan: no note yet</li></ul>

Every copy is rewritten from its sources, in order, and the page fragment at the end is new. (If you wrote notes in the last chapter, the report has them where this one says no note yet, and the byte count is larger.) After an edit to the context, the question or a template, this one command brings every copy up to date.

When a line fails

Here is the same refresh with one name misspelled on its first line, as typo.iki:

source urn:iki:fn:compose src=urn:file:grahp.txt | sink urn:file:quakes.jsonld
source urn:file:queries/notable.rq | urn:sparql:select graph="urn:file:quakes.jsonld urn:file:quakes-2024-01-08.jsonld" as=text/csv | sink urn:file:notable.csv
$ ikigai --quiet --files . < typo.iki; echo "exit $?"
error: not found: `urn:file:grahp.txt` does not exist
wrote 216 bytes to notable.csv
[2 computed · 1 uncacheable]
exit 1

The exit status is 1, so a shell script, a make rule or a CI job that runs the replay sees that it failed. But look at what happened in between. ikigai-cli 0.2.6 did not stop at the error: the line REPL goes on after a failed line (its --help says so), and only the exit status remembers. The second line ran, and it read the quakes.jsonld the first line failed to write, which is the one from before. Here that copy happened to be current. After a change to the context it would not be, and notable.csv would be rewritten from a stale graph, with nothing on the screen to say so but the error two lines up.

So read a replay’s output, not only its exit status, and keep a script short enough to read. A line that depends on the one before it is safe only if the one before it worked.

2. A plan is a file too

A script is text, and the only way to know what it does is to run it. A plan is the other kind of stored pipeline: the REPL’s plan command turns a pipeline into a graph of steps, an ik:Process in Turtle, and runs nothing. Write one for the notable question:

$ mkdir -p plans
$ ikigai --quiet --files . -c 'plan urn:file:queries/notable.rq | urn:sparql:select graph="urn:file:quakes.jsonld urn:file:quakes-2024-01-08.jsonld" as=text/csv' > plans/notable.ttl
$ cat plans/notable.ttl
@prefix ik:   <https://ikigai-rs.dev/ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .

<urn:plan:b3:9e31f4489224566ca02376654b32db3de25043f7bdc0d1642933cd18022ddee6> a ik:Process ;
    rdfs:comment "urn:file:queries/notable.rq | urn:sparql:select graph=\"urn:file:quakes.jsonld urn:file:quakes-2024-01-08.jsonld\" as=text/csv" ;
    ik:step <urn:plan:b3:9e31f4489224566ca02376654b32db3de25043f7bdc0d1642933cd18022ddee6:step:1> ,
            <urn:plan:b3:9e31f4489224566ca02376654b32db3de25043f7bdc0d1642933cd18022ddee6:step:2> ;
    ik:result <urn:plan:b3:9e31f4489224566ca02376654b32db3de25043f7bdc0d1642933cd18022ddee6:step:2> .

<urn:plan:b3:9e31f4489224566ca02376654b32db3de25043f7bdc0d1642933cd18022ddee6:step:1> a ik:Step ;
    ik:verb "Source" ;
    ik:resolves <urn:file:queries/notable.rq> .

<urn:plan:b3:9e31f4489224566ca02376654b32db3de25043f7bdc0d1642933cd18022ddee6:step:2> a ik:Step ;
    ik:verb "Source" ;
    ik:resolves <urn:sparql:select> ;
    ik:pipeFrom <urn:plan:b3:9e31f4489224566ca02376654b32db3de25043f7bdc0d1642933cd18022ddee6:step:1> ;
    ik:argument <urn:plan:b3:9e31f4489224566ca02376654b32db3de25043f7bdc0d1642933cd18022ddee6:step:2:arg:as> ,
                <urn:plan:b3:9e31f4489224566ca02376654b32db3de25043f7bdc0d1642933cd18022ddee6:step:2:arg:graph> .

<urn:plan:b3:9e31f4489224566ca02376654b32db3de25043f7bdc0d1642933cd18022ddee6:step:2:arg:as> a ik:Argument ;
    ik:inputName "as" ;
    ik:value "text/csv" .

<urn:plan:b3:9e31f4489224566ca02376654b32db3de25043f7bdc0d1642933cd18022ddee6:step:2:arg:graph> a ik:Argument ;
    ik:inputName "graph" ;
    ik:value "urn:file:quakes.jsonld urn:file:quakes-2024-01-08.jsonld" .

-c runs one REPL line and exits, and plan prints the graph, so the shell’s > puts it in a file. Two steps: step 1 reads the question, step 2 is urn:sparql:select with its two arguments and its input piped from step 1, and the plan’s result is step 2’s. The plan’s own name is a BLAKE3 hash (b3:), and the same line makes the same name every time.

execute reads a plan by its name and runs it. Run it twice in one REPL:

$ ikigai --quiet --files . -c 'execute urn:file:plans/notable.ttl' -c 'execute urn:file:plans/notable.ttl'
id,mag,place
us6000m0xl,7.5,"2024 Noto Peninsula, Japan Earthquake"
us6000m2jp,6.7,"93 km SE of Sarangani, Philippines"
us6000m31m,6.4,"45 km SSW of Jurm, Afghanistan"
us6000m0xm,6.2,"8 km SW of Anamizu, Japan"
[3 computed]
id,mag,place
us6000m0xl,7.5,"2024 Noto Peninsula, Japan Earthquake"
us6000m2jp,6.7,"93 km SE of Sarangani, Philippines"
us6000m31m,6.4,"45 km SSW of Jurm, Afghanistan"
us6000m0xm,6.2,"8 km SW of Anamizu, Japan"
[2 cached · 1 computed]
— batch: 2 commands · 2 cached · 4 computed

The first run computed three things. The second computed one, and the other two, [2 cached], came from the cache, because a plan’s steps are ordinary reads through the kernel and are cached like any other. The last line is the -c batch’s own tally of both.

3. The authority a plan needs

A script cannot tell you what it will touch until it runs. A plan can, because each step names the resource it resolves, and every resource publishes a contract that says what authority it requires. Three resources answer questions about a plan, each given the plan as in: urn:plan:validate checks that it is a plan this kernel can run, urn:plan:requires derives the capability it needs from its steps’ contracts, and urn:plan:eval runs it. None of them invokes a step to answer, except urn:plan:eval.

Make a plan that reaches the network: the live feed from Somebody else’s resource, counted. Writing the plan sends no request.

$ ikigai --quiet --files . -c 'plan urn:http:get url=https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_week.geojson | urn:text:wc' > plans/live.ttl

Each resource takes the plan as its one required input, so a plan on standard input fills it. Check it, and ask what it needs:

$ ikigai --quiet --files . -c 'source urn:plan:validate' < plans/live.ttl
<urn:plan:b3:7b4651c595e64668b9fbf7ab70b4c9248c40fe20ab31c6ab649f8c69e49d761e> conforms: it satisfies ikigai_vocab::SHAPES and the executor's checks
[computed]
$ ikigai --quiet --files . -c 'source urn:plan:requires' < plans/live.ttl
<urn:plan:b3:7b4651c595e64668b9fbf7ab70b4c9248c40fe20ab31c6ab649f8c69e49d761e> requires urn:cap:net:*
this capability holds all of it
  urn:plan:b3:7b4651c595e64668b9fbf7ab70b4c9248c40fe20ab31c6ab649f8c69e49d761e:step:1  Source <urn:http:get>  requires urn:cap:net:*
  urn:plan:b3:7b4651c595e64668b9fbf7ab70b4c9248c40fe20ab31c6ab649f8c69e49d761e:step:2  Source <urn:text:wc>  requires nothing
[computed]

The plan needs urn:cap:net:*, some authority to reach the network, and only for its first step; counting needs nothing. The session has all authority, so it holds all of it. Narrow the session first, the way Somebody else’s resource did, and ask again. cap read-only leaves the session able to read this directory’s files and nothing else:

$ ikigai --quiet --files . -c 'cap read-only' -c 'source urn:plan:requires' < plans/live.ttl
narrowed — capability: urn:cap:fs:read:$PWD
<urn:plan:b3:7b4651c595e64668b9fbf7ab70b4c9248c40fe20ab31c6ab649f8c69e49d761e> requires urn:cap:net:*
this capability lacks urn:cap:net:* — a run under it is refused at the steps below that lack it
  urn:plan:b3:7b4651c595e64668b9fbf7ab70b4c9248c40fe20ab31c6ab649f8c69e49d761e:step:1  Source <urn:http:get>  requires urn:cap:net:*  LACKS urn:cap:net:*
  urn:plan:b3:7b4651c595e64668b9fbf7ab70b4c9248c40fe20ab31c6ab649f8c69e49d761e:step:2  Source <urn:text:wc>  requires nothing
[computed]
— batch: 2 commands · computed

That is the question to ask before a plan runs, and the answer names the step that would be refused and what it lacks. Nothing was fetched to find out.

The answer has a limit, and the narrowing from Somebody else’s resource shows it. Narrow the session to a different host, example.com, and ask, then run the plan:

$ ikigai --quiet --files . -c 'cap net-example.com' -c 'source urn:plan:requires' < plans/live.ttl
narrowed — capability: urn:cap:net:example.com
<urn:plan:b3:7b4651c595e64668b9fbf7ab70b4c9248c40fe20ab31c6ab649f8c69e49d761e> requires urn:cap:net:*
this capability holds all of it
  urn:plan:b3:7b4651c595e64668b9fbf7ab70b4c9248c40fe20ab31c6ab649f8c69e49d761e:step:1  Source <urn:http:get>  requires urn:cap:net:*
  urn:plan:b3:7b4651c595e64668b9fbf7ab70b4c9248c40fe20ab31c6ab649f8c69e49d761e:step:2  Source <urn:text:wc>  requires nothing
[computed]
— batch: 2 commands · computed
$ ikigai --quiet --files . -c 'cap net-example.com' -c 'source urn:plan:eval' < plans/live.ttl; echo "exit $?"
narrowed — capability: urn:cap:net:example.com
error: denied: capability does not grant `GET` to `earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_week.geojson`
exit 1

urn:plan:requires said yes, and the run was refused, naming the host. Both are right about different things. urn:http:get’s contract requires urn:cap:net:*, which any network grant satisfies, urn:cap:net:example.com among them; which host a URL may reach is decided when the step runs, by the URL it is given, and that is where the run stopped, before any request was sent. So urn:plan:requires tells you what kind of authority a plan needs and which steps need it; the host is settled by running it under a capability that names the host, as that chapter’s session named earthquake.usgs.gov.

A plan cannot do more than its caller could do one request at a time: every step runs under the capability of whoever runs the plan, and meets its own target’s requirement. A plan you were sent is a file you can validate and question before it runs, and run under a capability you narrowed for it.

4. A script that writes a script

A script is a file, and a file is a resource, so a composition can answer one. This jq program turns notable.json into two REPL lines per earthquake: one that writes a card template for it, with its note spliced in, and one that fills the template into a page fragment. Save it as queries/cards.jq:

[.[] |
  "sink urn:file:cards/\(.id).txt <h2>M\(.mag) \(.place)</h2><p>$h{urn:file:notes/\(.id).txt || urn:file:notes/none.txt}</p>",
  "source urn:iki:fn:compose src=urn:file:cards/\(.id).txt | sink urn:file:cards/\(.id).html"
] | join("\n")

And the script that runs it, make-cards.iki, one line: the program piped into the query as its q, as in Reshaping JSON with jq, with as=text/plain so the joined lines come out as text and not as one quoted JSON string:

source urn:file:queries/cards.jq | urn:json:query src=urn:file:notable.json as=text/plain | sink urn:file:cards.iki

Replay it, read what it wrote, and replay that:

$ ikigai --quiet --files . < make-cards.iki
wrote 985 bytes to cards.iki
[2 computed · 1 uncacheable]
$ cat cards.iki
sink urn:file:cards/us6000m0xl.txt <h2>M7.5 2024 Noto Peninsula, Japan Earthquake</h2><p>$h{urn:file:notes/us6000m0xl.txt || urn:file:notes/none.txt}</p>
source urn:iki:fn:compose src=urn:file:cards/us6000m0xl.txt | sink urn:file:cards/us6000m0xl.html
sink urn:file:cards/us6000m2jp.txt <h2>M6.7 93 km SE of Sarangani, Philippines</h2><p>$h{urn:file:notes/us6000m2jp.txt || urn:file:notes/none.txt}</p>
source urn:iki:fn:compose src=urn:file:cards/us6000m2jp.txt | sink urn:file:cards/us6000m2jp.html
sink urn:file:cards/us6000m31m.txt <h2>M6.4 45 km SSW of Jurm, Afghanistan</h2><p>$h{urn:file:notes/us6000m31m.txt || urn:file:notes/none.txt}</p>
source urn:iki:fn:compose src=urn:file:cards/us6000m31m.txt | sink urn:file:cards/us6000m31m.html
sink urn:file:cards/us6000m0xm.txt <h2>M6.2 8 km SW of Anamizu, Japan</h2><p>$h{urn:file:notes/us6000m0xm.txt || urn:file:notes/none.txt}</p>
source urn:iki:fn:compose src=urn:file:cards/us6000m0xm.txt | sink urn:file:cards/us6000m0xm.html
$ ikigai --quiet --files . < cards.iki
wrote 118 bytes to cards/us6000m0xl.txt
[uncacheable]
wrote 69 bytes to cards/us6000m0xl.html
[1 computed · 1 uncacheable]
wrote 115 bytes to cards/us6000m2jp.txt
[uncacheable]
wrote 66 bytes to cards/us6000m2jp.html
[1 computed · 1 uncacheable]
wrote 111 bytes to cards/us6000m31m.txt
[uncacheable]
wrote 62 bytes to cards/us6000m31m.html
[1 computed · 1 uncacheable]
wrote 106 bytes to cards/us6000m0xm.txt
[uncacheable]
wrote 57 bytes to cards/us6000m0xm.html
[1 computed · 1 uncacheable]
$ cat cards/us6000m0xl.html
<h2>M7.5 2024 Noto Peninsula, Japan Earthquake</h2><p>no note yet</p>

One card per notable earthquake (with your note in it, if you wrote one), and the script that made them was written by a query over the data. When next week’s copy has a fifth notable earthquake, the same two replays make a fifth card; nobody edits a script.

Read a generated script before you replay it. It holds whatever the data put in it: a place with a line break in it would be a second REPL line, and that line would run with your authority. USGS’s places have none, and cat is how you know. A replay of a generated script is also a good place for the narrowing in section 3: cap write as its first line would keep it to this directory’s files.

5. Plans with parameters

The notable plan has its threshold, 6, written into the question it reads. A plan that took the threshold as a parameter, bound to a name and read with a value, would be one plan for every threshold. This page was written before the ikigai-cli this book is checked against could do that: binding a plan to a name comes with engine 0.2.6 (ledger #2509), which the book’s pinned cli, 0.2.6, links, and its REPL’s bind names a request with its arguments (named requests, ledger #2447). This part has not taken them up yet, so here a different threshold is still a different question file and a different plan; A river of feeds waits on the same thing, and Naming a composition is where this part takes names up.

6. What a script does not have yet

The .iki format is the REPL’s lines and nothing more. It has no comments: a line starting # is an unknown command, an error, and the replay exits 1; it declares no parameters, so a script for a different week is a copy of the script; it has no shebang line, so it is replayed with ikigai --quiet < file and never run as a program; and a script stored as a resource cannot yet be run by name from inside the REPL, only through standard input. Those four are the subject of one ledger item, the .iki format’s missing pieces (ledger #2485). It is a list of what is missing, not a promise of when.

Next, the same parts over many publishers at once, each with its own rules for how long a copy stays good, refreshed by a script like the one above: A river of feeds.

A river of feeds

The earthquakes came from one publisher, in one format, on one schedule. A feed reader has the opposite problem: many small publishers, each with a feed of their own, each deciding how often it changes and how long a copy stays good. This chapter builds a reader of that kind out of the same parts as the rest of this part, SPARQL among them, as Giving the data names asked it of the earthquakes, plus one more: XSLT, which reads XML.

The feeds are a directory of personal blogs, and a directory is a list of names. Each feed becomes RDF, the feeds together become one graph called the river, questions about the river are files, and one answer is written out four ways: a web page, a digest, a feed of its own and a directory of the feeds that matched. Every step is a REPL line. The only code is XSLT, SPARQL and three files of REPL lines.

What you need

The ikigai command, 0.2.5 or later (section 2’s revalidation needs 0.2.6), and a copy of this book’s repository, which holds the example in examples/rss/:

git clone https://github.com/ikigai-rs/ikigai-tutorial
cd ikigai-tutorial/examples/rss
./iki --plain

./iki starts the REPL over the example’s own workspace, ws/, with a configuration directory of its own (.home/) and none of your machine’s mounts, so nothing here touches the CLI’s default workspace. It runs ikigai from your PATH; name another binary with IKIGAI=.

The feeds are a fixture: four invented blogs under example.com and example.org, domains reserved for examples, shaped like the real feeds in a directory of personal sites (their dates, their guid forms, HTML in descriptions, missing fields, a podcast with no guid). Nothing in ws/ came from a real person’s feed, and nothing in this chapter reads one. The example’s README ends with a live run, which is your choice to make, rarely.

The book’s checks replay every ikigai> block on this page against ikigai-cli 0.2.6, over a copy of ws/ as git has it, so those answers are the ones you get. Each block is a fresh REPL unless the text says it goes on from the one before. The steps against the stand-in origin in section 2 are not replayed, and the section says why.

Sections 1, 3, 5 and 6 are also Run cells, which run in this page, on a kernel compiled into it, under the CLI’s own engine. Its urn:file: names are the same files with the same bytes, compiled in, so a cell and a REPL line name one resource. The cells on the page share one kernel and one cache, so run them in order. The page cannot do two things: write a file, and watch one. So urn:file:river.nt in a cell is the copy the example commits, the one section 4’s build line writes (check.sh holds the two to each other). Sections 2, 4 and 7 stay REPL transcripts: section 2 needs a web server, section 4 writes the river and watches the workspace, and section 7 writes and lists files.

1. A directory is a list of names

ws/directory.opml is the directory. OPML is a small XML format for lists of feeds; each <outline> names one, with its address in xmlUrl. A directory of four looks like this, one line per feed:

<outline type="rss" version="RSS" text="Alice Writes Things" title="Alice Writes Things" description="Notes on static sites, feeds and the small web." xmlUrl="https://alice.example.com/feed.xml" htmlUrl="https://alice.example.com/"/>

urn:xslt:transform runs an XSLT stylesheet over a resource: src is what to read and stylesheet the stylesheet, and both are names, read through the kernel. ws/feeds.xsl writes each feed’s address on a line of its own. ws/feeds-local.xsl writes, for each feed, the name of the fixture’s copy of it, urn:file:feeds/{host}.xml:

https://alice.example.com/feed.xml
https://bob.example.org/feed/
https://carol.example.com/index.xml
https://dave.example.org/feed
[computed]
urn:file:feeds/alice.example.com.xml
urn:file:feeds/bob.example.org.xml
urn:file:feeds/carol.example.com.xml
urn:file:feeds/dave.example.org.xml
[computed]

A list with one name per line is the shape .. maps over (A lab with names in it mapped toUpper over places). Both lists are answers like any other: cached, and hanging from the directory’s thread and the stylesheet’s. Add a feed to the OPML and the list changes; the rest of this chapter uses the second one, so nothing leaves your machine.

2. Somebody else’s rules

Somebody else’s resource read a feed whose publisher said how long a copy stayed good: sixty seconds. Personal feeds mostly say less. In one polite pass over a real directory of 115 personal feeds, on 2026-10-28, one request each, the headers said this (counts only; which site said what is not recorded here):

What the feed’s answer saidFeeds
No Cache-Control at all39
max-age=028
A copy may be kept more than ten seconds30 of 112
An ETag95
304 Not Modified, to a conditional request90

So a copy of most of these feeds may barely be kept, but nearly all of them can say “not changed” in a few bytes when asked properly. A reader that honors the first and uses the second downloads almost nothing on a feed that has not changed.

None of that can be shown against a real person’s site, and this book never tries. The example has a stand-in origin instead, tools/origin.py: a small web server on your own machine that serves the fixture feeds with whatever cache headers the path asks for (/cc/max60/…, /cc/etag/…, /cc/none/…, and a few more listed in the file) and logs every request it is sent. Start it in a second terminal:

python3 -I tools/origin.py ws/feeds /tmp/origin.log

It prints the port it chose, port 46413 here. The port is different every run, which is why the blocks in this section are not replayed: each one prints the port. Yours will show your port; everything else is what ikigai-cli 0.2.6 printed on 2026-10-29.

A feed that says max-age=60 is kept for sixty seconds, as USGS’s was. The second read is served from the cache, and the origin’s log shows one request:

source urn:http:get url=http://127.0.0.1:46413/cc/max60/alice.example.com.xml | urn:text:wc
source urn:http:get url=http://127.0.0.1:46413/cc/max60/alice.example.com.xml | urn:text:wc
cache urn:http:get url=http://127.0.0.1:46413/cc/max60/alice.example.com.xml
38
[2 computed]
38
[2 cached]
cached

A feed that says nothing is not kept at all: every read is a download. You may decide for yourself how stale a copy you will accept, with max_age= in seconds, and say who you are, with a User-Agent header. A publisher reading their logs then sees a tutorial asking once an hour, not an anonymous client asking every time:

source urn:http:get url=http://127.0.0.1:46413/cc/none/carol.example.com.xml headers="User-Agent: ikigai-tutorial (+https://ikigai-rs.github.io/ikigai-tutorial/)" | urn:text:wc
cache urn:http:get url=http://127.0.0.1:46413/cc/none/carol.example.com.xml headers="User-Agent: ikigai-tutorial (+https://ikigai-rs.github.io/ikigai-tutorial/)"
source urn:http:get url=http://127.0.0.1:46413/cc/none/carol.example.com.xml max_age=3600 headers="User-Agent: ikigai-tutorial (+https://ikigai-rs.github.io/ikigai-tutorial/)" | urn:text:wc
cache urn:http:get url=http://127.0.0.1:46413/cc/none/carol.example.com.xml max_age=3600 headers="User-Agent: ikigai-tutorial (+https://ikigai-rs.github.io/ikigai-tutorial/)"
29
[2 uncacheable]
not cached
29
[2 computed]
cached

The same feed, kept for an hour because you asked for an hour. max_age= does not override a publisher who forbids a copy: the same line against /cc/nostore/ (which sends Cache-Control: no-store) stays [2 uncacheable]. And a feed that sends an ETag and nothing else:

source urn:http:get url=http://127.0.0.1:46413/cc/etag/bob.example.org.xml | urn:text:wc
source urn:http:get url=http://127.0.0.1:46413/cc/etag/bob.example.org.xml | urn:text:wc
source urn:kernel:uncached
49
[2 computed]
49
[2 computed]
uncached (why a computed read was not cached; the last 64, most recent first)
  urn:text:wc      ×2  expired 1793296165726  [root]

[uncacheable]

Nothing in the headers allows a copy to be served as it is, but the ETag allows the next best thing, and ikigai-cli 0.2.6 (with ikigai-http 0.5.0) uses it. The answer is stored already expired, kept for revalidation: the second read sends If-None-Match with the stored ETag, the origin answers 304 Not Modified with no body, and the stored body is served. The origin’s log shows one download and one 304:

{"t": 1793296165.725, "path": "/cc/etag/bob.example.org.xml", "if-none-match": null, "if-modified-since": null, "user-agent": "ikigai/0.2.6", "accept-encoding": "gzip", "status": 200}
{"t": 1793296165.726, "path": "/cc/etag/bob.example.org.xml", "if-none-match": "\"985b241130e43cac\"", "if-modified-since": null, "user-agent": "ikigai/0.2.6", "accept-encoding": "gzip", "status": 304}

Both reads still say computed: the stored body stays expired, so every read asks the origin again, cache on the URL says not cached (source urn:kernel:cache lists the body as expired), and the wc computed from it is not kept either. urn:kernel:uncached says so, with the moment its upstream expired (in milliseconds since 1970). What the 304 saves is the download, not the computation. On 0.2.5, which kept nothing for revalidation, the same two reads were [2 uncacheable] and the log showed two full 200s with no If-None-Match.

Stop the origin when you are done (Ctrl-C in its terminal). The rest of the chapter reads the fixture’s files.

3. XSLT reads RSS directly

RSS 1.0 was RDF; RSS 2.0, the format nearly every feed uses now, is plain XML. So turning a feed into RDF needs no new vocabulary, only a stylesheet. ws/items.xsl reads an RSS 2.0 feed and writes RDF/XML in RSS 1.0’s own terms (rss:item, rss:title, rss:link, rss:description) plus Dublin Core for the rest (dc:subject for a category, dc:creator, dcterms:isPartOf for the feed an item belongs to). urn:rdf:transrept then rewrites the RDF/XML as N-Triples, one statement per line.

An item in RDF needs a name, an IRI, and an RSS item has two candidates. Its guid is the publisher’s own identifier, but it is only a web address when the feed says isPermaLink (the default) and it starts with http. Its link is always an address. The stylesheet takes the guid when it is a permalink and the link otherwise, and keeps the guid as dc:identifier either way. Carol’s feed uses tag: identifiers, which are not web addresses, so her items are named by their links:

<https://carol.example.com/2026/10/24/rss-cloud/> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://purl.org/rss/1.0/item> .
<https://carol.example.com/2026/10/24/rss-cloud/> <http://purl.org/dc/terms/isPartOf> <https://carol.example.com/index.xml> .
<https://carol.example.com/2026/10/24/rss-cloud/> <http://purl.org/rss/1.0/link> "https://carol.example.com/2026/10/24/rss-cloud/" .
<https://carol.example.com/2026/10/24/rss-cloud/> <http://purl.org/dc/elements/1.1/identifier> "tag:carol.example.com,2026-10-24:/rss-cloud" .
<https://carol.example.com/2026/10/24/rss-cloud/> <http://purl.org/rss/1.0/description> "<p>Tried <a href=\"https://rpc.example.net/\">rssCloud</a> today: the feed tells readers when it changes, so nobody has to poll.</p>" .
<https://carol.example.com/2026/10/24/rss-cloud/> <http://purl.org/dc/terms/issued> "Sat, 24 Oct 2026 16:45:00 GMT" .
<https://carol.example.com/2026/10/24/rss-cloud/> <http://purl.org/dc/elements/1.1/date> "2026-10-24T16:45:00Z"^^<http://www.w3.org/2001/XMLSchema#dateTime> .
[3 computed]

That item has no title, which RSS 2.0 allows; the river’s queries call it (untitled). Its description is HTML, kept as text. And its date is there twice. RSS dates are written the way email dates are, Sat, 24 Oct 2026 16:45:00 GMT, which nothing can sort or compare. The stylesheet keeps that as dcterms:issued, and when it can read it, adds a dc:date in the form SPARQL compares, 2026-10-24T16:45:00Z, typed xsd:dateTime. A date it cannot read gets no dc:date rather than a wrong one.

Dave’s podcast has no guid at all, so its episodes are named by their links too. Its dates use a one-digit day and an offset, Wed, 7 Oct 2026 10:00:00 -0700, and come out keeping the offset:

<https://dave.example.org/episodes/12> <http://purl.org/dc/elements/1.1/date> "2026-10-07T10:00:00-07:00"^^<http://www.w3.org/2001/XMLSchema#dateTime> .
<https://dave.example.org/episodes/11> <http://purl.org/dc/elements/1.1/date> "2026-09-23T10:00:00-07:00"^^<http://www.w3.org/2001/XMLSchema#dateTime> .
<https://dave.example.org/episodes/10> <http://purl.org/dc/elements/1.1/date> "2026-09-09T10:00:00-07:00"^^<http://www.w3.org/2001/XMLSchema#dateTime> .
[3 computed]

The stylesheet’s comments name the places it works around the XSLT engine’s gaps (how it gets a newline into the text output, why one IRI lives in a variable), so it reads as a record of what that engine does today, not only as a recipe.

4. The river

Now the whole directory at once. Section 1’s list of names, mapped (..) over the two stages in parentheses, so each feed is lifted to RDF on its own; the four answers joined, and the result written to ws/river.nt:

ikigai> source urn:xslt:transform src=urn:file:directory.opml stylesheet=urn:file:feeds-local.xsl .. ( urn:xslt:transform stylesheet=urn:file:items.xsl | urn:rdf:transrept as=application/n-triples ) | sink urn:file:river.nt
wrote 14886 bytes to river.nt
[9 computed · 1 uncacheable]
ikigai> source urn:xslt:transform src=urn:file:directory.opml stylesheet=urn:file:feeds-local.xsl .. ( urn:xslt:transform stylesheet=urn:file:items.xsl | urn:rdf:transrept as=application/n-triples ) | sink urn:file:river.nt
wrote 14886 bytes to river.nt
[9 cached · 1 uncacheable]

Each name on the list reaches urn:xslt:transform as its src, the one argument the stage leaves out. The first build computed nine answers: the list, and two stages for each of four feeds. The write is the one uncacheable step, because a write has to happen each time it is asked for. The second build, the same line again, found all nine in the cache, so only the write ran. (The --plain REPL also prints [fan-out 4 → 1 wide] under each build; the grammar explains it.)

Ask what hangs from one feed, then say that another one changed and build again:

ikigai> dependents urn:file:feeds/dave.example.org.xml
dependents of urn:file:feeds/dave.example.org.xml  (cut 0 times)
  3 live cached entries hang from it — a cut makes them stale, and the next read of each recomputes it
  urn:file:feeds/dave.example.org.xml  text/xml                     1.4 KB  1 thread    root
  urn:rdf:transrept                    application/n-triples        2.8 KB  6 threads   root
  urn:xslt:transform                   application/xml              2.3 KB  5 threads   root
[uncacheable]
ikigai> sink urn:kernel:cut urn:file:feeds/alice.example.com.xml
cut urn:file:feeds/alice.example.com.xml
[uncacheable]
ikigai> source urn:xslt:transform src=urn:file:directory.opml stylesheet=urn:file:feeds-local.xsl .. ( urn:xslt:transform stylesheet=urn:file:items.xsl | urn:rdf:transrept as=application/n-triples ) | sink urn:file:river.nt
wrote 14886 bytes to river.nt
[7 cached · 2 computed · 1 uncacheable]

Three things hang from Dave’s feed: the file, and the two stages that lifted it. Nothing else in the river does, so a change to Dave’s feed can make only those stale. The cut says Alice’s feed changed, and the rebuild computes exactly two answers, the two stages of Alice’s lift. The other three feeds’ lifts, and the list, come from the cache.

Editing the file does the same, with no cut by hand: the CLI watches its workspace and cuts a file’s thread when the file changes. Change a title in ws/feeds/dave.example.org.xml with your editor and build again, and the tally is the same, [7 cached · 2 computed · 1 uncacheable], with the new title in river.nt (and 7 more bytes, if you add , again to it). Put the file back afterwards (git checkout ws/feeds), because the next sections expect the fixture as it was.

5. Questions are files

The questions are SPARQL, kept in ws/queries/ as files, so each is a resource with a name. urn:sparql:select runs one over a graph: pipe the query in, name the graph with graph=, and choose the answer’s form with as=. The river’s census, how many items each feed holds and its newest:

feedTitle,items,newest
Bob's Workbench,3,2026-10-27T21:14:09-05:00
Alice Writes Things,3,2026-10-26T09:30:00Z
carol.example.com,3,2026-10-24T16:45:00Z
Dave Talks,3,2026-10-07T10:00:00-07:00
[2 computed]

The other queries have a blank to fill: since.rq a day, in.rq a category, from.rq a feed and search.rq some text. Each blank is a $r{{name}} marker, and urn:iki:fn:compose, which filled a template in Reshaping JSON with jq, fills it from an argument of the same name. Their answers have more columns than fit on a line, so here each goes through views/digest.xsl, which writes a SPARQL answer as a short Markdown list (section 6 is about views):

# Digest
 
- 2026-10-27 [My home server caches everything now](https://bob.example.org/2026/10/home-server-cache/) from Bob's Workbench
- 2026-10-26 [Why my feed sends an ETag](https://alice.example.com/posts/ETag/) from Alice Writes Things
- 2026-10-24 [(untitled)](https://carol.example.com/2026/10/24/rss-cloud/) from carol.example.com
- 2026-10-15 [A static site in one afternoon](https://alice.example.com/posts/static-site/) from Alice Writes Things
[3 computed]
# Digest
 
- 2026-10-27 [My home server caches everything now](https://bob.example.org/2026/10/home-server-cache/) from Bob's Workbench
- 2026-10-26 [Why my feed sends an ETag](https://alice.example.com/posts/ETag/) from Alice Writes Things
[3 computed]
# Digest
 
- 2026-10-24 [(untitled)](https://carol.example.com/2026/10/24/rss-cloud/) from carol.example.com
- 2026-10-12 [(untitled)](https://carol.example.com/2026/10/12/coffee/) from carol.example.com
- 2026-10-05 [A longer one about feed readers](https://carol.example.com/2026/10/05/readers/) from carol.example.com
[3 computed]
# Digest
 
- 2026-10-27 [My home server caches everything now](https://bob.example.org/2026/10/home-server-cache/) from Bob's Workbench
- 2026-10-26 [Why my feed sends an ETag](https://alice.example.com/posts/ETag/) from Alice Writes Things
- 2026-09-23 [Episode 11: Caching for people who hate caching](https://dave.example.org/episodes/11) from Dave Talks
[3 computed]

A feed is named by its own address (from.rq compares it with each item’s dcterms:isPartOf). The search is a case-insensitive substring of an item’s title, summary, full text and categories, so cach finds “caches”, “caching” and “Caching”. The summaries are HTML, so a search also matches markup.

A term with a quote in it

compose splices the term into the query as text. A term with a " in it ends the query’s string early, and what follows is read as SPARQL:

error: endpoint error: SPARQL syntax error: error at 18:139: expected NOT

Here it is only a syntax error. With a term chosen to be valid SPARQL, the same splice would change the question, which is why a term somebody else typed must never be spliced. urn:sparql:select has a way that cannot go wrong: bindings=, a JSON object of variable names and values, binds each value into the query after it is parsed, so a value is always a term and never syntax. queries/search-bound.rq is the search written for it, with ?q where search.rq has its marker (a bound variable must be one the query projects, so it selects ?q too):

# Digest
 
- 2026-10-24 [(untitled)](https://carol.example.com/2026/10/24/rss-cloud/) from carol.example.com
[3 computed]

The term is the text href="https, quote and all, and it matched the one summary with a link in it, Carol’s. The backslashes are the REPL’s quoting, not the query’s: inside a quoted value \" is a quotation mark and \\ a backslash (the grammar), so the value the REPL passes is the JSON {"q": "href=\"https"}.

6. One answer, four faces

A SPARQL answer has a standard XML form, application/sparql-results+xml, and XSLT reads XML. So one answer can be written out many ways, a stylesheet for each, and none of them knows anything about feeds. ws/views/ holds four:

  • page.xsl, an HTML page, a list of links with each item’s feed and day; everything it takes from a feed is written as text, never as markup, so a feed cannot put a script in your page;
  • digest.xsl, a Markdown list, as above;
  • feed.xsl, an RSS feed of the answer, so a search is something you can subscribe to;
  • opml.xsl, an OPML directory of the feeds that matched, so the answer to “who writes about caching?” can be loaded into a feed reader.

Each face is the same three steps with a different stylesheet at the end. The page, over river.rq, every item in the river, newest first:

<html lang='en'><head><meta charset='utf-8'/><title>River</title></head><body><h1>River</h1><ol><li><a href='https://bob.example.org/2026/10/home-server-cache/'>My home server caches everything now</a> (Bob&apos;s Workbench, 2026-10-27)</li><li><a href='https://alice.example.com/posts/ETag/'>Why my feed sends an ETag</a> (Alice Writes Things, 2026-10-26)</li><li><a href='https://carol.example.com/2026/10/24/rss-cloud/'>(untitled)</a> (carol.example.com, 2026-10-24)</li><li><a href='https://alice.example.com/posts/static-site/'>A static site in one afternoon</a> (Alice Writes Things, 2026-10-15)</li><li><a href='https://carol.example.com/2026/10/12/coffee/'>(untitled)</a> (carol.example.com, 2026-10-12)</li><li><a href='https://bob.example.org/2026/10/dovetails/'>Dovetails by hand</a> (Bob&apos;s Workbench, 2026-10-10)</li><li><a href='https://dave.example.org/episodes/12'>Episode 12: The small web</a> (Dave Talks, 2026-10-07)</li><li><a href='https://carol.example.com/2026/10/05/readers/'>A longer one about feed readers</a> (carol.example.com, 2026-10-05)</li><li><a href='https://alice.example.com/posts/tomatoes/'>Tomatoes, finally</a> (Alice Writes Things, 2026-10-02)</li><li><a href='https://bob.example.org/2026/09/still-rss/'>Why I still read RSS</a> (Bob&apos;s Workbench, 2026-09-28)</li><li><a href='https://dave.example.org/episodes/11'>Episode 11: Caching for people who hate caching</a> (Dave Talks, 2026-09-23)</li><li><a href='https://dave.example.org/episodes/10'>Episode 10: Live from the garage</a> (Dave Talks, 2026-09-09)</li></ol></body></html>
[3 computed]

The digest, section 5’s first question again, so all three of its answers come from the cache:

# Digest
 
- 2026-10-27 [My home server caches everything now](https://bob.example.org/2026/10/home-server-cache/) from Bob's Workbench
- 2026-10-26 [Why my feed sends an ETag](https://alice.example.com/posts/ETag/) from Alice Writes Things
- 2026-10-24 [(untitled)](https://carol.example.com/2026/10/24/rss-cloud/) from carol.example.com
- 2026-10-15 [A static site in one afternoon](https://alice.example.com/posts/static-site/) from Alice Writes Things
[3 cached]

A feed of the search for cach. Section 5 asked the same question, so only the stylesheet runs:

<rss version='2.0'><channel><title>River search</title><link>https://example.com/river</link><description>Items from the directory&apos;s feeds that matched a saved search.</description><item><title>My home server caches everything now</title><link>https://bob.example.org/2026/10/home-server-cache/</link><guid>https://bob.example.org/2026/10/home-server-cache/</guid><source url='https://bob.example.org'>Bob&apos;s Workbench</source><description>A reverse proxy, a cache, and a lot of patience. [&amp;#8230;]</description><dc:date xmlns:dc='http://purl.org/dc/elements/1.1/'>2026-10-27T21:14:09-05:00</dc:date></item><item><title>Why my feed sends an ETag</title><link>https://alice.example.com/posts/ETag/</link><guid>https://alice.example.com/posts/ETag/</guid><source url='https://alice.example.com/feed.xml'>Alice Writes Things</source><description>&lt;p&gt;Readers that send &lt;code&gt;If-None-Match&lt;/code&gt; get a &lt;strong&gt;304&lt;/strong&gt; and no body. Caching is politeness.&lt;/p&gt;</description><dc:date xmlns:dc='http://purl.org/dc/elements/1.1/'>2026-10-26T09:30:00Z</dc:date></item><item><title>Episode 11: Caching for people who hate caching</title><link>https://dave.example.org/episodes/11</link><guid>https://dave.example.org/episodes/11</guid><source url='https://dave.example.org/'>Dave Talks</source><description>Max-age, ETags, and why a 304 is the nicest answer a server can give.</description><dc:date xmlns:dc='http://purl.org/dc/elements/1.1/'>2026-09-23T10:00:00-07:00</dc:date></item></channel></rss>
[2 cached · 1 computed]

And a directory of the feeds that search matched:

<opml version='2.0'><head><title>Feeds that matched</title></head><body><outline htmlUrl='https://alice.example.com/' text='Alice Writes Things' title='Alice Writes Things' type='rss' version='RSS' xmlUrl='https://alice.example.com/feed.xml'/><outline htmlUrl='https://bob.example.org' text='Bob&apos;s Workbench' title='Bob&apos;s Workbench' type='rss' version='RSS' xmlUrl='https://bob.example.org'/><outline htmlUrl='https://dave.example.org/' text='Dave Talks' title='Dave Talks' type='rss' version='RSS' xmlUrl='https://dave.example.org/'/></body></opml>
[3 computed]

A cell in this page cannot write a file. At a shell, each line ends in a sink, which writes the face into ws/out/:

ikigai> source urn:file:queries/river.rq | urn:sparql:select graph=urn:file:river.nt as=application/sparql-results+xml | urn:xslt:transform stylesheet=urn:file:views/page.xsl | sink urn:file:out/river.html
wrote 1581 bytes to out/river.html
[3 computed · 1 uncacheable]
ikigai> source urn:iki:fn:compose src=urn:file:queries/since.rq since=2026-10-15 | urn:sparql:select graph=urn:file:river.nt as=application/sparql-results+xml | urn:xslt:transform stylesheet=urn:file:views/digest.xsl | sink urn:file:out/digest.md
wrote 455 bytes to out/digest.md
[3 computed · 1 uncacheable]
ikigai> source urn:iki:fn:compose src=urn:file:queries/search.rq q=cach | urn:sparql:select graph=urn:file:river.nt as=application/sparql-results+xml | urn:xslt:transform stylesheet=urn:file:views/feed.xsl | sink urn:file:out/caching.xml
wrote 1542 bytes to out/caching.xml
[3 computed · 1 uncacheable]
ikigai> source urn:iki:fn:compose src=urn:file:queries/search-feeds.rq q=cach | urn:sparql:select graph=urn:file:river.nt as=application/sparql-results+xml | urn:xslt:transform stylesheet=urn:file:views/opml.xsl | sink urn:file:out/caching.opml
wrote 559 bytes to out/caching.opml
[3 computed · 1 uncacheable]

Open ws/out/river.html in a browser, or read the others in an editor. The OPML is built from search-feeds.rq, the same search counted per feed, because a directory lists each feed once. At a shell, each written file is as fresh as the river: these four lines are the end of a pipeline that starts at the directory, and every answer in it hangs from every file it read.

7. Saved searches and the refresh script

A search worth keeping is a file. Fill search.rq once and keep what compose wrote: the result is a complete query, no blank left, under ws/searches/:

ikigai> source urn:iki:fn:compose src=urn:file:queries/search.rq q=cach | sink urn:file:searches/caching.rq
wrote 1077 bytes to searches/caching.rq
[1 computed · 1 uncacheable]
ikigai> source urn:iki:fn:compose src=urn:file:queries/search.rq q=rss | sink urn:file:searches/rss.rq
wrote 1076 bytes to searches/rss.rq
[1 computed · 1 uncacheable]
ikigai> source urn:file:searches
caching.rq
rss.rq
[uncacheable]
ikigai> source urn:file:searches | urn:text:wc
2
[1 computed · 1 uncacheable]

ws/save-searches.iki is those two lines in a file: a script that writes scripts, because each query it writes is a program urn:sparql:select runs. A REPL reads a file of lines as readily as it reads you, so at a shell, in examples/rss:

./iki --quiet < ws/save-searches.iki

Reading a directory, source urn:file:searches, answers its listing, one name per line, and that listing is never cached: it is read fresh every time. So the folder is a set. Save another search and it is in the set; delete a file and it is gone, with nothing else to update.

What a map cannot do with that set yet is run it. The listing names the files as the folder knows them, caching.rq, not as the kernel does, urn:file:searches/caching.rq, and a stage mapped over it is handed the short name. In the same REPL as the saves above:

ikigai> source urn:file:searches .. urn:iki:fn:compose
error: invalid argument `src`: not an IRI: invalid IRI: No scheme found in an absolute IRI
       `src`: the IRI of the template to fill

So ws/refresh.iki, which rebuilds the river and all four faces, names its searches one by one. It is section 4’s build line and section 6’s four lines, with the feed reading the saved search, urn:file:searches/caching.rq, instead of composing it again. After a change to a feed, the directory or a stylesheet:

./iki --quiet < ws/refresh.iki

Only what the change reached is computed again; the rest comes from the cache of that one REPL. examples/rss/check.sh runs both scripts and fails if river.nt or anything in ws/out/ differs from the committed copy, which is how the example knows it still works.

8. What the river is waiting for

Everything above works, and three things in it are clumsier than they should be, all for the same reason: in this chapter, a request with arguments is not a name.

  • The build line is the same long line in section 4 and in refresh.iki, and anything that wants the river has to repeat it, or read the file it wrote and lose the thread back to the feeds. A name for that composition, say urn:river:all, would be read like any resource, cached like one, and stale when any feed changed.
  • A live feed is urn:http:get with a url, a max_age and a User-Agent, written out at every use. Named once, with the politeness in the name, no line could forget it.
  • The saved searches are a set of files, but not yet a set of names a map can walk.

Named requests (ledger #2447) remove all three, and ikigai-cli 0.2.6, the release this book is checked against, has them: the REPL’s bind names a request with its arguments. This chapter was written before that release and does not use them yet (nor does Reshaping JSON with jq for its headline). The next chapter introduces them and comes back to this river: Naming a composition names the live feed and the saved searches, and says why the build line is not a name yet.

Naming a composition

Four chapters in a row worked around the same gap. Giving the data names kept the filled graph as a file, quakes.jsonld, because graph= takes a name and a request with arguments was not one. Composites, one layer at a time kept notable.csv and notable.json for the same reason. Process scripts are files replayed a file of lines to bring those copies up to date. And A river of feeds wrote its build line out twice and listed three things that waited on one chapter. This is that chapter.

The REPL’s bind gives a request with its arguments a name of its own. The name is read like any resource, cached like one, and stale when anything it read changes, so a composite at a shell no longer needs a copy in the middle.

What you need

The ~/quakes directory from the earlier chapters, a shell in it, and, for the last sections, the copy of this book’s repository that A river of feeds used. As in the last chapter, every step is a shell command: a file of REPL lines replayed with ikigai --quiet, or a few -c lines.

cd ~/quakes

The book’s checks run every $ line on this page against ikigai-cli 0.2.6, in order, in a fresh directory holding the two recorded weeks, the context and the RSS example, so the answers are the ones you get. $PWD in an answer stands for your own directory. Each ikigai command below starts a new REPL, so nothing is cached from one $ line to the next. The names are another matter: they are kept in a file, and that file is what this chapter is about.

1. A name for a request

A binding is one line: a name, =, and the request the name stands for, in the words you would type at the prompt. Each key=value in it is fixed: the name always sends it. An argument written key=<iri> is a reference: the name sends the resource’s name, and the kernel reads it, so the answer hangs from that resource’s thread.

The graph is the first thing to name. Giving the data names filled a template, graph.txt, and kept the answer as quakes.jsonld, because a file’s type comes from its name and SPARQL needs to know it is reading JSON-LD. urn:iki:fn:compose gives its answer the type of the template it filled. So keep the same template under a .jsonld name, and the filled answer is JSON-LD with no copy in between. Save this as bind-graphs.iki:

sink urn:file:templates/week.jsonld {"@context": $r{urn:json:query?src=urn:file:quakes.context.jsonld&q=.[]}, "@graph": [$r{urn:file:quakes-2024-01-01.geojson}]}
sink urn:file:templates/week-2024-01-08.jsonld {"@context": $r{urn:json:query?src=urn:file:quakes.context.jsonld&q=.[]}, "@graph": [$r{urn:file:quakes-2024-01-08.geojson}]}
bind urn:quakes:graph = source urn:iki:fn:compose src=<urn:file:templates/week.jsonld>
bind urn:quakes:graph:2024-01-08 = source urn:iki:fn:compose src=<urn:file:templates/week-2024-01-08.jsonld>

--names names tells the REPL which file holds its names: here names, in this directory. Without it, the REPL keeps them in its config home, in a file also called names, for every session you start; a file beside the data keeps this part’s names with this part.

$ ikigai --quiet --files . --names names < bind-graphs.iki
wrote 125 bytes to templates/week.jsonld
[uncacheable]
wrote 125 bytes to templates/week-2024-01-08.jsonld
[uncacheable]
bound in <urn:iki:host:names>:
urn:quakes:graph = source urn:iki:fn:compose src=<urn:file:templates/week.jsonld>
[uncacheable]
bound in <urn:iki:host:names>:
urn:quakes:graph:2024-01-08 = source urn:iki:fn:compose src=<urn:file:templates/week-2024-01-08.jsonld>
[uncacheable]
$ cat names
urn:quakes:graph:2024-01-08 = source urn:iki:fn:compose src=<urn:file:templates/week-2024-01-08.jsonld>
urn:quakes:graph = source urn:iki:fn:compose src=<urn:file:templates/week.jsonld>

bind wrote the file. It is plain text, one binding a line, and you may edit it with an editor as well: a REPL that is running hears the edit and binds again. (The lines are kept most specific first, which is the order the names are tried in, not the order you typed them.)

The REPL that bound the names has exited. A new one reads the file and answers them. Ask the first week how many earthquakes it holds, through the name:

$ ikigai --quiet --files . --names names -c 'source urn:json:query src=urn:quakes:graph q=".[\"@graph\"][0].features | length"'
178
[computed]

The pages of this part had urn:quakes:graph from the start; now your REPL has it too, and it is your own.

2. The questions, by name

The questions are files, as before, and the names read them by reference. Save the count question from Giving the data names, the strong filter from Composites, and bindings for both, as bind-questions.iki:

sink urn:file:queries/count.rq PREFIX qk: <https://ikigai-rs.dev/ns/quakes#> SELECT (COUNT(?quake) AS ?quakes) WHERE { ?quake a qk:Earthquake }
sink urn:file:queries/strong.rq PREFIX qk: <https://ikigai-rs.dev/ns/quakes#> CONSTRUCT { ?quake a qk:Earthquake ; qk:mag ?mag ; qk:place ?place } WHERE { ?quake a qk:Earthquake ; qk:mag ?mag ; qk:place ?place FILTER(?mag >= 5.5) }
sink urn:file:queries/notable.rq PREFIX qk: <https://ikigai-rs.dev/ns/quakes#> SELECT ?id ?mag ?place WHERE { ?quake qk:mag ?mag ; qk:place ?place FILTER(?mag >= 6) BIND(STRAFTER(STR(?quake), "eventpage/") AS ?id) } ORDER BY DESC(?mag)
bind urn:quakes:count = source urn:sparql:select query=<urn:file:queries/count.rq> graph=urn:quakes:graph
bind urn:quakes:strong = source urn:sparql:construct query=<urn:file:queries/strong.rq> graph=urn:quakes:graph
bind urn:quakes:strong:count = source urn:sparql:select query=<urn:file:queries/count.rq> graph=urn:quakes:strong
bind urn:quakes:notable = source urn:sparql:select query=<urn:file:queries/notable.rq> graph="urn:quakes:graph urn:quakes:graph:2024-01-08"

urn:quakes:strong is a layer: its answer is a graph, and urn:quakes:strong:count asks the same count question of it. urn:quakes:notable reads both weeks; the two names in its graph are one value, so they are quoted. graph= is the graph’s name, not a reference: urn:sparql:select takes names there and reads each one itself, through the kernel.

$ ikigai --quiet --files . --names names < bind-questions.iki
wrote 112 bytes to queries/count.rq
[uncacheable]
wrote 199 bytes to queries/strong.rq
[uncacheable]
wrote 202 bytes to queries/notable.rq
[uncacheable]
bound in <urn:iki:host:names>:
urn:quakes:count = source urn:sparql:select graph=urn:quakes:graph query=<urn:file:queries/count.rq>
[uncacheable]
bound in <urn:iki:host:names>:
urn:quakes:strong = source urn:sparql:construct graph=urn:quakes:graph query=<urn:file:queries/strong.rq>
[uncacheable]
bound in <urn:iki:host:names>:
urn:quakes:strong:count = source urn:sparql:select graph=urn:quakes:strong query=<urn:file:queries/count.rq>
[uncacheable]
bound in <urn:iki:host:names>:
urn:quakes:notable = source urn:sparql:select graph="urn:quakes:graph urn:quakes:graph:2024-01-08" query=<urn:file:queries/notable.rq>
[uncacheable]

Ask by name:

$ ikigai --quiet --files . --names names -c 'source urn:quakes:count as=text/csv' -c 'source urn:quakes:strong:count as=text/csv' -c 'source urn:quakes:notable as=text/csv'
quakes
178
[computed]
quakes
12
[computed]
id,mag,place
us6000m0xl,7.5,"2024 Noto Peninsula, Japan Earthquake"
us6000m2jp,6.7,"93 km SE of Sarangani, Philippines"
us6000m31m,6.4,"45 km SSW of Jurm, Afghanistan"
us6000m0xm,6.2,"8 km SW of Anamizu, Japan"
[computed]
— batch: 3 commands · 3 computed

The same three answers the earlier chapters printed, with no quakes.jsonld, no notable.csv and nothing to refresh. Each line is one stage, so each tally says [computed] once; the graph and the question file a name reads on its way, it reads through the kernel, and section 4 shows the kernel keeping track.

3. A fixed argument stays fixed

A name stands for its arguments, so the caller cannot change one. Ask urn:quakes:count about the other week:

$ ikigai --quiet --files . --names names -c 'source urn:quakes:count graph=urn:quakes:graph:2024-01-08'; echo "exit $?"
error: invalid argument `graph`: fixed by <urn:quakes:count> to urn:quakes:graph
       `graph`: optional: one or more graph source IRIs, comma- or space-separated; each resolved through the kernel, transrepted to RDF when its type is not an RDF syntax and a transreptor converts it, and loaded as a named graph. Omit to query the always-present ikigai vocabulary alone.
exit 1

Refused, and the error names the binding that fixed it and the value it fixed. A count of the other week is a different question, so it is a different name. What a binding leaves out, the caller still gives: as=text/csv above was the caller’s, and so is everything else urn:sparql:select takes that the line did not fix. One argument cannot be fixed at all. as chooses which form an answer comes in, and that is the caller’s choice every time:

$ ikigai --quiet --files . --names names -c 'bind urn:quakes:count:csv = source urn:quakes:count as=text/csv'; echo "exit $?"
error: not a declaration: at <urn:quakes:count:csv>: it fixes `as`, which negotiates the face and is never fixed: declare `as` as an input with a default, the name's default face
exit 1

A bound name has no contract of its own: it is described by its target’s, with the fixed arguments marked. describe shows it, here only the lines that say what is fixed:

$ ikigai --quiet --files . --names names -c 'describe urn:quakes:count' | grep 'ik:fixed'
[computed]
    ik:fixed "<urn:file:queries/count.rq>" .
    ik:fixed "urn:quakes:graph" .
    ik:fixed "<urn:file:queries/count.rq>" .
    ik:fixed "urn:quakes:graph" .

Two of each, because urn:sparql:select has two actions, Source and Exists, and the name fixes the same two arguments of both. The rest of the description is urn:sparql:select’s own: its summary, its other inputs, and the forms as may ask for.

4. A reference follows its file

The question is a reference, so the name’s answer hangs from the question’s file. Change the file and the answer is stale. In one REPL, so the cache carries from line to line, ask the strong count, raise the filter to magnitude 6, and ask again. Save it as follow.iki:

source urn:quakes:strong:count as=text/csv
cache urn:quakes:strong:count as=text/csv
sink urn:file:queries/strong.rq PREFIX qk: <https://ikigai-rs.dev/ns/quakes#> CONSTRUCT { ?quake a qk:Earthquake ; qk:mag ?mag ; qk:place ?place } WHERE { ?quake a qk:Earthquake ; qk:mag ?mag ; qk:place ?place FILTER(?mag >= 6) }
cache urn:quakes:strong:count as=text/csv
cache urn:quakes:graph
source urn:quakes:strong:count as=text/csv
$ ikigai --quiet --files . --names names < follow.iki
quakes
12
[computed]
cached
wrote 197 bytes to queries/strong.rq
[uncacheable]
not cached
cached
quakes
2
[computed]

Two earthquakes of magnitude 6 and up in the first week, the Noto Peninsula earthquake and its largest aftershock. The write cut the question file’s thread; the strong layer hung from it and so did the count read from the layer, so both were stale. The graph did not read the question, so it was still cached. Nobody named the count, the layer or the file in between: the names say what they read, and the kernel kept track. Editing queries/strong.rq in an editor does the same, because the CLI watches its workspace.

Put the filter back, so the rest of the chapter counts the same twelve:

$ ikigai --quiet --files . -c 'sink urn:file:queries/strong.rq PREFIX qk: <https://ikigai-rs.dev/ns/quakes#> CONSTRUCT { ?quake a qk:Earthquake ; qk:mag ?mag ; qk:place ?place } WHERE { ?quake a qk:Earthquake ; qk:mag ?mag ; qk:place ?place FILTER(?mag >= 5.5) }'
wrote 199 bytes to queries/strong.rq
[uncacheable]

5. Reading the names, not writing them

The names are a resource too, urn:iki:host:names, and its contract says who may change them:

$ ikigai --quiet --files . --names names -c 'describe urn:iki:host:names' | grep -E 'ik:summary "re|ik:requires'
[computed]
    ik:summary "read the names file" ;
    ik:summary "replace the names file and rebind" ;
    ik:requires <urn:cap:host:write:names> ;

Reading the table needs nothing; replacing it needs urn:cap:host:write:names, and bind and unbind replace it. Narrow the session the way Process scripts are files did, with cap read-only, and try both:

$ ikigai --quiet --files . --names names -c 'cap read-only' -c 'source urn:iki:host:names' -c 'source urn:quakes:strong:count as=text/csv' -c 'unbind urn:quakes:count'; echo "exit $?"
narrowed — capability: urn:cap:fs:read:$PWD
urn:quakes:graph:2024-01-08 = source urn:iki:fn:compose src=<urn:file:templates/week-2024-01-08.jsonld>
urn:quakes:strong:count = source urn:sparql:select graph=urn:quakes:strong query=<urn:file:queries/count.rq>
urn:quakes:notable = source urn:sparql:select graph="urn:quakes:graph urn:quakes:graph:2024-01-08" query=<urn:file:queries/notable.rq>
urn:quakes:strong = source urn:sparql:construct graph=urn:quakes:graph query=<urn:file:queries/strong.rq>
urn:quakes:count = source urn:sparql:select graph=urn:quakes:graph query=<urn:file:queries/count.rq>
urn:quakes:graph = source urn:iki:fn:compose src=<urn:file:templates/week.jsonld>
[computed]
quakes
12
[computed]
error: denied: capability does not grant `urn:cap:host:write:names` (declared by `urn:iki:host:names`)
— batch: 4 commands · 2 computed
exit 1

The narrowed session read the table and asked a name in it, and its unbind was refused before anything changed: cat names still shows all six. A session you hand a script, or a person you hand a REPL, can use your names without being able to rename what they mean.

6. A name somebody already serves

The names are bound last, after everything the CLI serves itself. So a binding can never take over a name the host answers already: the host’s answer wins. That would be a quiet way to be wrong, so the names layer says when it happens, at urn:iki:space:dynamic:names:status. Bind a name the CLI already serves, urn:text:wc, ask the status, and see whose answer urn:text:wc gives:

$ ikigai --quiet --files . --names names -c 'bind urn:text:wc = source urn:quakes:count' -c 'source urn:iki:space:dynamic:names:status' -c 'source urn:text:wc "one line"'
bound in <urn:iki:host:names>:
urn:text:wc = source urn:quakes:count
[uncacheable]
space: urn:iki:space:dynamic:names
declaration: urn:iki:host:names
in force: …
installs: …
refused: nothing since the last bind
names file: $PWD/names (text/x-ikigai-names)
shadowed: urn:text:wc is served ahead of the names layer, so its binding never answers
[uncacheable]
1
[computed]
— batch: 3 commands · 1 computed · 2 uncacheable

shadowed: names the binding that never answers, and urn:text:wc still counts lines, as it did before. (in force: is a hash of the table, and installs: counts how often the layer has bound it, so both change as you go.) Take the binding away again:

$ ikigai --quiet --files . --names names -c 'unbind urn:text:wc'
unbound `urn:text:wc` in <urn:iki:host:names> (was: urn:text:wc = source urn:quakes:count)
[uncacheable]

This is why this part names everything of its own under urn:quakes:: no host serves it, so no binding there is shadowed.

7. The river’s questions

The river from A river of feeds gets names of its own the same way, in its own file, river.names. Every step here runs in examples/rss, with the example’s ./iki, which passes --names through to ikigai:

cd ikigai-tutorial/examples/rss

The saved searches are not in git (the example’s .gitignore leaves ws/searches/ out), so write them again first, as that chapter did:

$ ./iki --quiet < ws/save-searches.iki
wrote 1077 bytes to searches/caching.rq
[1 computed · 1 uncacheable]
wrote 1076 bytes to searches/rss.rq
[1 computed · 1 uncacheable]

Three names, as bind-river.iki: a saved search, every search, and a live feed read politely.

bind urn:river:caching = source urn:sparql:select query=<urn:file:searches/caching.rq> graph=urn:file:river.nt
bind urn:river:search:{q} = source urn:iki:fn:compose src=<urn:file:queries/search.rq> q={q}
bind urn:river:live:{url} = source urn:http:get url={url} max_age=3600 headers="User-Agent: ikigai-tutorial (+https://ikigai-rs.github.io/ikigai-tutorial/)"

The second and third are templates: a {var} in the name matches whatever stands there in the name a caller asks for, and q={q} fixes q to it. So urn:river:search:rss is the search query filled with rss, and every term has a name of its own. A capture is always a whole value: q=a{q} is refused rather than spliced.

$ ./iki --quiet --names river.names < bind-river.iki
bound in <urn:iki:host:names>:
urn:river:caching = source urn:sparql:select graph=urn:file:river.nt query=<urn:file:searches/caching.rq>
[uncacheable]
bound in <urn:iki:host:names>:
urn:river:search:{q} = source urn:iki:fn:compose q={q} src=<urn:file:queries/search.rq>
[uncacheable]
bound in <urn:iki:host:names>:
urn:river:live:{url} = source urn:http:get headers="User-Agent: ikigai-tutorial (+https://ikigai-rs.github.io/ikigai-tutorial/)" max_age=3600 url={url}
[uncacheable]

The saved search is now a question you ask by name, written out by the digest view:

$ ./iki --quiet --names river.names -c 'source urn:river:caching as=application/sparql-results+xml | urn:xslt:transform stylesheet=urn:file:views/digest.xsl'
# Digest
- 2026-10-27 [My home server caches everything now](https://bob.example.org/2026/10/home-server-cache/) from Bob's Workbench
- 2026-10-26 [Why my feed sends an ETag](https://alice.example.com/posts/ETag/) from Alice Writes Things
- 2026-09-23 [Episode 11: Caching for people who hate caching](https://dave.example.org/episodes/11) from Dave Talks
[2 computed]

And any search by its term. The name answers the filled query, so it pipes into urn:sparql:select as a query file did:

$ ./iki --quiet --names river.names -c 'source urn:river:search:rss | urn:sparql:select graph=urn:file:river.nt as=application/sparql-results+xml | urn:xslt:transform stylesheet=urn:file:views/digest.xsl'
# Digest
- 2026-10-26 [Why my feed sends an ETag](https://alice.example.com/posts/ETag/) from Alice Writes Things
- 2026-10-24 [(untitled)](https://carol.example.com/2026/10/24/rss-cloud/) from carol.example.com
- 2026-10-07 [Episode 12: The small web](https://dave.example.org/episodes/12) from Dave Talks
- 2026-09-28 [Why I still read RSS](https://bob.example.org/2026/09/still-rss/) from Bob's Workbench
[3 computed]

The live feed is the one this book will not read for you. A template capture can hold a whole web address, colons, slashes and all, so urn:river:live:https://alice.example.com/feed.xml names that feed with the politeness built in: an hour’s copy and a User-Agent saying who asks. Ask it under a session narrowed to another host, and the refusal shows the address the name captured, with nothing sent:

$ ./iki --quiet --names river.names -c 'cap net-example.org' -c 'source urn:river:live:https://alice.example.com/feed.xml'; echo "exit $?"
narrowed — capability: urn:cap:net:example.org
error: denied: capability does not grant `GET` to `alice.example.com/feed.xml`
exit 1

Without the narrowing, that line is a real request to a real address; point it at the stand-in origin from the river’s section 2 instead, and its log shows the User-Agent the name sends.

8. What names make plain

Back through what the earlier chapters had to work around:

  • Reshaping JSON with jq filled its headline from a saved week.json, because a template could not splice urn:http:get with its url. A template splices a name, so bind urn:quakes:live = source urn:http:get url=https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_week.geojson is all $j{urn:quakes:live …} needs. That step reaches the network, so this page does not run it; a binding of the same shape over the recorded week, served from this machine over HTTP, filled the headline with 178 and the Noto Peninsula earthquake on ikigai-cli 0.2.6, on 2026-10-29.
  • Giving the data names kept the filled graph as quakes.jsonld; section 1 named it, and the name is never older than its context and its week.
  • Composites kept notable.csv and notable.json as copies that did not hang from the weeks. urn:quakes:notable does, and the strong layer at a shell is a name, as it was on the page.
  • Process scripts are files wanted a plan with a parameter. A name can bind a plan, bind urn:… = source urn:plan:eval in=<plan>, and copies the plan’s declared inputs into its own contract; this page does not write such a plan, so that part is still to be shown here.
  • A river of feeds listed three. The live feed with its politeness is urn:river:live:{url}, and the saved searches are names. The build line, urn:river:all, is still a line: a binding is one request, and the build is a pipeline that maps over two stages in parentheses. The way to name a pipeline is to store it as a plan, and ikigai-cli 0.2.6 refuses that one (.. ( … ), mapping over a fork, has no plan spelling yet). The river keeps refresh.iki until it does. And a map over ws/searches/ still hands each stage a file’s short name, so the set of saved searches is not yet a set of names a map can walk.

What the names make plain is what the copies hid: which answer reads which file. A name says it in one line of names, the kernel holds the answer to it, and a change to anything the line reads reaches the answer, the next time somebody asks.

When a rule is not enough

Everything this part has asked so far was a rule: a jq program, a SPARQL question, an XSLT stylesheet, a template, and in Naming a composition a binding that gives one of them a name. A rule answers from what it is handed. A jq query reads the one src its caller names, and a SPARQL question reads the graphs in its graph. Neither decides what to read.

This chapter climbs past rules, one rung at a time, and stops on each rung to say where it ends. The first rung is a program, in the Lisp the ikigai command carries, that decides which weeks to read. The second is a peer: a few lines of Python in a process of their own, for a job the program’s sandbox does not do.

What you need

The ~/quakes directory from the earlier chapters, with its two recorded weeks and the names file from Naming a composition, and a shell in it. Section 5 also needs python3 (3.11 or later, as ikigai-python requires) and the ikigai-python package.

cd ~/quakes

The book’s checks run every $ line in sections 1 to 4 against ikigai-cli 0.2.6, in order, in a fresh directory holding the two recorded weeks. Section 5 starts a second process, which those checks cannot do, so its steps are replayed by another check, scripts/polyglot-tracks.sh. It starts the peer from this page’s own lines, with ikigai-python at its main, and compares what every ikigai command prints. $PWD in an answer stands for your own directory.

This page has no Run buttons. The kernel in the book’s pages has no Lisp in it, because ikigai-lisp runs only in a native process, and a page cannot start a Python process.

1. A question with steps in it

Ask the weeks where the earthquakes were. Every earthquake’s place ends with its region, after the last comma: "39 km NE of Anamizu, Japan" is in Japan. Some places have no comma ("Balleny Islands region") and are a region already. One place breaks the rule: the Noto Peninsula earthquake’s place is its name, "2024 Noto Peninsula, Japan Earthquake", and what follows its last comma is Japan Earthquake.

Count the earthquakes in each region, keep each region’s strongest, and put the busiest region first. Splitting a string is not what makes this hard to ask as a rule: jq’s split does it, and split(", ") | last gives a place’s region. What does is that which weeks to count is data, kept in a file you change, and a rule reads only the inputs its caller names. A program can read the list, then read each week on it. And the Noto place needs one more step: when the region ends in Earthquake, take that off.

2. The program is a file

The ikigai command carries a Lisp: ikigai-lisp, built on the Steel Scheme engine and served at urn:lisp:eval. Since ikigai-cli 0.2.5 it is ikigai-lisp 0.4.1, the first whose sandbox has sort (with a comparator of your own) and string-split, and this program needs both. A program is text, so it is a file in the workspace, and the CLI reads it like any other resource.

Say which weeks to count first, in a file of their names. Save this as weeks.txt:

urn:file:quakes-2024-01-01.geojson

Then the program. Save it as regions.scm:

; Which regions shook in the weeks `weeks.txt` lists: how many earthquakes each had, and
; the strongest of them, the busiest region first.

; One line per earthquake, "mag<TAB>place", picked out of a week by jq.
(define (rows week)
  (string-split
    (invoke 'source "urn:json:query" "src" week "as" "text/plain"
            "q" ".features | map(\"\\(.properties.mag)\t\\(.properties.place)\") | join(\"\n\")")
    "\n"))

; The region is what follows the place's last ", ", or the whole place when it has none.
; A named earthquake's place is its name ("2024 Noto Peninsula, Japan Earthquake"), so
; " Earthquake" comes off the end.
(define (last xs) (if (null? (cdr xs)) (car xs) (last (cdr xs))))
(define (region place)
  (let ((r (last (string-split place ", "))))
    (if (ends-with? r " Earthquake")
        (substring r 0 (- (string-length r) 11))
        r)))

; Count one earthquake into a tally of (region count strongest) entries.
(define (bump tally reg mag)
  (cond ((null? tally) (list (list reg 1 mag)))
        ((equal? (car (car tally)) reg)
         (let ((e (car tally)))
           (cons (list reg (+ 1 (cadr e)) (if (> mag (caddr e)) mag (caddr e)))
                 (cdr tally))))
        (else (cons (car tally) (bump (cdr tally) reg mag)))))

(define (count-rows tally rows)
  (if (null? rows)
      tally
      (let ((fields (string-split (car rows) "\t")))
        (count-rows (bump tally (region (cadr fields)) (string->number (car fields)))
                    (cdr rows)))))

(define (count-weeks tally weeks)
  (if (null? weeks)
      tally
      (count-weeks (count-rows tally (rows (car weeks))) (cdr weeks))))

; The busiest first; of two as busy, the one with the stronger earthquake.
(define (busier? a b)
  (or (> (cadr a) (cadr b))
      (and (= (cadr a) (cadr b)) (> (caddr a) (caddr b)))))

(define (first-n xs n)
  (if (or (null? xs) (= n 0)) (list) (cons (car xs) (first-n (cdr xs) (- n 1)))))
(define (csv-row e)
  (string-append (car e) "," (number->string (cadr e)) "," (number->string (caddr e))))
(define (join-lines xs)
  (if (null? (cdr xs)) (car xs) (string-append (car xs) "\n" (join-lines (cdr xs)))))

(cacheable
  (join-lines
    (cons "region,quakes,strongest"
          (map csv-row
               (first-n (sort (count-weeks (list) (split-whitespace (source "urn:file:weeks.txt")))
                              busier?)
                        8)))))

Read it from the bottom. The last form is the answer: weeks.txt read with source, its names split apart, each week counted, the tally sorted with busier? and the first eight rows written out as CSV. rows reads one week through urn:json:query with invoke, the verb that takes an endpoint’s named arguments. jq picks out two fields, and the program does the rest. Each (source …) and (invoke …) is a request to the kernel, as a line at the prompt is.

--load reads a program resource and evaluates it:

$ ikigai --quiet --files . --load urn:file:regions.scm
region,quakes,strongest
Japan,57,7.5
Indonesia,13,5.3
Philippines,7,5
Northern Mariana Islands,7,5
Papua New Guinea,6,5.2
Vanuatu,6,4.8
Japan region,5,4.8
Tonga,4,5.7
[2 computed]

Japan has 57 earthquakes, the Noto Peninsula earthquake among them, so its strongest is 7.5. Without the line that takes Earthquake off, Japan would have 56, and a region called Japan Earthquake would have one. Japan region is a separate row: USGS names the sea off Japan that way, and this program does not merge the two. Moving it into Japan would take one more line in region.

The tally says two things were computed: reading regions.scm and evaluating it. The reads the program made went through the kernel inside the evaluation.

3. Cached, and recomputed when what it read changes

An evaluation is uncacheable unless the program says otherwise, because a program may write. This one wraps its answer in (cacheable …). The kernel honors that only when the evaluation wrote nothing, and the answer is never fresher than what it read. It hangs from the thread of every resource the program read: the program file, weeks.txt and both weeks.

Give the program a name, the way Naming a composition did. in is urn:lisp:eval’s input for the program text, and <urn:file:regions.scm> is a reference, so the kernel reads the file for it. Save these lines as regions.iki. In one REPL, so the cache carries from line to line, they bind the name, ask it twice, add the second week to weeks.txt and ask again:

bind urn:quakes:regions = source urn:lisp:eval in=<urn:file:regions.scm>
source urn:quakes:regions
cache urn:quakes:regions
sink urn:file:weeks.txt urn:file:quakes-2024-01-01.geojson urn:file:quakes-2024-01-08.geojson
cache urn:quakes:regions
source urn:quakes:regions
$ ikigai --quiet --files . --names names < regions.iki
bound in <urn:iki:host:names>:
urn:quakes:regions = source urn:lisp:eval in=<urn:file:regions.scm>
[uncacheable]
region,quakes,strongest
Japan,57,7.5
Indonesia,13,5.3
Philippines,7,5
Northern Mariana Islands,7,5
Papua New Guinea,6,5.2
Vanuatu,6,4.8
Japan region,5,4.8
Tonga,4,5.7
[computed]
cached
wrote 69 bytes to weeks.txt
[uncacheable]
not cached
region,quakes,strongest
Japan,63,7.5
Indonesia,21,5.3
Philippines,18,6.7
Tonga,13,5.7
Northern Mariana Islands,13,5
Papua New Guinea,10,5.2
Vanuatu,8,5.2
Japan region,8,4.9
[computed]

After the first answer the name was cached. The write to weeks.txt cut that file’s thread, the answer hung from it, and so the next read computed the tally again, now over both weeks: the second week’s M6.7 off the Philippines moved the Philippines up to third place. Nobody told the kernel the program read weeks.txt. The program read it through the kernel, so the kernel knew.

The name went into names, the file Naming a composition wrote, beside the six names already there.

4. What the program can reach

A program’s only way out is the kernel’s verbs: source, sink, meta, exists, delete and invoke, each issued under the capability of the session that runs it. The sandbox has no other door. Every name a program could use to open a file, print, read the environment or load code is refused, and only a short list of pure functions is left (numbers, lists and strings).

Narrow a session to what the tally needs: reading this directory, and running Lisp (urn:cap:lisp). Then ask for the tally, a write, a request to USGS, and a file opened without the verbs. "$PWD" is in double quotes so the shell puts your directory in the scope:

$ ikigai --quiet --files . --names names -c "cap urn:cap:fs:read:$PWD urn:cap:lisp" -c 'source urn:quakes:regions' -e '(sink "urn:file:weeks.txt" "nothing")' -e '(source "urn:http:get" "https://earthquake.usgs.gov/")' -e '(open-input-file "weeks.txt")'; echo "exit $?"
narrowed — capability: urn:cap:fs:read:$PWD, urn:cap:lisp
region,quakes,strongest
Japan,63,7.5
Indonesia,21,5.3
Philippines,18,6.7
Tonga,13,5.7
Northern Mariana Islands,13,5
Papua New Guinea,10,5.2
Vanuatu,8,5.2
Japan region,8,4.9
[computed]
error: denied: capability does not grant `urn:cap:fs:write:*` (declared by `urn:file:weeks.txt`; any grant under `urn:cap:fs:write:` satisfies it)
error: denied: capability does not grant `urn:cap:net:*` (declared by `urn:http:get`; any grant under `urn:cap:net:` satisfies it)
error: denied: lisp: Error: Generic: `open-input-file` is not available to a program (ikigai-lisp's sandbox allows the kernel verbs and a short list of pure functions; see src/sandbox.rs)
— batch: 5 commands · computed
exit 1

The tally ran, because everything it reads is in this directory. The write and the request were refused by the resources they asked, with the grant each needs. The file opened without a verb was refused by the sandbox before anything was read. A program has the authority of the session that runs it, and no more.

The short list of pure functions is also where the sandbox stops. It has +, -, *, quotient and modulo, and no division, square root or trigonometry:

$ ikigai --quiet --files . -e '(/ 1 2)' -e '(sqrt 2)'; echo "exit $?"
error: denied: lisp: Error: Generic: `/` is not available to a program (ikigai-lisp's sandbox allows the kernel verbs and a short list of pure functions; see src/sandbox.rs)
error: denied: lisp: Error: Generic: `sqrt` is not available to a program (ikigai-lisp's sandbox allows the kernel verbs and a short list of pure functions; see src/sandbox.rs)
exit 1

So the next question is not one for this program.

5. Where composition stops: a peer in Python

Name a point, and ask how far each earthquake was from it. Here the point is Tokyo, at latitude 35.68 and longitude 139.69. The distance between two points on the Earth’s surface is a great circle, and the sum needs a sine, a cosine and an arcsine. jq has those, so a long q= could hold the sum, and the Lisp sandbox has none of them. Neither is where this code belongs. The day you want the Earth as an ellipsoid rather than a sphere, or a coastline from a shapefile, you want a library, and the libraries are in languages like Python.

A peer is a process of its own that serves resources over the kernel’s wire protocol, and ikigai-python (pure Python, one dependency, blake3) is how a Python process serves them. Install it, in a virtual environment if you keep one:

pip install git+https://github.com/ikigai-rs/ikigai-python

Save this as near.py (it is examples/quakes/near.py in this book’s repository):

EARTH_KM = 6371.0  # the mean radius of the Earth


def km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
    """The great-circle distance between two points, by the haversine formula."""
    p1, p2 = math.radians(lat1), math.radians(lat2)
    dp, dl = p2 - p1, math.radians(lon2 - lon1)
    a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
    return 2 * EARTH_KM * math.asin(math.sqrt(a))


# A family: `{lat}` and `{lon}` come from the name, the week from the pipe. A pure function
# of the three, so it says `cacheable=True`; whether the mounting host keeps a copy is the
# host's call, and the chapter shows what ikigai-cli does with it.
@endpoint("urn:quakes-py:near:{lat}:{lon}", output="text/csv", cacheable=True,
          summary="The five earthquakes of a GeoJSON week nearest a point, nearest first")
def near(lat: Annotated[float, "the point's latitude, in degrees north"],
         lon: Annotated[float, "the point's longitude, in degrees east"],
         week: Annotated[str, "a USGS GeoJSON feed, piped in"]) -> str:
    if not -90 <= lat <= 90:
        raise InvalidArgumentError("lat", f"{lat} is not between -90 and 90")
    if not -180 <= lon <= 180:
        raise InvalidArgumentError("lon", f"{lon} is not between -180 and 180")
    rows = []
    for quake in json.loads(week)["features"]:
        qlon, qlat = quake["geometry"]["coordinates"][:2]
        rows.append((km(lat, lon, qlat, qlon), quake["properties"]["mag"],
                     quake["properties"]["place"]))
    rows.sort()
    lines = ["km,mag,place"]
    lines += [f'{d:.0f},{mag},"{place}"' for d, mag, place in rows[:5]]
    return "\n".join(lines)

@endpoint gives the function a name, and the signature is its contract. {lat} and {lon} come from the name, so every point has a name of its own. week is the one input left, so a piped value reaches it. The peer reads no file and asks the kernel for nothing: the week it measures is the week it is handed.

Start the peer, and wait for its socket before you mount it (the rm -f clears a socket an earlier run left behind):

rm -f /tmp/quakes-near.sock
python3 near.py /tmp/quakes-near.sock &
until [ -S /tmp/quakes-near.sock ]; do sleep 0.2; done

--mount urn:quakes-py:=/tmp/quakes-near.sock mounts it: the CLI forwards every name under urn:quakes-py: to that socket. Pipe each week into the point:

$ ikigai --quiet --files . --mount urn:quakes-py:=/tmp/quakes-near.sock -c 'source urn:file:quakes-2024-01-01.geojson | urn:quakes-py:near:35.68:139.69' -c 'source urn:file:quakes-2024-01-08.geojson | urn:quakes-py:near:35.68:139.69'
km,mag,place
168,4.5,"39 km ESE of Takahagi, Japan"
282,4.6,"55 km ENE of Anamizu, Japan"
283,5.2,"56 km ENE of Anamizu, Japan"
286,4.7,"66 km NNW of Itoigawa, Japan"
287,4.6,"71 km NNW of Itoigawa, Japan"
[1 computed · 1 uncacheable]
km,mag,place
280,4.5,"63 km NNW of Itoigawa, Japan"
291,4.5,"42 km NE of Anamizu, Japan"
296,5.9,"53 km WSW of Sado, Japan"
297,4.5,"60 km WSW of Sado, Japan"
426,4.6,"Izu Islands, Japan region"
[1 computed · 1 uncacheable]
— batch: 2 commands · 2 computed · 2 uncacheable

The nearest earthquake to Tokyo in the first week was 168 km away, off Takahagi; the Noto aftershocks were nearly 300 km away, across the island.

6. Two processes

That was two processes: the CLI, and Python. The pipe crossed between them twice, the week out and the CSV back. Three things follow from that, and they are why a peer is the last rung here rather than a bigger rule.

A peer has its own cache, or none. Each stage’s tally above says 1 uncacheable, and the peer says cacheable=True. Ask the same point twice, and ask the kernel why it kept nothing:

$ ikigai --quiet --files . --mount urn:quakes-py:=/tmp/quakes-near.sock -c 'source urn:file:quakes-2024-01-01.geojson | urn:quakes-py:near:35.68:139.69' -c 'source urn:file:quakes-2024-01-01.geojson | urn:quakes-py:near:35.68:139.69' -c 'source urn:kernel:uncached'
…
[1 computed · 1 uncacheable]
…
[1 cached · 1 uncacheable]
uncached (why a computed read was not cached; the last 64, most recent first)
  urn:quakes-py:near:35.68:139.69      ×2  declared  [root]

[uncacheable]
— batch: 3 commands · 1 cached · 1 computed · 3 uncacheable

The week came from the CLI’s cache the second time; the peer was asked again. ikigai-cli 0.2.6 keeps no copy of what a mounted peer answers, whatever the peer declares (declared is the mount’s own declaration). A peer can change its answer without the kernel hearing of it, because no thread crosses the socket, so a copy could be served stale for as long as the CLI runs. The Lisp program’s answer hung from every file it read; the peer’s answer hangs from nothing the kernel can cut.

A peer is another authority. The program in section 4 had exactly the session’s capability, and a narrowed session narrowed it. The peer runs with whatever its process may do, as the user who started it, and the session’s capability decides only whether the session may ask it. This peer declares nothing it requires, so even a cap read-only session reaches it. That it reads no files is a property of these lines of Python, not something the kernel enforces. It also has no way back: ikigai-python has no channel back to the mounting kernel to read a name through, so this peer could not read urn:file:quakes-2024-01-01.geojson if it tried, and the week has to be piped in.

A mount takes a whole prefix. The CLI serves a mount ahead of its names layer, so a peer mounted at urn:quakes: would take every name under it, urn:quakes:regions included, and answer none of them. That is why this peer has a first segment of its own, urn:quakes-py:. (On ikigai-cli 0.2.6, on 2026-10-30, mounting this peer at urn:quakes: made source urn:quakes:regions fail: the CLI forwarded urn:regions to the peer, which serves no such name.)

Stop the peer when you are done (kill %1 in the shell that started it), or leave it running: nothing later in this part uses its socket.

7. Spaces that change

One more rung is not shown here: a dynamic space, whose doors are rebuilt from a declaration while the kernel runs. You have used one already. The names layer of Naming a composition is urn:iki:space:dynamic:names, rebuilt each time names changes. A dynamic space of your own binds its doors to endpoints a host registers, and this part writes no host, and this page’s kernel holds no dynamic space, so there is no Run cell for one here. A space that changes while it runs, later in the book, builds one in Rust and runs it in its page.

8. The ladder

  • A rule (jq, SPARQL, XSLT, a template) answers from the inputs its caller names. It stops where the question has to decide what to read.
  • A name for a rule (Naming a composition) fixes its inputs and keeps it cached. It is still one request.
  • A program (Lisp, this chapter) reads what it decides to read, through the kernel, under the session’s capability, and its answer is cached and recomputed by the threads of everything it read. It stops at the sandbox’s list of pure functions.
  • A peer (Python, this chapter) is any code you like, in a process of its own. It stops being the kernel’s to govern: its own authority, and no copy of its answers in the CLI’s cache.

Each rung up gives you more to say and gives the kernel less it can check for you.

Resolution

A name, not a call

urn:iki:fn:toUpper is a name. Nothing about it says where the code lives, what language it is in, whether the answer is computed now or was computed an hour ago and cached, or whether it runs in this process. Those are all decisions the kernel makes when it resolves the name.

This is the one idea to take seriously before any of the code makes sense. In a function call, the caller has already decided almost everything by the time it calls. In a resolution, the caller has decided only what it wants.

Nine terms follow. Each is a paragraph here and a thing you do somewhere in Part I, and every one of them says where — this chapter is the map, not the territory.

The five verbs

There is no method vocabulary to learn, because there are five verbs and they are the same five for every resource in the system:

verbmeaning
Sourcegive me a representation of this
Sinkhere is a new state for this
Existsis there anything at this name?
Deleteremove it
Metadescribe yourself

You issue a Source in Hello, resource and a Sink in What resolution buys you, where the write is what cuts a thread.

Meta is the one that surprises people. Every endpoint can be asked what it is, what arguments it takes, what it returns, and what authority it requires — and it answers in a machine-readable form. That is what makes the system legible to an agent rather than merely usable by a programmer, and it is the subject of Why an endpoint describes itself.

Representations

A resolution returns a representation: bytes plus a media type. Not an object, not a language-specific value — bytes with a declared type, because the answer may have come from another process, another machine, or another language.

When the type you have is not the type you want, a transreptor converts between them. The consequence worth internalizing: a resource does not have a format. It has whatever formats the kernel can reach from what it has, and asking for as=text/turtle is a routing question, not a serialization call.

You return one in Hello, resource — bytes and their type — and ask for one as Turtle in What resolution buys you.

Golden threads

When a resolution is derived from other resolutions, the kernel records the dependency. Write to something upstream and everything that declared a dependency on it — directly, or by having resolved it — is invalidated: precisely, not by guesswork and not by expiry guessing. (A pure function of its arguments declares no dependency of its own; since core 0.1.73 the one thread it hangs from is the one the kernel names after it, so only a cut of its own name touches it, as the front door showed.)

This is why caching here is not a bolt-on. A cache that cannot tell you why an entry is still valid has to fall back on timeouts; one that tracks derivation can keep an answer until the thing it came from actually changes.

⚠ It also means cacheability propagates from your dependencies. Adding one uncacheable source to an otherwise cached resource makes the whole thing uncacheable — a correctness no-op that is a large performance change. Nothing warns you; the types are identical either way.

You will cut one in What resolution buys you, and watch a derived result recompute that never named the thread it depended on; Golden threads in practice is the whole mechanism, and what bounds the cache.

Capabilities

Authority travels with the invocation, not with the process. An endpoint runs under a capability, and when it resolves a sub-request that capability is what the sub-request runs under — it can be narrowed on the way down, never widened.

The rule the whole system leans on: declared capabilities are enforced capabilities. An action that enforces authority it does not declare makes the catalog lie by promising more than it can do; one that declares authority it does not enforce is worse. Both are treated as defects.

Part I runs everything as root, on purpose; The file workspace is where scoped capabilities are explained (the grammar chapter’s cap already showed one refuse a write), and Part III leans on them hardest — Who is asking is where who and what may happen come apart, and where the two ways authority could grow are named and declined.

Where this is going

Put those together and you get a system that can describe itself completely: a catalog of every resolvable name (urn:kernel:catalog), and a capability-scoped list of what the current caller may actually invoke (urn:kernel:actions). An agent’s tool list is not something you write down for it — it is that second thing, computed.

You read the catalog of this book’s own host in What resolution buys you, learn why the descriptions it is built from are load-bearing in Why an endpoint describes itself, and meet the tool list as a client would in The machine client.

Running it

Read a chapter, then have a kernel in front of you. Four ways, cheapest first.

In this page

The published book carries its own kernel: Part I’s space — camel-case and the endpoints What resolution buys you adds — compiled to WebAssembly, under the same engine the CLI uses. Where a chapter has a Run button, the lines beside it are real REPL lines and the answer is a real resolution, in your browser, with nothing installed:

resourceOrientedComputing
[computed]

Nothing runs until you press Run, and nothing is answered in advance — the listing’s expected output sits behind a disclosure under the cell. One kernel serves the whole page and keeps its cache between runs, so a second press answers [cached], and every run is kept under the cell. The command is yours to edit — Reset puts the chapter’s command back and empties the cell. The kernel binds Part I’s names and nothing else — the other parts’ hosts, and your own crate, are not in the page — and if it fails to load, the expected output stands in for the result and the cell says so.

The tutorial binary (this repo, nothing else)

cargo run -p hello-camel -- "resource oriented computing"
cargo run -p hello-camel -- --catalog
in  resource oriented computing
out resourceOrientedComputing

@prefix ik: <https://ikigai-rs.dev/ns#> .
…
<urn:ikigai:endpoint:camel-case> a ik:Endpoint ;
    ik:id "camel-case" ;
…

That is a complete ikigai host: a root space, a kernel around it, one resolution. It is about thirty lines and you will have read all of them by the end of Binding, and a host of your own. The second form prints the host’s catalog — every endpoint it binds, describing itself, several hundred lines of which a … here stands for most — which What resolution buys you reads through the same kernel.

The CLI

The full host is the ikigai-cli crate, which installs a binary called ikigai. If you installed it in Twenty minutes, nothing compiled, it is already there; if not:

cargo install ikigai-cli --locked

⚠ The crate is ikigai-cli. Only the binary is called ikigai — and cargo install ikigai does not fail, it fetches an unrelated crate by another author and leaves you with something that is not this. --locked builds against the dependency versions the release was tested with rather than whatever the registry resolves today.

The other side of --locked: it also holds you to that release’s flaws. ikigai-cli 0.1.68 is the first release whose lockfile fixes the rustls-webpki advisory (RUSTSEC-2026-0382), using 0.103.16 instead of 0.103.15. Install 0.1.68 or later.

To reach a kernel on another machine over QUIC, add --features quic; Install says why.

One-shot resolutions take -c, and --plain drops the decoration so output is pipeable:

ikigai --plain -c 'source urn:iki:fn:toUpper in="hello"'

Run it with no arguments and you get a REPL with the same grammar: pipes (|), map (..), named arguments, compose, cache, cap, trace, and list. The REPL, the one-shot flag and the page-assembling browser demo all drive the same engine — worth knowing early, because anything you learn in one place transfers. One difference that matters: the cache lives in the process, so a cache hit is something you see in a REPL session and never across two -c runs.

The two commands the front door opened with are worth keeping at hand, because they show the system describing itself rather than doing work:

ikigai --plain -c 'source urn:kernel:catalog'
ikigai --plain -c 'source urn:kernel:actions'

The first is everything resolvable. The second is everything you may invoke, given the capability you are holding — the same list an agent would be handed.

The browser demo

https://ikigai-rs.github.io/ikigai-web-demo/ runs the kernel as WebAssembly, in the page, with no server: the whole page is one resource that the in-browser kernel composed. The Control tab shows the scheduler, the cache and its golden threads updating live; the Demo tab is a set of runnable walkthroughs.

It is also the only host that loads modules rather than linking everything in (as of 2026-09-08) — see What a module is.

Hello, resource

The smallest useful thing: an endpoint that camel-cases text. It is this book’s “hello world”, and it is a real endpoint — the listing below is included from the crate that compiles it, so it cannot drift from what actually runs.

The implementation

/// Camel-case the whitespace-separated words of the `in` argument.
///
/// The first word is left exactly as given and each subsequent word has its first
/// character upper-cased, so `"resource oriented computing"` becomes
/// `"resourceOrientedComputing"`.
///
/// ⚠ Note what it deliberately does *not* do: it never lower-cases anything. `"Hello
/// WORLD"` becomes `"HelloWORLD"`, because the input's own casing is treated as
/// meaningful rather than as noise to normalize away.
pub fn camel_case_impl(inv: &Invocation<'_>) -> Result<Representation> {
    let input = inv.inline_str("in")?;
    Ok(Representation::new(text_plain_utf8(), camel(input).into_bytes()).cacheable())
}

/// The pure function under the endpoint, so `camel-title` can reuse it without a
/// second resolution.
pub fn camel(input: &str) -> String {
    let mut words = input.split_whitespace();
    let mut output = words.next().unwrap_or_default().to_string();

    for word in words {
        let mut characters = word.chars();
        if let Some(first) = characters.next() {
            output.extend(first.to_uppercase());
            output.push_str(characters.as_str());
        }
    }
    output
}

The endpoint is the first function; the second is the pure string function under it, split out so a later chapter can reuse it. Four things in there are worth slowing down for.

inv.inline_str("in")

Arguments arrive on the Invocation, by name, and an argument may be inline bytes or a reference to another resource — ArgRef::Inline and ArgRef::Reference. inline_str says “give me this argument as a UTF-8 string, and fail cleanly if it is not there, is not text, or is a reference rather than a value.”

That last clause is the one to notice. An endpoint written this way accepts values, and taking a resource is an opt-in: you match ArgRef::Reference(iri) yourself and resolve it with inv.source(&iri).await, which makes the endpoint async and threads your answer to theirs. It is a real change to your code, and it is exercise 3.

Pipes do not need it, which is worth separating out: | is not a shell feature bolted on, but what it passes along is the previous resolution’s output, inline — one resolution’s answer becoming the next one’s argument, by value.

Representation::new(text_plain_utf8(), …)

You return bytes and their type. Always. The type is not decoration: it is what lets a transreptor find a route from what you produced to what somebody asked for.

.cacheable()

You are asserting this is a pure function of its declared inputs. Same arguments, same answer, forever. The kernel may then cache it and hand out the cached representation under a golden thread.

Get this wrong in the optimistic direction and you have a bug that is very hard to see: stale answers that look plausible. The rule of thumb is the honest one — when in doubt, do not cache. camel-case is genuinely pure, so it says so.

char::to_uppercase returns an iterator

Not a char. Some characters upper-case to more than one — German ß becomes SS — so the API cannot pretend otherwise. output.extend(first.to_uppercase()) is the Unicode-correct form; push would not compile, which is the type system doing you a favor.

Try it

#![allow(unused)]
fn main() {
extern crate hello_camel;
extern crate ikigai_core;
use hello_camel::camel_case;
use ikigai_core::Endpoint;

// Every endpoint knows its own name.
assert_eq!(camel_case().describe().id, "camel-case");
}

And the endpoint itself, resolved by the kernel in this page:

HelloWORLD
[computed]

That is an endpoint. What you get for having resolved a name rather than called a function is the next chapter, What resolution buys you.

What resolution buys you

Hello, resource built an endpoint that camel-cases text, and if you are honest about it, a function would have done the same job in fewer lines. This chapter is where the difference shows up. Four things happen to a resolution that never happen to a function call, and you run each of them.

Everything here resolves through the tutorial host — hello_camel::kernel(), a kernel over this book’s space() — which you have not built yet. Binding, and a host of your own is where you read it in full; for now it is the thing that answers.

Two more endpoints join camel-case for this chapter, because a pure function of its arguments cannot show most of what follows. title is a string the host holds in memory, readable and writable; camel-title is the camel-cased form of whatever title currently says. Here is title:

/// What `title` is *about*: a string, and a count of how often it has been read.
///
/// The state lives outside the endpoint on purpose. An endpoint over shared state is the
/// normal shape — a store handle, a connection pool, a file — and keeping the handle
/// where a test can hold it is what lets the test *prove* a cached answer was served
/// without this code running, rather than merely returning the same bytes, which a
/// recompute would also do.
#[derive(Debug)]
pub struct TitleState {
    text: Mutex<String>,
    /// How many times the `Source` arm has actually run.
    pub reads: AtomicUsize,
}

impl Default for TitleState {
    fn default() -> Self {
        TitleState {
            text: Mutex::new("resource oriented computing".to_string()),
            reads: AtomicUsize::new(0),
        }
    }
}

/// `title`: a string the host holds in memory. `Source` reads it, `Sink` replaces it.
///
/// The `Source` representation is `.cacheable()` *and* `.depends_on(TITLE)`. Together
/// those say: cache this, and treat it as valid until the thread named `TITLE` is cut.
/// The kernel cuts that thread itself after every successful `Sink` to this name — so
/// a write invalidates the cached read, and everything derived from it, with no code
/// here doing the invalidating.
pub fn title(state: Arc<TitleState>) -> FnEndpoint {
    FnEndpoint::new("title", move |inv: &Invocation<'_>| {
        let mut current = state.text.lock().expect("title lock");
        match inv.request.verb {
            Verb::Sink => {
                *current = inv.inline_str("content")?.to_string();
                Ok(Representation::new(text_plain_utf8(), b"ok".to_vec()))
            }
            _ => {
                state.reads.fetch_add(1, Ordering::SeqCst);
                Ok(
                    Representation::new(text_plain_utf8(), current.clone().into_bytes())
                        .cacheable()
                        .depends_on(TITLE),
                )
            }
        }
    })
    .with_description(
        Description::new("title")
            .title("Title")
            .summary("A string the host holds in memory: read it, or replace it.")
            .verb(Verb::Meta)
            // Two verbs with two different contracts, so each gets its own action.
            .action(
                ActionSpec::new(Verb::Source)
                    .summary("the current title")
                    .output(TEXT_PLAIN_UTF8),
            )
            .action(
                ActionSpec::new(Verb::Sink)
                    .summary("replace the title")
                    .input(
                        ArgSpec::new("content")
                            .summary("the new title")
                            .class(XSD_STRING),
                    ),
            ),
    )
}

Two things to notice before it runs. The Source arm says .cacheable() — “cache this, and treat it as valid until the thread named urn:iki:tutorial:title is cut.” It needs to say nothing more: since ikigai-core 0.1.73 the kernel hangs every cacheable read from the thread named after the resource it read. (The .depends_on(TITLE) beside it was needed before that, and is now harmless rather than load-bearing — a thread declared twice is one thread; Golden threads shows the endpoint without it.) And the Sink arm cuts nothing: the kernel does that itself, after any successful write, to the thread named after the write’s target. The two names are the same string, so the write reaches the read.

And camel-title, which is the interesting one:

/// `camel-title`: the camel-cased form of whatever `title` currently says.
///
/// This endpoint takes no arguments. It *resolves* `urn:iki:tutorial:title` through the
/// kernel — `inv.source(..)` — and camel-cases the answer. That one call is what makes it
/// a composite: the kernel records the sub-resolution as a dependency, so this result
/// inherits `title`'s golden thread and is invalidated when `title` is written, even
/// though nothing here names the thread.
///
/// `inv.source` is async, which is why this is an [`AsyncFnEndpoint`] rather than the
/// [`FnEndpoint`] the other two are.
pub fn camel_title() -> AsyncFnEndpoint {
    AsyncFnEndpoint::new("camel-title", |inv: &Invocation<'_>| -> InvokeFuture<'_> {
        Box::pin(async move {
            let source = inv
                .source(&Iri::parse(TITLE).expect("a constant IRI"))
                .await?;
            let text = String::from_utf8_lossy(&source.bytes);
            Ok(Representation::new(text_plain_utf8(), camel(&text).into_bytes()).cacheable())
        })
    })
    .with_description(
        Description::new("camel-title")
            .title("Camel-cased title")
            .summary("The camel-cased form of whatever `title` currently says.")
            .verb(Verb::Source)
            .verb(Verb::Meta)
            .output(TEXT_PLAIN_UTF8),
    )
}

It takes no argument. It resolves title — inv.source(..) — which is the same act a caller performs from outside, made from inside an invocation. That one line is what makes it a composite, and the rest of this chapter is the consequences of that line.

The four demonstrations are tests in crates/hello-camel/tests/payoff.rs. Each is included below from the file that runs it. To see their output rather than take the book’s word:

cargo test -p hello-camel --test payoff -- --nocapture --test-threads 1

Or press Run. Each section below ends in a cell: a command you can edit, and the output the listing produces, shown as expected until you run it against this book’s own kernel, in this page — book_wasm::page_kernel() compiled to WebAssembly, which serves Part I’s names exactly as hello_camel::kernel() does, under the same engine the ikigai CLI uses, so the lines are the CLI’s grammar and the answers are real resolutions. Nothing runs until you press Run (or Enter in the command). One kernel serves every cell on the page and keeps its cache and its golden threads between runs; that is the point, so the order you run them in shows, running a cell twice answers differently the second time, and every run stays under the cell so the two can be compared. Edit a command and see what changes — a name the kernel does not bind answers error: no endpoint resolved, which is the CLI’s answer too. Nothing is answered in advance: the listing’s expected output sits behind a disclosure under each cell, and stands in for the result only if the kernel did not load — the cell says so. Reset puts the whole cell back the way the chapter shipped it.

1. Cached once

Resolve title twice through one kernel. The second answer is served without the endpoint running — and served is the claim, so the test does not settle for the same bytes twice (a recompute would give those too). It counts.

/// The second `Source` of a cacheable resource is served without the endpoint running.
#[test]
fn a_cacheable_resource_is_computed_once_and_then_served() -> Result<()> {
    let state = Arc::new(TitleState::default());
    let kernel = kernel_over(state.clone());
    let request = Request::new(Verb::Source, iri(TITLE));

    // Nothing has been resolved yet, so nothing is cached.
    assert!(!kernel.is_cached(&request, &Capability::root()));

    let first = source(&kernel, TITLE)?;
    assert!(kernel.is_cached(&request, &Capability::root()));

    let second = source(&kernel, TITLE)?;
    assert_eq!(first, second);

    // Same bytes twice is not the proof — a recompute would also give the same bytes.
    // The proof is that the endpoint ran once.
    assert_eq!(state.reads.load(Ordering::SeqCst), 1);
    println!("cached once: {first:?} served twice, endpoint ran 1 time");
    Ok(())
}
cached once: "resource oriented computing" served twice, endpoint ran 1 time

kernel.is_cached(..) is a probe, not a resolution: it answers “would this be served from cache right now” without resolving anything, which is what lets a test ask the question without changing the answer.

resource oriented computing
[computed]
resource oriented computing
[cached]

The bracketed word is the engine’s verdict on each line: on your first run the first resolution is computed and the second served. Press Run again and both say [cached] — the history under the cell keeps both runs so you can see the change. Then edit the name to one that is not bound and run that.

The cache is keyed on the request, the capability, and the chain the request was resolved in — a result computed under one authority is never handed to a caller holding another, and a result computed in one context is never served in another. You will not feel either here, where everything runs as root in the plain root, but it is why .cacheable() is safe to say on an endpoint whose answer depends on who is asking. Golden threads in practice states the rule; Scope and alias shows the third slot at work.

2. A golden thread, cut

This is the one the introduction promised. camel-title is cached after its first resolution. Then title is written — and camel-title’s cache entry is gone, even though camel-title never mentioned a thread.

/// A write to `title` invalidates `camel-title`, which never named the thread it depends
/// on — it inherited it by resolving `title`.
#[test]
fn a_sink_upstream_cuts_the_thread_and_the_composite_recomputes() -> Result<()> {
    let state = Arc::new(TitleState::default());
    let kernel = kernel_over(state.clone());
    let composite = Request::new(Verb::Source, iri("urn:iki:tutorial:camel-title"));

    assert_eq!(
        source(&kernel, "urn:iki:tutorial:camel-title")?,
        "resourceOrientedComputing"
    );
    assert!(kernel.is_cached(&composite, &Capability::root()));
    assert_eq!(state.reads.load(Ordering::SeqCst), 1);

    // The write. The kernel cuts the thread named `urn:iki:tutorial:title` on its way
    // out, because that is the target of a successful mutating verb.
    sink(&kernel, TITLE, "golden threads cut")?;

    // ...and the composite's cache entry is gone with it, transitively.
    assert!(!kernel.is_cached(&composite, &Capability::root()));
    assert_eq!(
        source(&kernel, "urn:iki:tutorial:camel-title")?,
        "goldenThreadsCut"
    );
    // The recompute went all the way down: `title` was read again, not served stale.
    assert_eq!(state.reads.load(Ordering::SeqCst), 2);
    println!("thread cut: camel-title recomputed after a Sink to title");
    Ok(())
}
thread cut: camel-title recomputed after a Sink to title

Follow the thread. title’s Source answered a cacheable read, and the kernel hung it from the thread named after title. camel-title resolved title through the kernel, and the kernel recorded that: a composite inherits the golden threads of everything it resolved. The Sink to title succeeded, so the kernel cut the thread named urn:iki:tutorial:title. Every cached representation depending on that thread — directly, or transitively through composition — stopped being valid at that instant.

Nothing polled. No timeout expired. No one wrote an invalidation. The write invalidated exactly what was derived from the thing written, because derivation was recorded rather than guessed at — and the last assertion is the one to sit with: the recompute went all the way down, reading title again rather than reusing a stale copy of it.

⚠ The thread’s name is the resource’s IRI, by convention and by the kernel’s own choice: after a mutating verb it cuts the thread named after the target. An endpoint that declares .depends_on("some-other-name") is not wrong, but nothing will cut that thread unless something explicitly does — The file workspace has a watcher doing exactly that for files that change out from under the kernel.

resourceOrientedComputing
[computed]
cached
ok
[uncacheable]
not cached
goldenThreadsCut
[computed]

cache is is_cached from the REPL — a probe. Between the two probes is one Sink, and the composite went from cached to not cached without anyone naming it. (The Sink’s own verdict is [uncacheable]: a write is never served from a cache, by definition.)

3. Traced

A resolution is a tree — the request you issued, and every sub-request made on its behalf — and the kernel will show you the tree. Hand issue_traced something that implements Tracer:

/// A tracer that keeps every event. The kernel hands it one `TraceEvent` per invocation
/// of the resolution it was passed to — and only that resolution.
#[derive(Default)]
struct Recorder(Mutex<Vec<TraceEvent>>);

impl Tracer for Recorder {
    fn record(&self, event: TraceEvent) {
        self.0.lock().expect("recorder lock").push(event);
    }
}
/// Tracing one resolution shows the sub-resolution `camel-title` made, as a child span.
#[test]
fn a_traced_resolution_shows_the_sub_resolution_as_a_child_span() -> Result<()> {
    let kernel = kernel();
    let recorder = Arc::new(Recorder::default());
    let request = Request::new(Verb::Source, iri("urn:iki:tutorial:camel-title"));

    block_on(kernel.issue_traced(request, &Capability::root(), recorder.clone()))?;

    let events = recorder.0.lock().expect("recorder lock").clone();
    for event in &events {
        println!(
            "span {} parent {:?}  {}  cache_hit={}",
            event.span, event.parent, event.target, event.cache_hit
        );
    }

    // Two invocations: the one we asked for, and the one it made.
    let root = events
        .iter()
        .find(|e| e.parent.is_none())
        .expect("a root span");
    let child = events
        .iter()
        .find(|e| e.parent.is_some())
        .expect("a child span");
    assert_eq!(root.target, "urn:iki:tutorial:camel-title");
    assert_eq!(child.target, TITLE);
    assert_eq!(child.parent, Some(root.span));
    assert!(
        !child.cache_hit,
        "a fresh kernel: the sub-resolution was computed"
    );
    Ok(())
}
span 1 parent Some(0)  urn:iki:tutorial:title  cache_hit=false
span 0 parent None  urn:iki:tutorial:camel-title  cache_hit=false

The child prints first, because it finished first — an event is recorded when its invocation completes. (span, parent) pairs reconstruct the tree: title ran as span 1, inside span 0. Each event also records whether the cache served it, which worker thread it ran on, and the capability it ran under, so an attenuation down a call chain is visible node by node. Run the traced resolution a second time on the same kernel and both spans report cache_hit=true.

The events are plain, serializable data. That matters in Part III, where a remote kernel records its own events and ships them back to be stitched into the caller’s tree — and Tracing a resolution is where the events that name something which never ran (a denial, a miss inside a confinement) get their own chapter.

cut urn:iki:tutorial:title
[uncacheable]
trace  urn:iki:tutorial:camel-title
  client      ikigai repl  ·  capability: root (full authority)
  transport   embedded · in-process
 
urn:iki:tutorial:camel-title   camel-title · computed · ThreadId(1) · —   → 16b  goldenThreadsCut
└─ urn:iki:tutorial:title   title · computed · ThreadId(1) · —

The first line cuts title’s thread by hand — urn:kernel:cut is the resource for cutting somebody else’s thread — so the trace shows a real resolution rather than a cache hit with no children. trace is issue_traced with the engine’s own tree renderer: the child is indented under its parent, and each node says whether it was computed or served. (The — is the duration: this kernel has no clock, and it says so rather than guessing; ThreadId(1) is the browser’s one thread. The text is goldenThreadsCut because the cell above wrote the title, in the same kernel; run this one first, on a fresh page, and it reads 25b resourceOrientedComputing.)

4. Described

Everything so far was about resolving a name. Meta is about asking it. Through the tutorial host’s renderer, the answer is a graph:

/// `Meta` on `camel-case`, rendered as Turtle: the description is a graph, and the
/// endpoint's inputs are nodes in it with stable names.
#[test]
fn meta_renders_the_description_as_a_graph() -> Result<()> {
    let kernel = kernel();
    let request = Request::new(Verb::Meta, iri("urn:iki:tutorial:camel-case"))
        .with_arg("as", ArgRef::Inline(b"text/turtle".to_vec()));

    let repr = block_on(kernel.issue(request, &Capability::root()))?;
    let turtle = String::from_utf8_lossy(&repr.bytes);
    println!("{turtle}");

    assert_eq!(repr.repr_type.media_type, "text/turtle");
    assert!(turtle.contains("<urn:ikigai:endpoint:camel-case> a ik:Endpoint"));
    assert!(turtle.contains("ik:input <urn:ikigai:endpoint:camel-case:input:in>"));
    assert!(turtle.contains("ik:class <http://www.w3.org/2001/XMLSchema#string>"));
    Ok(())
}
@prefix ik: <https://ikigai-rs.dev/ns#> .

<urn:ikigai:endpoint:camel-case> a ik:Endpoint ;
    ik:id "camel-case" ;
    ik:title "Camel-case" ;
    ik:summary "Camel-cases the UTF-8 text supplied in the `in` argument." ;
    ik:verb "Source", "Meta" ;
    ik:output "text/plain;charset=utf-8" ;
    ik:input <urn:ikigai:endpoint:camel-case:input:in> ;
    ik:action <urn:ikigai:contract:camel-case:source:b3:…> .

<urn:ikigai:endpoint:camel-case:input:in> ik:inputName "in" ;
    ik:source "argument" ;
    ik:required true ;
    ik:summary "the text to camel-case" ;
    ik:class <http://www.w3.org/2001/XMLSchema#string> .

(The graph goes on: the contract that ik:action points at, and the input again under it. The run below prints all of it.)

Three triples are worth reading slowly, and the test asserts all three so this page cannot quietly drift from the code:

  • <urn:ikigai:endpoint:camel-case> a ik:Endpoint — the endpoint is a node with a stable IRI, not a blob of documentation.
  • ik:input <urn:ikigai:endpoint:camel-case:input:in> — so is each input. No blank nodes: every node has a name you can point a query at, and two catalogs diff cleanly.
  • ik:class <http://www.w3.org/2001/XMLSchema#string> — the .class(..) you declared on the ArgSpec is where “what can I do with a string?” gets its answer from.

And the whole host at once — urn:kernel:catalog, one graph over every binding:

/// `urn:kernel:catalog` is every bound endpoint's description, as one graph.
#[test]
fn the_catalog_is_one_graph_over_every_binding() -> Result<()> {
    let kernel = kernel();
    let catalog = source(&kernel, "urn:kernel:catalog")?;

    // Ours, and ikigai-fn's — the host is mostly other people's endpoints.
    assert!(catalog.contains("<urn:ikigai:endpoint:camel-case> a ik:Endpoint"));
    assert!(catalog.contains("<urn:ikigai:endpoint:title> a ik:Endpoint"));
    assert!(catalog.contains("<urn:ikigai:endpoint:toUpper> a ik:Endpoint"));
    // The two-verb endpoint shows up as two actions, one per verb. Each is named by its
    // CONTENT, `urn:ikigai:contract:{id}:{verb}:b3:{hex}`, so the test checks the shape and
    // leaves the 64 hex digits to the kernel: they change whenever the contract does.
    let contract = |verb: &str| {
        let node = format!("<urn:ikigai:contract:title:{verb}:b3:");
        catalog
            .lines()
            .any(|line| line.starts_with(&node) && line.contains("> a ik:Action"))
    };
    assert!(contract("source"));
    assert!(contract("sink"));

    // The agent's tool list names each row by its door and verb instead, which is stable for
    // as long as the door is, and joins it to the contract above by `ik:contract`.
    let request = Request::new(Verb::Source, iri("urn:kernel:actions"))
        .with_arg("as", ArgRef::Inline(b"text/turtle".to_vec()));
    let repr = block_on(kernel.issue(request, &Capability::root()))?;
    let actions = String::from_utf8_lossy(&repr.bytes);
    assert!(actions.contains(&format!("<urn:ikigai:match:source:{TITLE}>")));
    assert!(actions.contains(&format!("<urn:ikigai:match:sink:{TITLE}>")));
    assert!(actions.contains("ik:contract <urn:ikigai:contract:title:sink:b3:"));
    Ok(())
}

title declared two verbs with two contracts, and it appears as two ik:Action nodes — that per-verb view, not the endpoint, is the unit an agent’s tool list is built from. Why an endpoint describes itself is about why this is load-bearing rather than decorative.

Each contract node is named by its content: urn:ikigai:contract:{id}:{verb}:b3:{hex}, where the hex is a BLAKE3 digest of the id, the verb and everything the catalog says about that verb’s contract. An id is not unique in a kernel (a peer’s endpoint mounted beside yours can carry the same one, and one endpoint can sit at two doors), so two copies with the same contract share a node and a copy with a different contract gets its own. This book writes … where the digest goes: it changes whenever the contract does, down to the wording of a summary, and Run prints the real one. The tool list, urn:kernel:actions, names each of its rows by door and verb instead, which stays put for as long as the door does: title’s read is urn:ikigai:match:source:urn:iki:tutorial:title, and ik:contract joins it to the contract it satisfies.

@prefix ik: <https://ikigai-rs.dev/ns#> .
 
<urn:ikigai:endpoint:camel-case> a ik:Endpoint ;
    ik:id "camel-case" ;
    ik:title "Camel-case" ;
    ik:summary "Camel-cases the UTF-8 text supplied in the `in` argument." ;
    ik:verb "Source", "Meta" ;
    ik:output "text/plain;charset=utf-8" ;
    ik:input <urn:ikigai:endpoint:camel-case:input:in> ;
    ik:action <urn:ikigai:contract:camel-case:source:b3:…> .
 
<urn:ikigai:endpoint:camel-case:input:in> ik:inputName "in" ;
    ik:source "argument" ;
    ik:required true ;
    ik:summary "the text to camel-case" ;
    ik:class <http://www.w3.org/2001/XMLSchema#string> .
 
<urn:ikigai:contract:camel-case:source:b3:…:input:in> ik:inputName "in" ;
    ik:source "argument" ;
    ik:required true ;
    ik:summary "the text to camel-case" ;
    ik:class <http://www.w3.org/2001/XMLSchema#string> .
 
<urn:ikigai:contract:camel-case:source:b3:…> a ik:Action ;
    ik:verb "Source" ;
    ik:output "text/plain;charset=utf-8" ;
    ik:input <urn:ikigai:contract:camel-case:source:b3:…:input:in> .
[computed]

describe … text/turtle is Meta with as=text/turtle, and the graph it prints is the one the test above asserts three triples of. The tutorial binary prints the same description, inside its whole catalog (a … line stands for the other endpoints, and a … in an IRI for the digest):

cargo run -p hello-camel -- --catalog
@prefix ik: <https://ikigai-rs.dev/ns#> .
…
<urn:ikigai:endpoint:camel-case> a ik:Endpoint ;
    ik:id "camel-case" ;
    ik:title "Camel-case" ;
    ik:summary "Camel-cases the UTF-8 text supplied in the `in` argument." ;
    ik:verb "Source", "Meta" ;
    ik:output "text/plain;charset=utf-8" ;
    ik:input <urn:ikigai:endpoint:camel-case:input:in> ;
    ik:action <urn:ikigai:contract:camel-case:source:b3:…> .

<urn:ikigai:endpoint:camel-case:input:in> ik:inputName "in" ;
    ik:source "argument" ;
    ik:required true ;
    ik:summary "the text to camel-case" ;
    ik:class <http://www.w3.org/2001/XMLSchema#string> .
…

The same four things from a shell

None of this is a property of Rust. The ikigai CLI drives another host with the same kernel in it, and the REPL grammar reaches all four. Start a fresh ikigai with no arguments (quit a REPL still open from the front door first: its cache and its cut threads would change what these print) and type these in one session — the cache lives in the process, so one-shot -c runs would each start empty:

ikigai> source urn:iki:fn:toUpper in="a b" | urn:iki:fn:reverseList
A B
[2 computed]
ikigai> cache urn:iki:fn:toUpper in="a b"
cached
ikigai> source urn:iki:fn:toUpper in="a b"
A B
[cached]
ikigai> trace urn:iki:fn:toUpper in="a b"
trace  urn:iki:fn:toUpper
  client      ikigai repl  ·  capability: root (full authority)
  transport   embedded · in-process

urn:iki:fn:toUpper   toUpper · cached · main · 0ms   → 3b  A B
ikigai> describe urn:iki:fn:toUpper text/turtle
@prefix ik: <https://ikigai-rs.dev/ns#> .

<urn:ikigai:endpoint:toUpper> a ik:Endpoint ;
    ik:id "toUpper" ;
    …
ikigai> sink urn:file:notes.txt remember the milk
wrote 17 bytes to notes.txt
[uncacheable]
ikigai> source urn:file:notes.txt
remember the milk
[computed]
ikigai> cache urn:file:notes.txt
cached
ikigai> sink urn:kernel:cut urn:file:notes.txt
cut urn:file:notes.txt
[uncacheable]
ikigai> cache urn:file:notes.txt
not cached
ikigai> source urn:kernel:threads
threads (cut generations)
…
  urn:file:notes.txt  gen 2
  urn:iki:host:names  gen 1
  urn:iki:space:dynamic:names  gen 1
  urn:kernel:bindings  gen 1

Line by line: | pipes one resolution’s output into the next one’s unnamed argument, and the [2 computed] tally is the kernel counting invocations. cache is is_cached from a shell. trace is issue_traced with the CLI’s own tracer rendering the tree. describe … text/turtle is Meta with as=text/turtle. The file is the title of this chapter, one level up: its endpoint declared a thread named after the file, so the sink that wrote it cut that thread, and sink urn:kernel:cut cuts it again by hand — the resource for doing what a write does, on somebody else’s behalf — and the cached read is gone. Note what reaches a pure function: urn:iki:fn:toUpper declared no thread, but since core 0.1.73 every cacheable read also hangs from the thread named after its own target (above), so a cut of urn:iki:fn:toUpper would invalidate it too, as the front door showed. urn:kernel:threads shows every thread that has ever been cut, with its generation — the file’s twice. The … stands for any other file threads the CLI’s watcher cut: since CLI 0.2 a write goes to a temporary file first (.ikigai-fs- and a random name), which is then renamed into place, and the watcher cuts that file’s thread too, so a row like urn:file:.ikigai-fs-5d04287e853c130ee4476af679bdaae6.tmp gen 3 is likely, with a name that changes every time. The three rows after the file’s are the names layer’s: since CLI 0.2.6 the CLI starts that layer in every session, which cuts those threads once (the front door says more).

⚠ The Turtle the CLI prints may differ in shape from the tutorial host’s: which ikigai-vocab renders it is the CLI’s choice, made on the CLI’s release schedule, and older ones write inputs as blank nodes rather than the named ones above. The triples mean the same thing; the node names are the newer projection’s improvement.

What you have now

A name was cached, and you proved the endpoint did not run. A write upstream invalidated a derived result that had never heard of the thing written. A resolution showed you its own tree. An endpoint answered “what are you?” with a graph you could query. Four properties, zero lines of code in camel-case to get any of them — they came from resolving a name rather than calling a function.

After Golden threads in practice, the two chapters that follow it are about the two things that made the fourth one possible: the description an endpoint carries, and the host that knows how to render it.

Try it

The cut, in twelve lines, compiled by this page:

#![allow(unused)]
fn main() {
extern crate hello_camel;
extern crate ikigai_core;
extern crate futures;
use futures::executor::block_on;
use ikigai_core::{ArgRef, Capability, Iri, Request, Verb};

let kernel = hello_camel::kernel();
let composite = Request::new(Verb::Source, Iri::parse("urn:iki:tutorial:camel-title").unwrap());
let root = Capability::root();

block_on(kernel.issue(composite.clone(), &root)).unwrap();
assert!(kernel.is_cached(&composite, &root));

let write = Request::new(Verb::Sink, Iri::parse("urn:iki:tutorial:title").unwrap())
    .with_arg("content", ArgRef::Inline(b"try it".to_vec()));
block_on(kernel.issue(write, &root)).unwrap();

assert!(!kernel.is_cached(&composite, &root));
let repr = block_on(kernel.issue(composite, &root)).unwrap();
assert_eq!(String::from_utf8_lossy(&repr.bytes), "tryIt");
}

Golden threads in practice

What resolution buys you cut one thread and watched one derived answer recompute. This chapter is the mechanism whole — declare, derive, cut, recompute — and then the two things around it that the first chapter could not say yet: what bounds the cache, and the two rules that decide whether an answer is safe to keep at all.

Declare, derive, cut

A golden thread is a name. An endpoint declares that its answer hangs from one with .depends_on(thread); by convention the thread is named after the resource whose state it tracks, which is why title’s thread is urn:iki:tutorial:title. Three things then happen without the endpoint’s help:

  • Derive. A composite — anything that resolves another resource from inside its own invocation, inv.source(..) — inherits the threads of everything it resolved. Its cache entry hangs from the union.
  • Cut. After a Sink or Delete it dispatched, the kernel cuts the thread named after the write’s canonical target. Since ikigai-core 0.1.95 that is whether the write returned success or an error, because an endpoint can change state and then fail (record a run, then report the run’s error), and a cache that believed nothing changed would answer for the old state; a write refused before it ran (a capability the caller lacks, a name nothing binds) changed nothing and cuts nothing. A cut is cheap and blind: it bumps the thread’s generation and nothing else. Kernel::cut and the REPL’s sink urn:kernel:cut do the same by hand, which is what a filesystem watcher does when a file changes out from under the kernel.
  • Recompute, lazily. Nothing is evicted at the cut. An entry records the generation each of its threads held when it was stored; the next lookup compares, and an entry whose thread has moved is thrown away then. Readers never invalidate anything; only a change does.

The one-liner that falls out, and the reason the mechanism is cheap: rebuilding a composite costs one lookup per dependency, not one recompute per dependency. The dependency graph is the decomposition of a resource into the resources it needs, and recomputation stops at every part whose thread is still whole. Here a composite hangs from a thread of its own and from title’s; cutting its own thread rebuilds it and finds title still in the cache:

#![allow(unused)]
fn main() {
extern crate hello_camel;
extern crate ikigai_core;
extern crate futures;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use futures::executor::block_on;
use ikigai_core::{
    AsyncFnEndpoint, Capability, Exact, Iri, Kernel, ReprType, Representation, Request,
    Verb,
};

// `framed-title` reads `title` and hangs from one more thread, its "frame".
let framed = AsyncFnEndpoint::new("framed-title", |inv| {
    Box::pin(async move {
        let title = inv.source(&Iri::parse(hello_camel::TITLE).unwrap()).await?;
        let text = format!("[{}]", String::from_utf8_lossy(&title.bytes));
        Ok(Representation::new(ReprType::new("text/plain"), text.into_bytes())
            .cacheable()
            .depends_on("urn:iki:tutorial:frame"))
    })
});
let state = Arc::new(hello_camel::TitleState::default());
let kernel = Kernel::new(Arc::new(
    hello_camel::space_over(Arc::clone(&state))
        .bind(Exact::new("urn:iki:tutorial:framed-title"), framed),
));
let root = Capability::root();
let framed = || Request::new(Verb::Source, Iri::parse("urn:iki:tutorial:framed-title").unwrap());

// Derive: one read of `title`, and the composite hangs from both threads.
let first = block_on(kernel.issue(framed(), &root)).unwrap();
assert_eq!(String::from_utf8_lossy(&first.bytes), "[resource oriented computing]");
assert_eq!(state.reads.load(Ordering::SeqCst), 1);

// Cut the frame by hand — the watcher's move. The composite is gone…
kernel.cut("urn:iki:tutorial:frame");
assert!(!kernel.is_cached(&framed(), &root));

// …and rebuilding it cost one LOOKUP of `title`, not a second read.
block_on(kernel.issue(framed(), &root)).unwrap();
assert_eq!(state.reads.load(Ordering::SeqCst), 1);

// Cut `title`'s thread instead and the recompute goes all the way down.
kernel.cut(hello_camel::TITLE);
block_on(kernel.issue(framed(), &root)).unwrap();
assert_eq!(state.reads.load(Ordering::SeqCst), 2);
}

Ten thousand readers of a value that changes once an hour cost one recompute an hour, and a composite over ten such values costs ten lookups when any one of them moves.

Before you cut: what would it recompute?

A cut is blind, so the question an operator asks before making one (by hand, or by editing the file a watcher follows) is what hangs from this? urn:kernel:dependents answers it with thread=: every cached entry a read would be served from right now that hangs from that thread, which is exactly what the cut would make stale. It evicts nothing and counts no hit, so asking is free; and it is live, so it is never cached itself. Part I’s camel-title reads title, so after one read of it both hang from title’s thread:

#![allow(unused)]
fn main() {
extern crate hello_camel;
extern crate ikigai_core;
extern crate futures;
use std::sync::Arc;
use futures::executor::block_on;
use ikigai_core::{ArgRef, Capability, Iri, Request, Verb};

let kernel = hello_camel::kernel_over(Arc::new(hello_camel::TitleState::default()));
let root = Capability::root();
let dependents = |thread: &str| {
    let ask = Request::new(Verb::Source, Iri::parse("urn:kernel:dependents").unwrap())
        .with_arg("thread", ArgRef::Inline(thread.as_bytes().to_vec()));
    String::from_utf8(block_on(kernel.issue(ask, &root)).unwrap().bytes).unwrap()
};

// Nothing read yet: a cut now would cost nothing.
assert!(dependents(hello_camel::TITLE)
    .ends_with("  (no live cached entry hangs from it: a cut would recompute nothing)\n"));

let camel_title = Request::new(Verb::Source, Iri::parse("urn:iki:tutorial:camel-title").unwrap());
block_on(kernel.issue(camel_title, &root)).unwrap();

assert_eq!(
    dependents(hello_camel::TITLE),
    "\
dependents of urn:iki:tutorial:title  (cut 0 times)
  2 live cached entries hang from it — a cut makes them stale, and the next read of each recomputes it
  urn:iki:tutorial:camel-title  text/plain                     25 B  2 threads   root
  urn:iki:tutorial:title        text/plain                     27 B  1 thread    root
"
);

// Cut it, and the same question has a new answer: nothing live is left to go stale.
kernel.cut(hello_camel::TITLE);
assert!(dependents(hello_camel::TITLE).starts_with("dependents of urn:iki:tutorial:title  (cut 1 time)\n"));
assert!(dependents(hello_camel::TITLE).contains("a cut would recompute nothing"));
}

camel-title hangs from two threads, title’s and its own name’s (every cacheable read hangs from its own name, below), so cutting either would rebuild it. An entry that is already stale, cut by another thread or past its expiry, is not listed: it recomputes whatever happens to this one. The last column is the chain the entry was computed in (root: no corridor, a term Scope and alias introduces), because the cache files an answer under its chain as well as its request and its authority, the third slot of rule 1 below. In the REPL (ikigai-cli 0.1.43 and later) the same question is dependents <thread>.

What bounds the cache

The cache is a resource with a policy, installed at construction beside the clock and the Meta renderer, because the right trade is the host’s: an edge process facing strangers, a batch job that wants everything it computed and a browser tab want different ceilings. A CachePolicy is consulted in exactly two places — when the kernel is about to store an entry (admit), and when the cache is over its bound and something must go (victim). Absent, it is LRU at 4096 entries and 64 MiB, which is a change from the unbounded cache before core 0.1.70: a long-lived process now evicts instead of growing. Fifo and CostAware (keep what was expensive) ship beside it, and a policy that wants an unbounded cache says so by declining to name a victim — visibly, not by omission.

Since core 0.2.0 an entry holds a body, which need not be one buffer in memory (Streams, I), and the bound counts bodies as follows. A one-shot body, a stream that can be read only once, is never stored: there is nothing to read again, and the step’s uncached= trace note names the cause, one-shot body. A body spilled to a file is counted at its full length against the byte bound, although it holds no memory, and one that does not know its length counts as unbounded, so no bounded policy admits it. And a stored body is shared, not copied: the entry takes a second handle on the answer only once the policy has admitted it, and a hit read as a body is another handle on the same bytes. (A caller that asks for a plain buffer still gets its own copy, as every hit cost before 0.2.0.)

#![allow(unused)]
fn main() {
extern crate hello_camel;
extern crate ikigai_core;
extern crate futures;
use std::sync::Arc;
use futures::executor::block_on;
use ikigai_core::{ArgRef, CacheBound, Capability, Iri, Kernel, Lru, Request, Verb};

// A cache with room for two entries.
let kernel = Kernel::new(Arc::new(hello_camel::space()))
    .with_cache_policy(Arc::new(Lru::with_bound(CacheBound::new(2, 8 << 20))));
let root = Capability::root();
let upper = |text: &str| {
    Request::new(Verb::Source, Iri::parse("urn:iki:fn:toUpper").unwrap())
        .with_arg("in", ArgRef::Inline(text.as_bytes().to_vec()))
};

for text in ["one", "two", "three"] {
    block_on(kernel.issue(upper(text), &root)).unwrap();
}

// Three cacheable answers, two kept: the least recently used went.
assert_eq!(kernel.cache_len(), 2);
assert!(!kernel.is_cached(&upper("one"), &root));
assert!(kernel.is_cached(&upper("three"), &root));
}

The line to carry away: a policy decides what is worth keeping, never what is correct to serve. Golden threads and expiry are evaluated on the serving path, where no policy is consulted, so no policy — including a hostile one — can hand back an entry whose thread was cut or whose deadline has passed. Eviction bounds what is kept; validity is somebody else’s job. The REPL’s source urn:kernel:cache shows both halves at once — the bound (entries 3 / 4096, size … / 64.0 MB) and, per entry, how many threads it hangs from.

One more thing the cache does, stated because it closed a race: every cut takes the next number from one counter, and a request takes a snapshot of that counter before it resolves anything. When the result comes back to be stored, the store declines if any thread the result depends on was cut after the snapshot. So a write that lands while a read is in flight cannot be swallowed by the read’s stale answer — the answer is returned and not kept, and the next read recomputes. Declining to cache is never wrong.

Two rules you can state as rules

1. The key holds everything the answer depended on. A cache entry is filed under the content-addressed request (verb, canonical name, every argument), a fingerprint of the capability it ran under, and a fingerprint of the chain it was resolved in — Scope and alias shows the third slot splitting and joining entries. Leave a slot out and the cache hands an answer to a caller it was not computed for; the failure is a hit, and hits do not log. What the key does not hold, the threads and the expiry cover: the state of what the answer read, and time.

2. Cacheability is only as good as the least cacheable dependency. A representation has one of three expiries — Always (the default: never cached; an endpoint opts in), At(deadline) (until an instant, judged against the kernel’s clock; a kernel with no clock declines to store it), Never (.cacheable(): a pure function of its inputs) — and a composite’s is the meet of its own and every dependency’s, with Always at the bottom. Adding one uncacheable source to a cached resource makes it uncacheable. That is a correctness no-op, and it is a performance change with no type signal. The cautionary tale is three sentences long: one uncacheable overlay was joined into a cached graph on a live server (2026-08-13, in ikigai-cms-web, the reading-room CMS); every read went from about 20 µs to about 1.0 s; and 68 tests stayed green, because the types are identical either way. Nothing catches it but measuring the read — so when you add a source to a cached resource, measure the read before and after. That is the only signal there is.

Two holes, closed

Two holes in the mechanism were on the record here through core 0.1.72, so you would build around them rather than into them. Both closed in 0.1.73 (the kernel’s design notes track them as one arc, ledger item 512), and the block that used to pin the old behavior — it was written to fail the day the kernel closed the hole, and it did — now shows the new one.

A cacheable read hangs from its own name, whether or not it says so. The kernel adds the thread named after the request’s canonical target to every cacheable Source and Exists representation — the same name the Sink’s auto-cut fires on — so a write through a name evicts the cached read through that name with no help from the endpoint. Before 0.1.73 the kernel stored only the threads an endpoint declared plus those of its sub-requests, and every cacheable endpoint fronting mutable state was one forgotten .depends_on(<its own name>) from serving stale bytes after a write. title in Part I still declares it, by hand, and that line is now harmless rather than load-bearing: a thread declared twice is one thread. Here is the endpoint that used to serve stale, minus the line, and the Sink invalidating it anyway:

#![allow(unused)]
fn main() {
extern crate ikigai_core;
extern crate futures;
use std::sync::{Arc, Mutex};
use futures::executor::block_on;
use ikigai_core::{
    ArgRef, Capability, EndpointSpace, Exact, FnEndpoint, Iri, Kernel, ReprType,
    Representation, Request, Verb,
};

// Like `title`, minus the one line: the Source is cacheable and declares NO thread.
let text = Arc::new(Mutex::new(String::from("first")));
let note = {
    let text = Arc::clone(&text);
    FnEndpoint::new("note", move |inv| {
        let mut current = text.lock().unwrap();
        Ok(match inv.request.verb {
            Verb::Sink => {
                *current = inv.inline_str("content")?.to_string();
                Representation::new(ReprType::new("text/plain"), b"ok".to_vec())
            }
            _ => Representation::new(ReprType::new("text/plain"), current.clone().into_bytes())
                .cacheable(),
        })
    })
};
let kernel = Kernel::new(Arc::new(
    EndpointSpace::new().bind(Exact::new("urn:iki:tutorial:note"), note),
));
let root = Capability::root();
let name = || Iri::parse("urn:iki:tutorial:note").unwrap();
let read = || Request::new(Verb::Source, name());

// The read is cached — and what came back hangs from its own name, undeclared.
let first = block_on(kernel.issue(read(), &root)).unwrap();
assert!(first.threads().contains(&"urn:iki:tutorial:note".into()));
assert!(kernel.is_cached(&read(), &root));

// A Sink through the same name cuts that thread. The entry is gone…
let write = Request::new(Verb::Sink, name())
    .with_arg("content", ArgRef::Inline(b"second".to_vec()));
block_on(kernel.issue(write, &root)).unwrap();
assert!(!kernel.is_cached(&read(), &root));

// …and the next read is the new text, not the stale one.
let after = block_on(kernel.issue(read(), &root)).unwrap();
assert_eq!(String::from_utf8_lossy(&after.bytes), "second");
}

The rule for your own endpoints is therefore shorter than it was: a cacheable read of mutable state needs no thread of its own. It still needs one for every other name whose state it reads outside the kernel — a file a watcher cuts, say — because the kernel can only name what it resolved.

A failed sub-request is a dependency too. An endpoint that catches an Unresolved or a NotFound and returns a cacheable fallback is now cached with a thread on the missing name, so the cut that fires when that name is written — or the watcher’s cut by hand — evicts the fallback. And a result built on a denial, or on any other error, is pushed to Always: there is no thread to cut when a grant changes, so a fallback for a refusal is never kept at all. Before 0.1.73 both were cached with nothing hanging from the failure.

#![allow(unused)]
fn main() {
extern crate ikigai_core;
extern crate futures;
use std::sync::Arc;
use futures::executor::block_on;
use ikigai_core::{
    AsyncFnEndpoint, Capability, EndpointSpace, Error, Exact, Iri, Kernel, ReprType,
    Representation, Request, Verb,
};

// Reads `footnote` if there is one, and falls back to a cacheable "(none)" if not.
let annotated = AsyncFnEndpoint::new("annotated", |inv| {
    Box::pin(async move {
        let footnote = match inv.source(&Iri::parse("urn:iki:tutorial:footnote").unwrap()).await {
            Ok(repr) => String::from_utf8_lossy(&repr.bytes).into_owned(),
            Err(Error::Unresolved(_)) => "(none)".to_string(),
            Err(other) => return Err(other),
        };
        Ok(Representation::new(ReprType::new("text/plain"), footnote.into_bytes()).cacheable())
    })
});
let kernel = Kernel::new(Arc::new(
    EndpointSpace::new().bind(Exact::new("urn:iki:tutorial:annotated"), annotated),
));
let root = Capability::root();
let read = || Request::new(Verb::Source, Iri::parse("urn:iki:tutorial:annotated").unwrap());

// Nothing binds `footnote`: the fallback is served, cached, and it hangs from the
// name that was missing.
let fallback = block_on(kernel.issue(read(), &root)).unwrap();
assert_eq!(String::from_utf8_lossy(&fallback.bytes), "(none)");
assert!(fallback.threads().contains(&"urn:iki:tutorial:footnote".into()));
assert!(kernel.is_cached(&read(), &root));

// The day `footnote` appears, whatever bound it cuts that thread — here, by hand.
kernel.cut("urn:iki:tutorial:footnote");
assert!(!kernel.is_cached(&read(), &root));
}

Not yet

Two things the kernel still does not do, and is not going to, stated so you do not wait for them.

A stored read does not hang from urn:kernel:bindings. That thread (0.1.74) means “the set of bindings this kernel resolves against changed”, and every face derived from the bindings hangs from it — the catalog, urn:kernel:actions, validation, every Meta answer, urn:kernel:topology. A cached Source does not: the safe version would be one more edge on every entry and a cache only as warm as the binding set is stable. The kernel cannot cut the thread itself either — its root is fixed at construction and a space reports resolutions, not changes — so the party that rebinds a running kernel is the party that says so: Kernel::bindings_changed() from Rust, sink urn:kernel:cut urn:kernel:bindings from the REPL, and then it cuts the names it moved, by hand.

Meta and the urn:kernel:* intrinsics get no thread on their own name, for the same reason: a description is a static contract, and what invalidates it is the binding set, above.

Why an endpoint describes itself is next: the other half of what made the fourth demonstration in the previous chapter possible.

Why an endpoint describes itself

In most systems, documentation is a courtesy to humans. Here it is load-bearing machinery, and skipping it produces a system that works and is unusable by anything but a person who already knows.

The Description does four jobs

  1. The engine routes named arguments by it. in="hello" finds the right slot because the endpoint declared a slot called in.
  2. Selection matches on it. Finding a transreptor, finding an endpoint for a task, and inferring what actions a set of things affords are all the same query against declared types.
  3. The catalog is built from it. urn:kernel:catalog and urn:kernel:actions are assembled out of these descriptions, not maintained separately.
  4. The agent tool list is it. Projected over MCP, an endpoint’s description becomes a tool definition. Nobody writes that by hand.

The third job deserves one more sentence, because it looks circular and is not. The catalog is the arrangement describing itself — the kernel’s own bindings, rendered from the same descriptions — and it is one resource among the resources, reached by a name like any other. A reader who can resolve urn:kernel:catalog has everything needed to learn what else can be resolved, including the catalog; nothing about that requires knowing in advance.

An endpoint with a thin description still runs. It is simply invisible to everything above — like a library function with no signature.

Here is camel-case’s, included from the crate:

/// `camel-case`: camel-cases the UTF-8 string in the `in` argument.
pub fn camel_case() -> FnEndpoint {
    FnEndpoint::new("camel-case", camel_case_impl).with_description(
        Description::new("camel-case")
            .title("Camel-case")
            .summary("Camel-cases the UTF-8 text supplied in the `in` argument.")
            .verb(Verb::Source)
            .verb(Verb::Meta)
            .input(
                ArgSpec::new("in")
                    .summary("the text to camel-case")
                    .class(XSD_STRING),
            )
            .output(TEXT_PLAIN_UTF8),
    )
}

ArgSpecs, from day one

#![allow(unused)]
fn main() {
extern crate ikigai_core;
use ikigai_core::ArgSpec;

let spec = ArgSpec::new("in")
    .summary("the text to camel-case")
    .class("http://www.w3.org/2001/XMLSchema#string");
let _ = spec;
}
  • optional() — an argument is required by default, and this marks the exception. The default is the strict one on purpose: forgetting to say “required” should not quietly widen what the endpoint accepts.
  • class(…) — an XSD datatype IRI for a scalar, or an rdfs:Class for an entity. This is the one people leave off, and it is the one that matters most.
  • one_of(…) for enums, default_value(…) where there is one.

Why class is the interesting field

Because it turns “what can I do with these things?” into a query.

If endpoints declare the entity types they consume — this one takes three schema:Person, a schema:Place and a schema:Date — then given a set of things, the actions they afford are the endpoints whose required input types are a subset of the types present. Set containment over the catalog. A SPARQL query.

That is the same mechanism as finding a transreptor from one media type to another, one level up. It is why the description is not paperwork.

The invariant that keeps it safe

Type-driven affordance sounds alarming: whoever asserts types influences what gets offered. A hostile assertion could surface an action that should not be there.

The defense is already in the model. Type intersection only offers an action; executing it still requires the actor’s capability. Affordance is type-driven, authorization is capability-driven, and they are separate gates. Adversarial data can make the menu wrong. It cannot make the kitchen cook.

The one that bites

⚠ Declared capabilities must be enforced capabilities. If your endpoint checks authority it never declared, the catalog over-offers — it advertises something that will fail. If it declares authority it never checks, the catalog under-protects, which is worse. Parameterized authority (network hosts, filesystem paths) declares the wildcard form: urn:cap:net:* means “holds some grant under this prefix”.

Binding, and a host of your own

An endpoint is inert. Until something binds it to a name, nothing can resolve it — defining and naming are separate acts, which is the whole point of Resolution showing up in the code.

Binding

/// This book's space: the built-in function library, plus the three endpoints above
/// under a prefix this book owns.
pub fn space() -> EndpointSpace {
    space_over(Arc::default())
}

space() is one call to space_over, which is where the bindings are:

pub fn space_over(state: Arc<TitleState>) -> EndpointSpace {
    ikigai_fn::space()
        .bind(Exact::new("urn:iki:tutorial:camel-case"), camel_case())
        .bind(Exact::new(TITLE), title(state))
        .bind(Exact::new("urn:iki:tutorial:camel-title"), camel_title())
}

Two things are happening.

ikigai_fn::space() is the built-in function library as a mountable space — urn:iki:fn:toUpper, urn:iki:fn:compose, urn:iki:fn:conditional and friends. bind is a builder, so a host starts from somebody else’s space and chains its own bindings on top. That is the normal shape of a host: mostly other people’s endpoints, plus the few that are yours — here, the three from Hello, resource and What resolution buys you. (The state handle is title’s memory, passed in so a test can watch it; space() makes a fresh one.)

Exact::new("urn:iki:tutorial:camel-case") matches one exact IRI. Bindings can also be URI templates, which is how urn:file:{path} covers a whole tree with one binding.

Binding authority is a host concern

Notice that the crate defining the camel-case endpoint does not decide it lives at urn:iki:tutorial:camel-case. It offers a constructor; the host decides the name. Two hosts can bind the same endpoint at different names, and a host can refuse to bind it at all.

Bind into a namespace you own

Look again at the binding line above, and at the two namespaces in it. The space is ikigai-fn’s; the name is this book’s own, under urn:iki:tutorial:. The space you compose and the name you bind are independent — chaining onto somebody else’s space gives you no claim on their prefix, and nothing but discipline stops you from binding into it anyway.

That discipline is what was missing here. This endpoint used to be bound at urn:fn:toCamel: nothing about it belonged to ikigai-fn, it merely sat inside ikigai-fn’s prefix. When that library renamed its own namespace, this book’s endpoint was caught in a migration it had no stake in — a name in somebody else’s prefix moves on their schedule, not on yours. Rebinding it under urn:iki:tutorial: is the entire fix, and it is one line.

Name resources as nouns

That one line changed in two ways at once, and the section above covered only the first. The endpoint did not merely leave ikigai-fn’s prefix: it also stopped being toCamel and became camel-case.

toCamel is a verb phrase. It reads as something you call, and that is exactly the reflex Resolution is trying to break. A resource is a thing you name and ask a kernel to resolve; whether the answer is computed now, served from an hour-old cache, or fetched from another machine is not yours to decide, and a name shaped like a function call quietly implies otherwise. So the convention is: resource names are nouns, and kebab-case — camel-case, not toCamel.

Kebab-case is the smaller half of the rule. It matters because these names do not stay in Rust — they are projected into an agent’s tool list, into generated editor commands, into shell one-liners — and a hyphen is a word separator every one of those already reads as one.

⚠ Now look back at the top of this chapter. urn:iki:fn:toUpper breaks the rule, and so does its sibling urn:iki:fn:reverseList — in the library this book depends on. You have not caught an oversight. The convention was settled after those names were already in use across a dozen repositories, so fixing them is a second migration with a schedule of its own, which is the previous section’s point arriving from the other direction: a name you use out of somebody else’s prefix is correct when they say it is.

Why this book does not patch the CLI

You could add camel-case to the CLI’s own base_space in ikigai-embedded and get it on the ikigai binary. Don’t — not because it fails, but because it teaches the wrong reflex. You do not extend ikigai by editing ikigai. You compose a kernel with the spaces you want, which is what the CLI itself is doing.

So this book ships its own host instead. The kernel is one line:

/// The tutorial host: a kernel over [`space`], with a Meta renderer.
///
/// `Kernel::new` would resolve every `Source` here just as well. What it could not do is
/// answer `Meta` or `urn:kernel:catalog`, because rendering a `Description` into bytes is
/// a projection the kernel deliberately does not own — `ikigai-core` has no RDF in it.
/// `ikigai-vocab`'s `TurtleRenderer` is that projection, and injecting it is the whole
/// difference between a host that can describe itself and one that cannot.
pub fn kernel() -> Kernel {
    Kernel::with_meta_renderer(Arc::new(space()), Arc::new(TurtleRenderer))
}

Kernel::new(Arc::new(space())) would resolve every Source in this book just as well. What it could not do is answer Meta, or urn:kernel:catalog, which is Meta over every binding: turning a Description into bytes is a projection, and ikigai-core owns no projection — there is no RDF in the kernel, by design. ikigai-vocab’s TurtleRenderer is the projection to Turtle (and to text/plain and JSON), and injecting it is the entire difference between a host that can describe itself and one that answers no Meta renderer configured. The CLI does the same thing with the same renderer.

And a resolution through it, short enough to read in full:

#![allow(unused)]
fn main() {
extern crate hello_camel;
extern crate ikigai_core;
extern crate futures;
use futures::executor::block_on;
use ikigai_core::{ArgRef, Capability, Iri, Request, Verb};

let kernel = hello_camel::kernel();

let request = Request::new(Verb::Source, Iri::parse("urn:iki:tutorial:camel-case").unwrap())
    .with_arg("in", ArgRef::Inline(b"resource oriented computing".to_vec()));

let repr = block_on(kernel.issue(request, &Capability::root())).unwrap();
assert_eq!(String::from_utf8_lossy(&repr.bytes), "resourceOrientedComputing");
}

Capability::root() is unrestricted authority, which is fine for a local tutorial and is not what a real host hands out — The file workspace shows the scoped kind.

Finding your crate from another project

When you want your endpoints in a host that lives in a different repository, the dependency is an ordinary Cargo one. During development, a path reference:

hello-camel = { path = "../ikigai-tutorial/crates/hello-camel" }

and once published, a version.

⚠ A path reference is a local arrangement: never commit one into a shared repository, because it only resolves on the machine that has both checkouts laid out that way. This workspace deliberately depends on the published ikigai crates for the same reason — clone it alone and it builds.

Scope and alias

Binding, and a host of your own put three names in a space and a kernel around it, and everything so far has resolved a name against that one space, from the outside. This chapter is about the two things that sit between a name and its binding, which a function call has no word for: where you are standing when you ask — the scope — and what a name is short for — the alias. Both are ikigai-core’s own API (0.1.72), both are small, and both change what the cache is allowed to remember.

First, though, the two combinators every host in this book already uses and no chapter has named.

Composing spaces: Fallback and Mount

A Space is anything that can answer “which endpoint, if any, is bound at this name”. EndpointSpace is the plain one — a row of bindings, first match wins — and two combinators compose them:

  • Fallback::new(vec![a, b, …]) tries each space in order; the first hit wins. Order is precedence, and precedence is the semantics: whatever a binds, b never sees. building_endpoints::space() in Transreption is one — this book’s bindings first, then the published SPARQL face.
  • Mount::new(prefix, inner) admits only names that start with prefix and hands those to inner, unchanged. Nothing is rewritten: the inner space’s names are already full names. The prefix is a fence, and a name outside it is not there at all.
#![allow(unused)]
fn main() {
extern crate hello_camel;
extern crate ikigai_core;
extern crate futures;
use std::sync::Arc;
use futures::executor::block_on;
use ikigai_core::{ArgRef, Capability, Error, Iri, Kernel, Mount, Request, Space, Verb};

// Part I's space, reachable only under this book's prefix.
let tutorial: Arc<dyn Space> = Arc::new(hello_camel::space());
let kernel = Kernel::new(Arc::new(Mount::new("urn:iki:tutorial:", tutorial)));
let root = Capability::root();
let upper = |name: &str| {
    Request::new(Verb::Source, Iri::parse(name).unwrap())
        .with_arg("in", ArgRef::Inline(b"a b".to_vec()))
};

// Inside the fence: bound.
let inside = block_on(kernel.issue(upper("urn:iki:tutorial:camel-case"), &root)).unwrap();
assert_eq!(String::from_utf8_lossy(&inside.bytes), "aB");

// The same space binds `urn:iki:fn:toUpper` — and through this mount it is not there.
let outside = block_on(kernel.issue(upper("urn:iki:fn:toUpper"), &root)).unwrap_err();
assert!(matches!(outside, Error::Unresolved(_)));
}

That Unresolved is worth a second look, because the rest of this chapter turns on it. The endpoint exists, in a space the kernel holds, and the answer is not “you may not” but “there is no such name here”. A mount does not refuse; it makes a region of the name space absent. Keep that distinction: an absence is a fact about the arrangement, and nobody can misconfigure it per request.

A name for a name

A logical rewrite is a stable name that resolves to a different backing resource without the caller knowing. Two shapes ship in core: Rewrite::new(inner, rule), a closure Fn(&Iri) -> Option<Iri> in front of a space, and Alias, its table-driven form — exact rules (urn:iki:tutorial:heading → one backing name) and prefix rules (a whole namespace moves, and every name under it follows). A host installs the table with Kernel::with_aliases, which wraps the root in an Alias and teaches the kernel the table, so the kernel can refuse a bad table before dispatch and answer urn:kernel:aliases with the rules and their counters.

Here is the one that matters — a write through one name invalidating the read through the other:

#![allow(unused)]
fn main() {
extern crate hello_camel;
extern crate ikigai_core;
extern crate futures;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use futures::executor::block_on;
use ikigai_core::{AliasTable, ArgRef, Capability, Iri, Kernel, Request, Verb};

// Part I's space, with `title`'s state in hand so the read counter can be watched —
// and one alias: `heading` is another name for `title`.
let state = Arc::new(hello_camel::TitleState::default());
let table = AliasTable::new().exact("urn:iki:tutorial:heading", hello_camel::TITLE);
let kernel = Kernel::new(Arc::new(hello_camel::space_over(Arc::clone(&state))))
    .with_aliases(Arc::new(table));
let root = Capability::root();
let read = |name: &str| Request::new(Verb::Source, Iri::parse(name).unwrap());

// Read through both names: the endpoint ran once, and there is ONE cache entry.
let via_alias = block_on(kernel.issue(read("urn:iki:tutorial:heading"), &root)).unwrap();
let via_backing = block_on(kernel.issue(read(hello_camel::TITLE), &root)).unwrap();
assert_eq!(via_alias.bytes, via_backing.bytes);
assert_eq!(state.reads.load(Ordering::SeqCst), 1);
assert_eq!(kernel.cache_len(), 1);

// Write through the alias. The read through the backing name went with it.
let write = Request::new(Verb::Sink, Iri::parse("urn:iki:tutorial:heading").unwrap())
    .with_arg("content", ArgRef::Inline(b"one resource, two names".to_vec()));
block_on(kernel.issue(write, &root)).unwrap();
assert!(!kernel.is_cached(&read(hello_camel::TITLE), &root));

let after = block_on(kernel.issue(read(hello_camel::TITLE), &root)).unwrap();
assert_eq!(String::from_utf8_lossy(&after.bytes), "one resource, two names");
assert_eq!(state.reads.load(Ordering::SeqCst), 2);
}

Nothing about title knows it has a second name. What makes that safe is a set of decisions core made once, each of them the answer to a way a rewrite goes wrong:

  1. The rewrite happens before the capability check. Authority is named by URN too, so the floor is evaluated against the backing resource — the thing that will actually be touched. The other order is an escalation device: check the public name, then rewrite to the secret one. The cost is real and deliberate: a grant that names the old spelling does not follow the name, so a denial on an aliased target names the hop in its message rather than failing silently.
  2. Meta reports the backing name. Ask urn:iki:tutorial:heading what it is and the description says title. A description that reported the alias would make the catalog, selection and the agent’s tool list disagree about what exists.
  3. Two names share one cache entry and one golden thread. That is the block above. It holds however the Alias was composed — by hand, under another overlay — because the overlay reports the rewrite (Resolved::canonical) and the kernel adopts the reported name before it computes the key, fires the cut, or checks the floor. Whoever rewrites a name says so; the kernel keys on what it is told.
  4. The catalog lists the backing name, once. Listing both would offer one resource twice. During a migration that property is the point: consumers that discover names by reading the catalog move forward on their own, and consumers holding the old name keep working.
  5. Chains resolve; cycles are refused, loudly. a → b → c is followed, up to eight hops. a → b, b → a is refused with the trail in the error — never truncated to whichever name the loop stopped on, because a half-applied rewrite is a wrong answer that looks like a right one.

⚠ Two things in this book are called an alias, and they make opposite promises on the one field that matters. Core’s Alias is the table above: the two names are one resource in this kernel, so it reports its canonical name and the kernel fuses them. The alias mount in Preferential resolution is MountedRemote: it rewrites a name on the way out to another kernel, where the rewritten spelling is a wire address — and it must report nothing, because this kernel may serve an unrelated resource under that spelling. Same word, two contracts. The question that tells them apart: does the rewritten name resolve to this very resource here? If yes, report it; if it crosses out of the kernel, do not.

Rewrite differs from Alias in one way a host feels: a closure cannot be listed, so a Rewrite in front of a space leaves the catalog unable to say what it rewrites — the kernel reports that it cannot enumerate rather than pretending the catalog is complete. A table can be listed. Prefer the table.

Where you are standing

Everything above resolved against the kernel’s root — the space you handed it at construction. Since 0.1.72 a request also resolves in a chain: corridors placed ahead of the root, chosen per request, and whether the root is on the end of the chain at all. That chain is Scope, and it has two faces.

Injection: a corridor ahead of the root

Kernel::issue_in(request, capability, scope) resolves the request in scope’s corridors first, innermost first, then the root. A corridor that binds a name the root also binds shadows it — for this request and for every sub-request it gives rise to. Here camel-title, which resolves title from inside its own invocation, is run once in the root and once standing in a corridor that binds title to a different door:

#![allow(unused)]
fn main() {
extern crate hello_camel;
extern crate ikigai_core;
extern crate futures;
use std::sync::Arc;
use futures::executor::block_on;
use ikigai_core::{
    Capability, EndpointSpace, Exact, FnEndpoint, Iri, ReprType, Representation, Request,
    Scope, Verb,
};

let kernel = hello_camel::kernel();
let root = Capability::root();
let camel_title =
    || Request::new(Verb::Source, Iri::parse("urn:iki:tutorial:camel-title").unwrap());

// One door, recognizing the same name as the host's `title`.
let at_six = Arc::new(EndpointSpace::new().bind(
    Exact::new(hello_camel::TITLE),
    FnEndpoint::new("title-at-six", |_| {
        Ok(Representation::new(ReprType::new("text/plain"), b"as it stood at six".to_vec())
            .cacheable())
    }),
));
let six_pm = Scope::empty().with_named(Iri::parse("urn:iki:tutorial:ctx:six-pm").unwrap(), at_six);

// The request named `camel-title` both times. Its SUB-request for `title` is what the
// corridor answered — the chain travels with the request, all the way down.
let here = block_on(kernel.issue(camel_title(), &root)).unwrap();
let there = block_on(kernel.issue_in(camel_title(), &root, six_pm)).unwrap();
assert_eq!(String::from_utf8_lossy(&here.bytes), "resourceOrientedComputing");
assert_eq!(String::from_utf8_lossy(&there.bytes), "asItStoodAtSix");

// Two contexts, two resources — and FOUR cache entries: the composite and its `title`
// sub-request, each once per chain, because the chain is in the key.
assert_eq!(kernel.cache_len(), 4);
}

issue_in lives on the kernel, and only there. Whoever can push a corridor ahead of the root can stand in for any door for every sub-request below — bind the contacts resource in a corridor and every sub-request that asks for contacts gets the corridor’s answer. So injection is authority, and it belongs to whoever holds the kernel: the same trust line as Capability::root(). An endpoint cannot reach it from its invocation.

The corridor’s name is what the cache remembers it by. with_named(name, space) is a claim — any corridor named this holds the same doors as this space — exactly the claim a reported canonical makes. Two requests carrying corridors named alike share cached answers, so name two different corridors alike and one request is served the other’s answer. Scope::with(space), the unnamed form, mints a fresh identity per call: sound, and useless for the case injection exists for, since a corridor rebuilt per request never shares with the last one.

Severing: a chain with no root

The other face goes the other way. Confine::new(name, space, inner) wraps an endpoint so that every sub-request it issues resolves in the corridors the host injected, then space — and no root. A name the chain does not bind is Unresolved, never Denied, and the root endpoint it would have reached is never entered:

#![allow(unused)]
fn main() {
extern crate hello_camel;
extern crate ikigai_core;
extern crate futures;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use futures::executor::block_on;
use ikigai_core::{
    AsyncFnEndpoint, Capability, Confine, EndpointSpace, Error, Exact, FnEndpoint, Iri,
    Kernel, ReprType, Representation, Request, Verb,
};

// An endpoint that reads a document, then tries to read the host's `title` as well.
let excerpt = AsyncFnEndpoint::new("excerpt", |inv| {
    Box::pin(async move {
        let document = inv.source(&Iri::parse("urn:iki:tutorial:document").unwrap()).await?;
        assert_eq!(String::from_utf8_lossy(&document.bytes), "the document");
        inv.source(&Iri::parse(hello_camel::TITLE).unwrap()).await
    })
});
// The corridor it is confined to holds the document and nothing else.
let corridor = Arc::new(EndpointSpace::new().bind(
    Exact::new("urn:iki:tutorial:document"),
    FnEndpoint::new("document", |_| {
        Ok(Representation::new(ReprType::new("text/plain"), b"the document".to_vec()))
    }),
));

let state = Arc::new(hello_camel::TitleState::default());
let kernel = Kernel::new(Arc::new(hello_camel::space_over(Arc::clone(&state)).bind_arc(
    Exact::new("urn:iki:tutorial:excerpt"),
    Arc::new(Confine::new(
        Iri::parse("urn:iki:tutorial:ctx:document").unwrap(),
        corridor,
        Arc::new(excerpt),
    )),
)));

// Under ROOT authority. The document resolved; `title` had nowhere to go.
let err = block_on(kernel.issue(
    Request::new(Verb::Source, Iri::parse("urn:iki:tutorial:excerpt").unwrap()),
    &Capability::root(),
))
.unwrap_err();
assert!(matches!(err, Error::Unresolved(ref name) if name.as_str() == hello_camel::TITLE));
assert_eq!(state.reads.load(Ordering::SeqCst), 0);
}

Read the last two lines together. The caller held root, so no capability refused anything; title is bound in the kernel’s root, so it was not missing. It was unreachable from where the endpoint stood, and the title endpoint’s read counter says it was never entered. That is the difference between a denial and an absence: a denial is a decision, and a decision can be misconfigured; an unresolvable name has nowhere to go. When the goal is a bound that cannot be got wrong per request, this is the construct — an extractor handed a document and a model, and structurally nothing else.

The same move is available from inside an invocation as inv.confine(name, space), which returns the same invocation with its chain narrowed. It is the only chain-changing operation an endpoint gets, and its shape is the whole argument: it puts space where the root was and cuts the root off, behind every corridor the host injected — so it can never shadow one, and relative to the chain it started in nothing resolves differently except what the root would have answered. Narrowing is an endpoint’s to do; widening is the host’s. That asymmetry is Resolution’s rule that authority is narrowed on the way down and never widened, applied to the chain instead of the capability.

What the key holds now

What resolution buys you named the three slots of the cache’s key, and Golden threads in practice stated the rule. Here is what each holds: the content-addressed request (verb, canonical name, every argument), a fingerprint of the capability, and a fingerprint of the chain. The third slot is why the injection block above ended with four entries — two resources, each with its sub-request, once per chain — and the alias block with one: a corridor splits one name into two resources, a reported rewrite joins two names into one, and in both cases the key is what changed. The chain slot holds the whole chain rather than the corridors actually consulted — sound and over-partitioned: two chains that differ only in a corridor neither request touched do not share.

Two things the chain does not touch. urn:kernel:* is intercepted ahead of it, so no corridor can shadow the catalog and a severed chain still reaches it — which means a confined endpoint can read a list of names it cannot resolve (names, never content). And the declared-capability floor runs unchanged, against whichever endpoint the chain resolved to: a corridor shadowing an open door with a gated one is gated.

As of: the corridor that pins time

Everything this chapter has gestured at comes together in one corridor. “Now” is the least cacheable resource there is — a live door, Always, and anything that reads it inherits that — and a corridor that binds now to one fixed instant turns it into a pure function of its context. Since 0.1.75 the chain can carry a clock as well as doors: Scope::with_named_at(name, space, clock) injects the corridor and sets the chain’s clock in one call, the only way a chain acquires one, so the binding and the clock cannot disagree. inv.now() then answers from the chain’s clock before the kernel’s. An endpoint that reads time by resolution and one that reads it from the clock see the same instant, and the result is Never under the corridor and Always under the root — checked here with Kernel::is_cached_in, the cache probe for a chain, rather than by counting entries:

#![allow(unused)]
fn main() {
extern crate ikigai_core;
extern crate futures;
use std::sync::Arc;
use futures::executor::block_on;
use ikigai_core::{
    AsyncFnEndpoint, Capability, EndpointSpace, Exact, Expiry, FixedClock, FnEndpoint, Iri,
    Kernel, ReprType, Representation, Request, Scope, Verb,
};

fn text(s: String) -> Representation {
    Representation::new(ReprType::new("text/plain"), s.into_bytes())
}
fn millis(time: Option<ikigai_core::Time>) -> String {
    time.map(|t| t.as_millis().to_string()).unwrap_or_default()
}

// `now`, as the root binds it: the kernel's clock, read through the invocation. A live
// door — uncacheable, the default `Always`.
let live_now = FnEndpoint::new("now", |inv| Ok(text(millis(inv.now()))));
// `stamp` reads time both ways an endpoint can — by resolution and from the clock — and
// is a pure function of what it read.
let stamp = AsyncFnEndpoint::new("stamp", |inv| {
    Box::pin(async move {
        let resolved = inv.source(&Iri::parse("urn:iki:tutorial:now").unwrap()).await?;
        let resolved = String::from_utf8_lossy(&resolved.bytes).into_owned();
        Ok(text(format!("{resolved} {}", millis(inv.now()))).cacheable())
    })
});
let kernel = Kernel::new(Arc::new(
    EndpointSpace::new()
        .bind(Exact::new("urn:iki:tutorial:now"), live_now)
        .bind(Exact::new("urn:iki:tutorial:stamp"), stamp),
))
.with_clock(Arc::new(FixedClock::at(2_000)));
let root = Capability::root();
let stamp = || Request::new(Verb::Source, Iri::parse("urn:iki:tutorial:stamp").unwrap());

// The corridor: `now` pinned to one instant AND the clock derived from it, in one call.
let six_pm = || {
    Scope::empty().with_named_at(
        Iri::parse("urn:iki:tutorial:ctx:six-pm").unwrap(),
        Arc::new(EndpointSpace::new().bind(
            Exact::new("urn:iki:tutorial:now"),
            FnEndpoint::new("now-at-six", |_| Ok(text("1000".to_string()).cacheable())),
        )),
        Arc::new(FixedClock::at(1_000)),
    )
};

// Under the root: live on both faces, and not kept.
let live = block_on(kernel.issue(stamp(), &root)).unwrap();
assert_eq!(String::from_utf8_lossy(&live.bytes), "2000 2000");
assert_eq!(live.expiry, Expiry::Always);
assert!(!kernel.is_cached(&stamp(), &root));

// Under the corridor: one instant, whichever seam the endpoint used — and immutable.
// Cached in that chain, and in that chain only.
let pinned = block_on(kernel.issue_in(stamp(), &root, six_pm())).unwrap();
assert_eq!(String::from_utf8_lossy(&pinned.bytes), "1000 1000");
assert_eq!(pinned.expiry, Expiry::Never);
assert!(kernel.is_cached_in(&stamp(), &root, &six_pm()));
assert!(!kernel.is_cached(&stamp(), &root));
}

That is as-of resolution: the same request, the same endpoint, and a then in place of a now for the whole resolution and every cache entry under it. Two things keep it honest. The corridor’s name is still the whole of its identity — the fingerprint does not include the clock, so two corridors named alike with different clocks would share answers, and the pairing is the injector’s claim exactly as the name is. And validity is judged on the kernel’s clock, never the chain’s: a corridor pinned in the past cannot un-expire an At(deadline) entry that has passed, and one pinned in the future cannot expire a fresh one. The chain’s clock is what an endpoint sees; the kernel’s is what decides whether to serve.

Not yet

The chain landed in 0.1.72, and by 0.1.75 three of the four kernel faces that used to answer for the empty chain answer for the chain instead: selection (select_transreptor_in, select_action_in — a confined endpoint’s manifold no longer offers an action its chain cannot resolve, and Meta … as= plans its route inside the chain), the cache probe (is_cached_in, and a scope column on urn:kernel:cache), and the pipe (issue_with_incoming_in). What remains, stated so you do not discover it at a keyboard (docs/design/resolution-scope.md in ikigai-core is the full account):

  • The REPL’s pipeline runs in the plain root, so nothing on this page has a Run button.
  • The chain does not cross the wire. A mount inside a confining corridor forwards with no chain, and the remote resolves in its own root — confining to a corridor that contains a mount confines to whatever the mount reaches. The wire’s default refuses a non-empty chain rather than dropping it, so a module endpoint inside a confinement fails loudly instead of quietly escaping. Carrying it is a protocol bump, its own decision.
  • The key holds the whole chain, not the corridors consulted. Sound and over-partitioned, as above. The datum a consulted-corridors key would need — which space answered — is reported since 0.1.78 (Resolved::answered_by); keying on it is not built.

And one thing that was “not built at all” here at 0.1.72 and is now a construct: Limit, the door that stops a family of names in place (0.1.76). Limit::new(prefix) is a space that answers a hit on nothing for every name under prefix and a miss for everything else, so Fallback([Limit("urn:personal:"), root]) is the root minus that family — by structure, with no decision left to misconfigure per request — and a Limit injected with with_named is the same wall at a position in the chain. A limited name is Unresolved, never Denied, and the catalog subtracts it too.

The three constructs — mount, alias, scope — are the arrangement, the naming, and the context of resolution. What resolution buys you is what you get for having them.

Configuration

A host reads a config file. This chapter is about where it looks, how two files combine, and — because the point of a book with tests is that the tests are the claim — how an endpoint that reads configuration is tested against a directory the test owns.

The code is in crates/building-endpoints, one part ahead, because it is the first endpoint in the book whose answer comes from a file. It is bound in that part’s host at urn:iki:tutorial:banner.

Where files live

ikigai-core owns the answer, so every host agrees:

#![allow(unused)]
fn main() {
extern crate ikigai_core;
use std::path::{Path, PathBuf};

// This machine's config home: `$XDG_CONFIG_HOME/ikigai`, else `~/.config/ikigai`.
let home: Option<PathBuf> = ikigai_core::config::config_home();

// The files that make up one logical configuration, in the order they are read.
let layers: Vec<PathBuf> = ikigai_core::config::layered_paths_in(
    Path::new("/tmp/example-home"), "tutorial.toml", Some("yours"),
);
assert_eq!(layers, vec![
    PathBuf::from("/tmp/example-home/tutorial.toml"),
    PathBuf::from("/tmp/example-home/yours.tutorial.toml"),
]);
let _ = home;
}

config_home() is None when the machine has no home to offer. That is a legal state — the process is under-configured, not broken — and it is None rather than a guess on purpose: a guessed home reads as success.

layered_paths_in(home, stem, app) is the layering rule, and it is the same for every crate in the ecosystem: the shared file, then an app-scoped sibling named <app>.<stem> that overrides it key-wise. The stem is the whole file name, extension included. A flat listing of ~/.config/ikigai/ then reads as what it is — a11y.toml states what every front end shares, cms-web.a11y.toml the handful of keys one of them differs on. The _in form takes the home as an argument; layered_paths(stem, app) is the same rule over config_home().

A host that reads one

[banner] text = "…" is the whole schema. Small on purpose, so the layering is visible:

/// What the config file configures. One key, so the layering is visible.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Settings {
    /// `[banner] text = "…"`.
    pub banner: String,
}

impl Settings {
    /// Read and merge the layers under `home`. A missing file is a missing *layer*, not
    /// an error — the process is under-configured, which is legal — but a file that is
    /// present and is not TOML stops the read, because a silently ignored config is the
    /// operator believing one thing while the process does another.
    pub fn load_in(home: &Path, app: Option<&str>) -> Result<Settings> {
        let mut merged = toml::Table::new();
        for path in layered_paths_in(home, STEM, app) {
            let Ok(text) = std::fs::read_to_string(&path) else {
                continue;
            };
            let layer: toml::Table = toml::from_str(&text).map_err(|e| {
                ikigai_core::Error::Endpoint(format!("{}: not TOML: {e}", path.display()))
            })?;
            // Key-wise, later wins: a later layer overrides the keys it states and
            // leaves the rest alone.
            for (key, value) in layer {
                merged.insert(key, value);
            }
        }
        let banner = merged
            .get("banner")
            .and_then(|banner| banner.get("text"))
            .and_then(toml::Value::as_str)
            .unwrap_or(DEFAULT_BANNER)
            .to_string();
        Ok(Settings { banner })
    }
}

Two rules in that loader. A missing file is a missing layer — skipped, silently, because “no override here” is ordinary. A file that is present and broken stops the read, because a config that is silently ignored is an operator believing one thing while the process does another, and nothing anywhere disagreeing until an outage.

And the endpoint that serves it:

/// `banner`: the configured banner text.
///
/// The endpoint holds the *home*, not the parsed settings, and re-reads per resolution.
/// That is the right split for a value that lives in a file: the representation is
/// cacheable and depends on a golden thread named after each candidate file, so the
/// re-read costs nothing until something cuts one of those threads — a watcher, or a
/// hand on `kernel.cut(..)`. A handle holding parsed settings would serve the values the
/// process started with, forever, however many times the thread was cut.
///
/// `home == None` is the under-configured state, stated rather than guessed: no file is
/// read and the default banner answers. There is no `current_dir()` fallback.
pub fn banner(home: Option<PathBuf>, app: Option<&str>) -> FnEndpoint {
    let app = app.map(str::to_string);
    FnEndpoint::new("banner", move |_inv: &Invocation<'_>| {
        let mut repr = match &home {
            Some(home) => {
                let settings = Settings::load_in(home, app.as_deref())?;
                Representation::new(text_plain_utf8(), settings.banner.into_bytes()).cacheable()
            }
            None => Representation::new(text_plain_utf8(), DEFAULT_BANNER.as_bytes().to_vec())
                .cacheable(),
        };
        if let Some(home) = &home {
            for path in layered_paths_in(home, STEM, app.as_deref()) {
                repr = repr.depends_on(path.to_string_lossy().into_owned());
            }
        }
        Ok(repr)
    })
    .with_description(
        Description::new("banner")
            .title("Banner")
            .summary("The banner text this host was configured with.")
            .verb(Verb::Source)
            .verb(Verb::Meta)
            .output(TEXT_PLAIN_UTF8),
    )
}

The home is taken at construction

banner(home, app) takes the home as an argument. It does not call config_home() in its body, and this is the rule the chapter exists to state: the injected form is the real one, and the ambient read is sugar over it, done once, in the host.

Three reasons, from ikigai-core’s design note on the subject. config_home() reads the environment, which is process-global, so an endpoint calling it under cargo test reads the developer’s real ~/.config/ikigai — and the tests that result assert only that a value has a type, because they do not own the file it came from. A test cannot hand such an endpoint a different home without set_var, which races the test beside it. And one process legitimately has two answers: a test binary mounting a fixture home beside a mount with none is the ordinary case.

So the host resolves the home once and hands it down, and a test hands down a directory of its own:

    /// Two layers, and the app-scoped one wins for the key it states.
    #[test]
    fn the_app_layer_overrides_the_shared_one() {
        let home = scratch_home("layers");
        std::fs::write(home.join("tutorial.toml"), "[banner]\ntext = \"shared\"\n").unwrap();

        let kernel = kernel_in(Some(home.clone()));
        assert_eq!(banner_of(&kernel), "shared");

        // The override appears. The cached answer is stale now — and this host has no
        // watcher on the config home, so the thread is cut by hand. Its name is the
        // file's path, which the endpoint declared.
        std::fs::write(
            home.join("yours.tutorial.toml"),
            "[banner]\ntext = \"yours\"\n",
        )
        .unwrap();
        assert_eq!(
            banner_of(&kernel),
            "shared",
            "served from cache: nothing cut a thread"
        );
        kernel.cut(
            home.join("yours.tutorial.toml")
                .to_string_lossy()
                .into_owned(),
        );
        assert_eq!(banner_of(&kernel), "yours");
    }

The last four lines of that test are the golden thread from What resolution buys you, meeting a file. The endpoint declared .depends_on(<path>) for each candidate file, so the cached banner is valid until one of those threads is cut. Nothing in this host watches the directory — a real one would — so the test cuts it by hand, and the next read sees the override.

⚠ The endpoint holds the home, not the parsed settings, and re-reads per resolution. That is the right split for a value that lives in a file: the cache makes the re-read free until a thread is cut, and cutting recomputes. A handle that parsed the file at construction would serve the values the process started with, forever, however many times the thread was cut. The other choice is right for process state a Sink mutates in place — then the handle is the authority, not a stale copy of a file. Ask which one you have before you choose.

Try it

Write a file into this machine’s config home and ask the host for its banner:

mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}/ikigai"
printf '[banner]\ntext = "hello from a file"\n' > "${XDG_CONFIG_HOME:-$HOME/.config}/ikigai/tutorial.toml"
cargo run -p building-endpoints -- --banner
hello from a file

Then write yours.tutorial.toml beside it with a different text and run again: the app-scoped layer wins. Delete both and the default answers.

The other channels

Command-line flags override the file; that is the second channel, and the two are the ones this book uses. Environment variables are the awkward third: ambient influence over a process, leaving no trace in any file, invisible to anyone reading the deployment, inherited silently by children, undiffable. A system whose thesis is that behavior should be nameable and inspectable is not comfortable taking instructions that way.

They are not absent, and a book that said so would be lying on your first day. The CLI (ikigai-cli 0.1.43) reads a set of IKIGAI_* variables — IKIGAI_FILES, the one you meet first, in The file workspace, and IKIGAI_GRANTS, IKIGAI_SMTP_HOST and others carrying deployment facts. The set shrinks: the passkey relying party was a variable until ikigai-cli 0.1.39, and is now two flags on serve, --passkey-rp-id <domain> and --passkey-origin <url>, with no default, so an edge started without them enrolls nothing. It picks its scheduler through a three-way precedence, decide(flag, config, env), and then says which one won:

ikigai --plain -c 'source urn:kernel:scheduler'
scheduler
  backend    single
  threads    1
  source     default

That source row is the shape of the argument rather than a settlement of it: what makes an invisible channel expensive is that nothing afterwards can tell you it was used, and a host that reports which channel decided has bought back the property the objection is about. Configure what you build through the file and flags. If you find yourself wanting an env var, treat that as a question worth raising rather than a pattern worth copying.

Fail loud on missing configuration

⚠ The house rule, and the opposite of what most frameworks do: something expected but unset must stop the program. Not warn, not silently substitute, not carry on degraded. The loader above draws the line where it belongs — absent is legal, broken is not — and a required value with no default should refuse to start and say which key is missing. The worst version of getting this wrong is a service that blocks on a keychain prompt before its first line of output, so the log shows nothing at all: an empty log reads as “still starting” and means “blocked, forever”. One println! before the call would have made it self-identifying.

The file workspace

Files are resources like anything else. urn:file:{path} is bound with a URI template, so one binding covers a whole tree, and reading a file is a Source — the same verb as everything else.

The jail

The tree is $IKIGAI_FILES, else ~/.ikigai/workspace. The CLI creates it if it is missing, and since ikigai-cli 0.1.41 it creates it private (0700) whatever your umask; a directory that already exists is left as you made it. The file endpoint never creates its own root: a root it cannot open serves nothing.

It is deliberately a dedicated, ikigai-owned sandbox and deliberately not your home directory or your documents. Even the owner’s root capability reaches only inside this tree, and the file endpoint’s jail is a hard floor regardless of capability — a second, independent check, because one mechanism guarding your entire filesystem is one mechanism too few.

“Hard floor” is a claim about the file actually opened, not about the path you typed, and it holds in that sense since ikigai-fs 0.1.8 (ikigai-cli 0.1.40). Before that the jail judged the path you named, and a symbolic link, or a swap between the check and the open, could put a different file behind it. Now:

  • The path is opened one name at a time, each from an open handle on the directory before it, and the read or write happens on the handle that walk produced, so nothing can be swapped in between the check and the use.
  • A symbolic link anywhere in the path is refused: the last name or a directory on the way, pointing inside the tree or out of it.
  • A file has one name, its path as it is spelled on disk. ./notes.txt, a//b and dir/ are refused, and the error names the spelling to use (notes.txt); so is NOTES.txt for notes.txt on a volume that folds case, as macOS does by default. .. and absolute paths never leave the tree.
  • On Windows there is no confined backend yet, so the file endpoint refuses every request rather than serve one unconfined.

Two mechanisms, on purpose

It is worth naming why the jail exists in addition to capabilities, since capabilities are supposed to be the authority model:

  • Capabilities decide what a given caller may do — urn:cap:fs:read:ws/abc123 grants read under one segment, and attenuates as it passes down a call chain.
  • The jail decides what the endpoint can reach at all, and no capability can widen it.

A bug in capability minting is then a bug about which files inside the sandbox were reachable, not a bug about whether your SSH keys were.

Segments

Capabilities are usually scoped to a segment of the workspace rather than the whole thing — ws/{id}, where the id is derived from an identity.

There is a running demonstration of that, and it is not in this repository: it is ikigai-web-demo, the kernel-as-WebAssembly page from Running it, at https://ikigai-rs.github.io/ikigai-web-demo/. hello-camel has no identity of any kind (nor does Part II’s loadable-module, when you get there), so nothing you have built so far can show you this.

Open that page’s Identity tab and sign in with a passkey: the credential yields a stable client id, and the id scopes a private workspace segment. The tab then offers three steps, and the third is the one to run — you try to write to somebody else’s segment and the resolver refuses. The boundary is the capability model, doing the one job it exists for.

Reading files through the kernel, not std::fs

⚠ Inside a module or endpoint, read files by resolving urn:file:… rather than calling std::fs directly.

Two reasons, and the second is the one that catches people:

  1. std::fs bypasses the jail and the capability check entirely.
  2. A kernel read is golden-threaded — a filesystem watcher can cut that thread when the file changes, so anything derived from it recomputes. A std::fs read is invisible to the kernel, so a cached result built on it goes stale silently.

It also keeps your code able to run in WebAssembly, where there is no filesystem to call in the first place.

Try it

At the REPL (ikigai), or one line at a time with ikigai --plain -c '…':

ikigai> sink urn:file:notes.txt a note
wrote 6 bytes to notes.txt
[uncacheable]
ikigai> source urn:file:notes.txt
a note
[computed]
ikigai> exists urn:file:notes.txt
true
[computed]
ikigai> exists urn:file:nothing-here.txt
false
[computed]

exists is the Exists verb (ikigai-cli 0.1.46 and later): is the resource there, asked without reading it. Its arguments route the way source’s do, and it takes no pipe, because the question is about one resource.

The rest of a sink line is the content verbatim (spaces, JSON, Turtle and quote marks kept), with one exception since ikigai-cli 0.1.46: content that is exactly one quoted token is unquoted, by the same rules as content="…". So a quoted note is stored without its quotes; to store the quote marks, escape them. Only the double quote is a quote to the REPL, so single quotes are stored as typed, and so is anything that is not one quoted token:

ikigai> sink urn:file:notes.txt "a note"
wrote 6 bytes to notes.txt
[uncacheable]
ikigai> source urn:file:notes.txt
a note
[computed]
ikigai> sink urn:file:notes.txt "\"a note\""
wrote 8 bytes to notes.txt
[uncacheable]
ikigai> source urn:file:notes.txt
"a note"
[computed]
ikigai> sink urn:file:notes.txt 'a note'
wrote 8 bytes to notes.txt
[uncacheable]
ikigai> sink urn:file:notes.txt "a" and "b"
wrote 11 bytes to notes.txt
[uncacheable]
ikigai> source urn:file:notes.txt
"a" and "b"
[computed]

A sink with no content on its line takes its content from a pipe, so printf 'a note' | ikigai --plain -c 'sink urn:file:notes.txt' stores a note. Under -c it waits at most two seconds for stdin’s first byte (ikigai-cli 0.1.46 and later): a stdin nobody writes to, such as an editor’s or an agent’s inherited pipe, then sends the sink with no content at all, never an empty one, and says so on stderr. The file endpoint requires content, so that sink is refused and the file keeps what it held. A stdin that is empty and closed (< /dev/null) is different: it answers at once, and its answer is an empty body.

Then look in ~/.ikigai/workspace: it is an ordinary directory. Nothing about the model requires the storage to be exotic; it requires the access to be named.

Files as bodies

The transcripts above are replayed against the book’s pinned ikigai-cli 0.2.6, which links ikigai-fs 0.3.0. Until ikigai-fs 0.2.3 (ikigai-cli 0.2.3) a file read answered one buffer; from it a file is a body (Streams, I) on the native backend:

  • A source answers a seekable body backed by the file, read 64 KiB at a time. A reader that streams it (a pipe into a stage that reads a body, an HTTP download) holds a chunk, not the file, and is not held to the 16 MiB a buffered file read is; one that collects it is bound by its run’s byte budget instead. A file that changes while it is read ends in a conflict, never a short or mixed body.
  • The body states a strong validator: the content id of its bytes, computed on the first read that asks for it and remembered against the file’s version, so an HTTP door can send an ETag, answer 304 and serve byte ranges (206) for a file. A file changed in the last two seconds states none yet, and until it does the door serves no ranges for it.
  • A sink streams its content into the file, a chunk at a time and never collected first, so source urn:file:big.log | sink urn:file:copy.log copies a file without holding it. A sink that fails part-way leaves the target as it was.
  • Each root has a write ceiling, 64 MiB unless the host sets another (FileEndpoint::max_write). A sink past it is refused, never truncated, and the file keeps what it held.

The browser’s localStorage backend still answers and stores buffers.

Exercises: getting started

Four ways to find out whether Part I landed. None of them needs anything beyond this repository.

First: where your code goes, and how to run it

This book has shown you how to run its code four times and never once how to run yours. So, before the exercises:

Your endpoints go in crates/your-endpoints/src/lib.rs. Not in hello-camel — that is the file the chapters quote, and editing it would change what the book says. Nothing quotes your-endpoints, and a test makes sure nothing starts to; it exists to be broken. It starts with one worked endpoint, word-count, in the shape the book teaches, and a space() that chains onto Part I’s, so camel-case and title answer from your host too. An endpoint is a function plus a description, and it reaches a name through one more line there:

pub fn space() -> EndpointSpace {
    hello_camel::space()
        .bind(Exact::new("urn:iki:tutorial:yours:word-count"), word_count())
        .bind(Exact::new("urn:iki:tutorial:yours:your-thing"), your_thing()) // ← yours
}

The prefix urn:iki:tutorial:yours: is a stand-in for one you own. Rename it the day this stops being a tutorial, and notice nothing else has to change.

One thing your crate cannot do is run in this page. The kernel the published book carries is Part I’s space compiled once, when the book was built; your code is compiled on your machine, so the loop for it is cargo test and the binary, not a Run button.

The fastest loop is a test, because a test needs no wiring at all:

cargo test -p your-endpoints

The test module at the bottom of that file already contains the four lines that build a kernel, resolve a name and hand back a String — the text_of helper. Copy it, change the IRI, and you have a way to ask your endpoint a question and assert the answer.

To see it from a shell, crates/your-endpoints/src/bin/yours.rs resolves one name and prints the result. Point its NAME at yours and run:

cargo run -p your-endpoints -- "a few words here"

To watch the host describe itself, ask for the catalog:

cargo run -p your-endpoints -- --catalog

Your endpoint appears in it the moment it is bound, described exactly as its Description says — because the catalog is assembled from those descriptions rather than maintained beside them. urn:kernel:actions lists the names alone. Both answer here because the tutorial host was built with a Meta renderer; a bare Kernel::new has none, and would answer no Meta renderer configured to either. Binding, and a host of your own shows the one line that makes the difference.

⚠ The catalog shows what you declared. A test that asserts your_thing().describe() is the same instrument pointed at one endpoint, and it is the one that runs on every cargo test — Hello, resource does exactly that with camel_case().describe().id.


1. Write a strict lowerCamelCase

camel-case never lower-cases anything: "Hello WORLD" becomes "HelloWORLD", because it treats the input’s own casing as meaningful. Write lower-camel-case, which does not — and decide what it should do with "XMLHttpRequest". There is no obviously right answer, which is the point of the exercise.

  • File — crates/your-endpoints/src/lib.rs; bind it at urn:iki:tutorial:yours:lower-camel-case. hello_camel::camel is the pure function under camel-case, if you want to start from it rather than from scratch.
  • Run — cargo test -p your-endpoints
  • Right when — your own test asserts the answer you chose for "XMLHttpRequest", and cargo test -p hello-camel still passes untouched. If camel-case’s behavior changed, you edited the wrong crate.
  • Read again — Hello, resource for the shape of an implementation, Binding, and a host of your own for the bind line.
Hint

The mechanical part is small: char::to_lowercase returns an iterator for the same reason to_uppercase does, so it composes the same way — output.extend(first.to_lowercase()).

The part that is actually the exercise is that "XMLHttpRequest" has no correct answer, only a stated one. "xMLHttpRequest" (lower-case the first character), "xmlHttpRequest" (treat a run of capitals as one word) and "xmlhttprequest" (lower-case everything before re-humping) are all defensible, and they disagree about what a “word” is.

So write the assertion first, in a test named after the decision:

#[test]
fn a_leading_run_of_capitals_is_one_word() {
    assert_eq!(lower_camel("XMLHttpRequest"), /* the answer you are choosing */);
}

A test name is where a decision like this survives, because it is the only comment the compiler reads. Correct output for the easy cases is unambiguous and worth pinning too: "resource oriented computing" → "resourceOrientedComputing", "Hello WORLD" → whatever your rule says, and " " → "" rather than an error.


2. Add a second argument

Add an optional separator, so a caller can split on something other than whitespace. Declare it in the description with optional() and a default_value.

  • File — crates/your-endpoints/src/lib.rs, in both halves of your lower-camel-case from exercise 1 (or of word-count, if you skipped it — a separator makes sense there too): the ArgSpec list in the description and the implementation.
  • Run — cargo test -p your-endpoints
  • Right when — resolving with no separator gives the same answers as before, and resolving with separator="," camel-cases "a,b,c" into "aBC". A test asserting your ArgSpec is present is worth writing too — your_thing().describe().inputs is a list you can look at.
  • Read again — Why an endpoint describes itself, the ArgSpecs section.
Hint

The default lives in one place, the description. Declare ArgSpec::new("separator").optional().default_value(" ") and the kernel fills it in before your endpoint runs: a request that left separator out reaches your code as if the caller had written separator=" ", so inv.inline_str("separator") answers " ". A caller’s own value always wins, an empty one included. The block below runs on every build of this book, so the sentence cannot drift from the kernel:

#![allow(unused)]
fn main() {
extern crate ikigai_core;
extern crate futures;
use std::sync::Arc;
use futures::executor::block_on;
use ikigai_core::{
    ArgRef, ArgSpec, AsyncFnEndpoint, Capability, Description, EndpointSpace, Exact, Iri,
    Kernel, ReprType, Representation, Request, Verb,
};

// An endpoint that answers with the separator it was handed, and nothing else.
let echo = AsyncFnEndpoint::new("separator-echo", |inv| {
    Box::pin(async move {
        let separator = inv.inline_str("separator")?.to_string();
        Ok(Representation::new(ReprType::new("text/plain"), separator.into_bytes()))
    })
})
.with_description(
    Description::new("separator-echo")
        .verb(Verb::Source)
        .input(ArgSpec::new("separator").optional().default_value(" ")),
);
let kernel = Kernel::new(Arc::new(
    EndpointSpace::new().bind(Exact::new("urn:iki:tutorial:separator-echo"), echo),
));
let ask = || Request::new(Verb::Source, Iri::parse("urn:iki:tutorial:separator-echo").unwrap());
let root = Capability::root();

// Left out: the kernel filled in the declared default before dispatch.
let left_out = block_on(kernel.issue(ask(), &root)).unwrap();
assert_eq!(left_out.bytes, b" ");

// Spelled out: the caller's value wins.
let spelled = ask().with_arg("separator", ArgRef::Inline(b",".to_vec()));
assert_eq!(block_on(kernel.issue(spelled, &root)).unwrap().bytes, b",");

// An explicitly empty value also wins over the nonempty default.
let empty = ask().with_arg("separator", ArgRef::Inline(Vec::new()));
assert_eq!(block_on(kernel.issue(empty, &root)).unwrap().bytes, b"");

// A default is what an omitted input assumes, so declaring one makes the input optional...
let separator = ArgSpec::new("separator").default_value(" ");
assert!(!separator.required);

// ...and a description that marks a defaulted input required anyway is refused.
let mut required = separator;
required.required = true;
let contradiction = Description::new("x").verb(Verb::Source).input(required);
assert!(contradiction.validate().is_err());
}

So your implementation reads separator like any other argument and does not repeat the default. (Before ikigai-core 0.1.105 the kernel did not fill it in: the read failed with MissingArgument, and every endpoint applied its default a second time, by hand, where it could disagree with the one it declared.) default_value makes the input optional by itself, and .optional() beside it only says so out loud; a description that sets a defaulted input back to required is refused by Description::validate. What is still yours is the test that resolves without the argument: it is the one that shows your camel-casing does the right thing with the default the kernel hands it.

(To read the declaration back in that test: the builder is .default_value(..), and the field it sets on ArgSpec is .default.)

urn:kernel:actions will not show your change, by the way: it lists names, and the name was already there. It is the description that changed, and in this host you read that through describe().


3. Take a resource, not a string

Make your endpoint accept in as a reference to another resource as well as an inline value: given ArgRef::Reference(urn:iki:tutorial:title), resolve that name through the kernel and camel-case whatever it returns.

  • File — crates/your-endpoints/src/lib.rs. urn:iki:tutorial:title already answers from your host — it is the resource What resolution buys you writes to, and your space() chains onto the one that binds it — so the only new thing is a version of camel-case that can take a reference to it.
  • Run — cargo test -p your-endpoints
  • Right when — one test passes the text inline and gets the answer it always got, and a second passes ArgRef::Reference and gets the camel-cased contents of title. Then Sink a new title and resolve again: the by-reference answer follows the write, the inline one cannot.
  • Read again — Hello, resource on inline_str, What resolution buys you for camel-title, which is this exercise with the reference hard-wired, and The callback, which is the same move seen from a module.
Hint

This is not free, and finding out why is the exercise. inv.inline_str("in") asks for an inline value and refuses anything else — hand it a Reference and you get invalid argument in: expected an inline value. An endpoint takes resources only by opting in:

match inv.request.args.get("in") {
    Some(ArgRef::Reference(iri)) => { let repr = inv.source(iri).await?; /* … */ }
    Some(ArgRef::Inline(bytes)) => { /* what you do today */ }
    _ => return Err(Error::MissingArgument("in".into())),
}

(The last arm also catches ArgRef::Content, a larger value interned in the content store, so keep a catch-all if you rewrite the match: without one it does not compile.)

Two consequences fall straight out of that shape.

inv.source(…) is async, so the endpoint is no longer an FnEndpoint — it becomes an AsyncFnEndpoint, authored as |inv| Box::pin(async move { … }). The type change is the whole cost, and it is why the book’s first endpoint is not written this way.

A Reference carries no arguments of its own — it is a name, not a call — so the resource it names has to resolve on its own. urn:iki:tutorial:title returning a constant works. urn:iki:fn:toUpper does not: it needs an in argument, and there is nowhere in a reference to put one. (urn:iki:fn:toUpper?in=hello does not resolve either; that binding matches the name exactly, query string included.)

Correct output looks like this: with title still at its starting text, resolving your endpoint with in as a reference to it returns "resourceOrientedComputing" — the same bytes the inline call gives, produced without the caller ever holding the text. And because inv.source recorded the dependency, the answer is cached under title’s golden thread: write to title and it recomputes, exactly as camel-title did.


4. Break cacheability on purpose

Write an endpoint that returns the current time, mark the representation .cacheable(), and watch how convincing a wrong answer looks.

  • File — crates/your-endpoints/src/lib.rs; bind it at urn:iki:tutorial:yours:now.
  • Run — cargo test -p your-endpoints
  • Right when — a test resolves it twice through one kernel, with a sleep in between, and the two answers are byte-identical. Remove .cacheable(), run again, and they differ. The test that proves the bug is the one that passes.
  • Read again — Resolution on golden threads, and Hello, resource on what .cacheable() asserts.
Hint

The cache lives in the Kernel, keyed on the request, the capability and the resolution chain — so build the kernel once and issue twice against it. Two Kernel::new calls give you two empty caches and a test that passes for the wrong reason.

let kernel = kernel();
let first = resolve(&kernel, "urn:iki:tutorial:yours:now");
std::thread::sleep(Duration::from_millis(50));
let second = resolve(&kernel, "urn:iki:tutorial:yours:now");
assert_eq!(first, second); // ← the bug, asserted

Sit with what that assertion means for a moment. Nothing failed. No warning was printed, no type was wrong, the endpoint is three lines of obviously correct code, and the answer is a perfectly well-formed timestamp — just not the current one. That is the whole reason .cacheable() is a claim you make rather than an optimization the kernel infers: same inputs, same answer, forever. A clock has no inputs and a different answer every time, so it is the one thing that can never be it.

Two things worth carrying out of the exercise. When in doubt, do not cache — an uncacheable resource is slow, and a wrongly cached one is quietly incorrect, which is much harder to notice. And in real code the time should come from inv.now(), the clock the kernel injects, rather than SystemTime::now() — a kernel does not only run on your machine, and std::time is not available everywhere it goes.

The host has to inject that clock, though. your_endpoints::kernel() is built without one, so inv.now() answers None there until the kernel is built with .with_clock(Arc::new(SystemClock)) (ikigai_core::SystemClock): the host hands the kernel its clock the way Configuration hands an endpoint its home.

Transreption

Resolution said a resource does not have a format: it has whatever formats the kernel can reach from what it has. This chapter is where you find out what “reach” means, by using a transreptor and then writing one.

The code is crates/building-endpoints, this part’s crate. Its host chains onto Part I’s space, so everything you built there is bound here too, plus four endpoints: banner, which Configuration borrowed from this part, and the three these chapters add:

/// This part's space: Part I's bindings, the published SPARQL face, and the four
/// endpoints these chapters build. `home` is where [`config`] looks for its file — a
/// test hands it a directory of its own; [`kernel`] hands it this machine's.
pub fn space(home: Option<PathBuf>) -> Fallback {
    let ours: EndpointSpace = hello_camel::space()
        .bind(
            Exact::new("urn:iki:tutorial:markdown"),
            transreption::markdown(),
        )
        .bind(
            Exact::new("urn:iki:tutorial:counter"),
            multi_verb::counter(),
        )
        .bind(Exact::new("urn:iki:tutorial:stamp"), hermetic::stamp())
        .bind(
            Exact::new("urn:iki:tutorial:banner"),
            config::banner(home, Some("yours")),
        );
    // Two spaces, first match wins: ours, then `urn:sparql:*` from the published crate.
    Fallback::new(vec![
        Arc::new(ours) as Arc<dyn Space>,
        Arc::new(ikigai_sparql::space()) as Arc<dyn Space>,
    ])
}

Using one

The ikigai CLI links a transreptor between RDF syntaxes, urn:rdf:transrept. It declares what it converts — describe it and look for the transrepts: line — and it is driven the way every transreptor is driven: content in through the pipe, the target type in as:

ikigai --plain -c 'source urn:kernel:catalog | urn:rdf:transrept as=application/ld+json'

The catalog came out as Turtle and went in as Turtle; what came back is the same graph as JSON-LD. Nothing about urn:kernel:catalog knows JSON-LD exists. That is the shape: the format is a property of the route, not of the resource.

What a transreptor declares

An ordinary endpoint’s description says what it does. A transreptor’s says, in addition, what it converts between — Description::transreptor(from, to) — and that declaration is what the kernel selects on. Two rules follow from how selection works, and they are worth knowing before you write one:

  • A transreptor is auto-invocable when its only required inputs are content and as. Those are exactly the two the kernel supplies when it drives a step. Declare a third required input — a stylesheet, say — and it is still a transreptor, still callable by hand, and never chosen automatically.
  • Routing is one hop, or two through Turtle. If no transreptor goes directly from what you have to what you want, the kernel looks for one that reaches text/turtle and one that leaves it. Turtle is the pivot, which is why the vocabulary renders to Turtle first and everything else second.

Writing one

The endpoint below turns the ikigai description vocabulary — the Turtle every Meta answers with — into Markdown. It parses real triples rather than lines, so it works on one endpoint’s description or a whole catalog, and it knows nothing about which. It is also, deliberately, not a transreption in the strict sense: it keeps the title, the summary and the inputs and drops the rest, so the Turtle cannot be recovered from it. That makes it a rendering — a projection — and the description says so:

/// Render the ikigai description vocabulary as Markdown: one section per endpoint, its
/// summary, and a bullet per declared input.
///
/// The input is parsed as RDF — real triples, not lines — so this works on any Turtle
/// carrying the vocabulary, whether it came from one `Meta` or a whole catalog. The
/// `content` argument is the document; a transreptor reads its input from there because
/// that is where the kernel puts it when it drives a transreption step.
pub fn markdown_impl(inv: &Invocation<'_>) -> Result<Representation> {
    let turtle = inv.inline_arg("content")?;
    let triples: Vec<Triple> = oxttl::TurtleParser::new()
        .for_slice(turtle)
        .collect::<std::result::Result<_, _>>()
        .map_err(|e| Error::Endpoint(format!("not Turtle: {e}")))?;

    // Every triple, grouped by subject and then by predicate — stable order, so the
    // output is diffable and a test can assert on it.
    let mut by_subject: BTreeMap<String, BTreeMap<String, Vec<Term>>> = BTreeMap::new();
    for triple in triples {
        let subject = match triple.subject {
            NamedOrBlankNode::NamedNode(node) => node.as_str().to_string(),
            other => other.to_string(),
        };
        by_subject
            .entry(subject)
            .or_default()
            .entry(triple.predicate.as_str().to_string())
            .or_default()
            .push(triple.object);
    }

    let literal = |node: &BTreeMap<String, Vec<Term>>, property: &str| -> Option<String> {
        node.get(&format!("{IK}{property}"))
            .and_then(|terms| terms.first())
            .and_then(|term| match term {
                Term::Literal(literal) => Some(literal.value().to_string()),
                _ => None,
            })
    };

    let mut out = String::new();
    for (iri, node) in &by_subject {
        let is_endpoint = node
            .get(RDF_TYPE)
            .is_some_and(|types| types.iter().any(|t| t.to_string().contains("ns#Endpoint")));
        if !is_endpoint {
            continue;
        }
        let id = literal(node, "id").unwrap_or_else(|| iri.clone());
        let title = literal(node, "title").unwrap_or_else(|| id.clone());
        out.push_str(&format!("## {title} (`{id}`)\n\n"));
        if let Some(summary) = literal(node, "summary") {
            out.push_str(&format!("{summary}\n\n"));
        }
        // Inputs are nodes of their own; follow the `ik:input` edges to them.
        let inputs = node.get(&format!("{IK}input")).into_iter().flatten();
        let mut listed = false;
        for input in inputs {
            let Term::NamedNode(input_iri) = input else {
                continue;
            };
            let Some(input_node) = by_subject.get(input_iri.as_str()) else {
                continue;
            };
            let name = literal(input_node, "inputName").unwrap_or_default();
            let required = literal(input_node, "required").as_deref() == Some("true");
            let summary = literal(input_node, "summary").unwrap_or_default();
            out.push_str(&format!(
                "- `{name}`{} — {summary}\n",
                if required { " (required)" } else { "" }
            ));
            listed = true;
        }
        if listed {
            out.push('\n');
        }
    }

    Ok(Representation::new(text_markdown_utf8(), out.into_bytes()).cacheable())
}

And its description, which is the half that makes it reachable:

/// `markdown`: a transreptor from `text/turtle` to `text/markdown` — declared **lossy**.
///
/// The description is the interesting half. `.transreptor(from, to)` is what makes the
/// kernel *select* this endpoint when something asks for Markdown and has Turtle; the
/// two inputs are `content` and `as`, and both are what the kernel supplies when it drives
/// a step — an auto-invocable transreptor is one whose required inputs are exactly
/// those. Declare a third required input and it is still a transreptor, but the kernel
/// will not pick it up on its own.
///
/// `.lossy()` says what kind of conversion this is. A transreption proper changes form
/// without changing what is represented — the Turtle could be recovered from the output.
/// This one cannot be undone: it keeps a title, a summary and the inputs and drops the
/// verbs, the capabilities, the output type and everything else, so it is a *rendering*
/// of the description, a projection. Saying so is what lets the kernel refuse to route a
/// `Meta` through it silently — the caller has to consent with `lossy=allow` — while a
/// transreptor that says nothing claims, by default, to be lossless.
pub fn markdown() -> FnEndpoint {
    FnEndpoint::new("markdown", markdown_impl).with_description(
        Description::new("markdown")
            .title("Turtle to Markdown")
            .summary("Renders the ikigai description vocabulary as Markdown.")
            .verb(Verb::Source)
            .verb(Verb::Meta)
            .input(ArgSpec::new("content").summary("the Turtle document to render"))
            .input(
                ArgSpec::new("as")
                    .summary("the target type; only text/markdown")
                    .optional(),
            )
            .output("text/markdown;charset=utf-8")
            .transreptor(["text/turtle"], ["text/markdown"])
            .lossy(),
    )
}

The kernel finds it

Bound at urn:iki:tutorial:markdown, the transreptor is found by what it declared, not by its name:

    /// The kernel finds the transreptor by its declaration, not by its name — and reads
    /// the whole declaration: a lossy edge is planned only with consent.
    #[test]
    fn the_kernel_selects_the_transreptor_from_its_declaration() {
        let kernel = kernel_in(None);

        // The default policy plans through lossless edges only, and this one is lossy.
        assert!(kernel
            .select_transreptor("text/turtle", "text/markdown")
            .is_none());

        // With consent the route is one hop, and the step reports what it declared.
        let plan = kernel
            .select_transreptor_with(
                "text/turtle",
                "text/markdown",
                &TransreptionPolicy::allow_lossy(),
            )
            .expect("a route from Turtle to Markdown exists, with consent");
        assert_eq!(plan.len(), 1, "one hop");
        assert_eq!(plan[0].endpoint, "urn:iki:tutorial:markdown");
        assert_eq!(plan[0].to, "text/markdown");
        assert!(!plan[0].lossless);

        // ...and no route to a type nothing produces, consent or not.
        assert!(kernel
            .select_transreptor_with(
                "text/turtle",
                "application/pdf",
                &TransreptionPolicy::allow_lossy()
            )
            .is_none());
    }

And then the payoff. camel-case has never heard of Markdown. Ask for its description as Markdown — with consent to the projection, the next section — and the kernel renders Turtle, finds the route, and runs it:

    /// `Meta … as=text/markdown lossy=allow` on an endpoint that knows nothing about
    /// Markdown. The renderer emits Turtle; the kernel finds the route; the answer is
    /// Markdown. The consent is there because the route is a projection — see the
    /// refusal test below for what happens without it.
    #[test]
    fn meta_reaches_markdown_through_the_transreptor() {
        let kernel = kernel_in(None);
        let request = Request::new(
            Verb::Meta,
            Iri::parse("urn:iki:tutorial:camel-case").expect("iri"),
        )
        .with_arg("as", ArgRef::Inline(b"text/markdown".to_vec()))
        .with_arg("lossy", ArgRef::Inline(b"allow".to_vec()));

        let repr = block_on(kernel.issue(request, &Capability::root())).expect("resolves");
        let markdown = String::from_utf8(repr.bytes).expect("utf-8");

        assert_eq!(repr.repr_type.media_type, "text/markdown");
        assert!(
            markdown.starts_with("## Camel-case (`camel-case`)\n"),
            "{markdown}"
        );
        assert!(
            markdown.contains("- `in` (required) — the text to camel-case"),
            "{markdown}"
        );
    }
cargo run -p building-endpoints -- --markdown
## Camel-case (`camel-case`)

Camel-cases the UTF-8 text supplied in the `in` argument.

- `in` (required) — the text to camel-case

Look at what was not written: no as=text/markdown branch in camel-case, no registry of formats, no call from the requester to the transreptor. A new format became reachable from every endpoint in the host by binding one endpoint that declared what it converts.

⚠ What the kernel routes today is Meta. A Source with as= reaches the endpoint with as as an ordinary argument — an endpoint that offers faces (text and Turtle, say) reads it and answers, and one that does not ignores it. Routing a Source through a transreptor chain is what the pipe does explicitly: source X | urn:rdf:transrept as=…. If you expected source urn:iki:tutorial:camel-case as=text/markdown to produce Markdown, that is the chapter’s one disappointment, and it is stated here so it is not discovered at a keyboard.

Declared, and the planner honors it

A transreptor claims to change form without changing what is represented: the function is injective, and the caller can treat the output as the same resource in another syntax. A summary, a thumbnail, an embedding, this chapter’s Markdown are not injective. They are projections — different resources that have lost information — and a projection registered as a transreptor would become a silent hop in a route, handing the caller less than it asked for under exactly the type it asked for, which no reader of the type can detect. So the declaration carries the distinction, since core 0.1.77: .transreptor(from, to) claims lossless by default, and .lossy() says this edge is not. It is a declaration, not a checked property — core cannot decide injectivity — but the planner reads it. Selection plans through lossless edges only, unless the caller consents; consent widens the search without reordering it, so a lossless route still wins wherever one exists, and every step of a plan reports its own declaration.

What that looks like at the Meta face. Ask for Markdown and the kernel finds the route, sees that its one hop is a projection, and refuses — typed, naming the step and the consent, never a silent projection and never a substitution that would hide that a route exists. Ask again with lossy=allow and the same route runs:

#![allow(unused)]
fn main() {
extern crate building_endpoints;
extern crate ikigai_core;
extern crate futures;
use futures::executor::block_on;
use ikigai_core::{ArgRef, Capability, Error, Iri, Request, Verb};

let kernel = building_endpoints::kernel_in(None);
let root = Capability::root();
let meta_as_markdown = || {
    Request::new(Verb::Meta, Iri::parse("urn:iki:tutorial:camel-case").unwrap())
        .with_arg("as", ArgRef::Inline(b"text/markdown".to_vec()))
};

// Without consent: refused, and the refusal says which step is lossy and what consents.
let refused = block_on(kernel.issue(meta_as_markdown(), &root)).unwrap_err();
assert!(matches!(refused, Error::Endpoint(_)));
assert_eq!(
    refused.to_string(),
    "endpoint error: meta face `text/markdown` is reachable only through a lossy \
     transreptor (`urn:iki:tutorial:markdown`): a projection loses information and \
     would not describe the endpoint; pass `lossy=allow` to consent"
);

// With consent: the route runs, and what comes back is Markdown.
let consented = meta_as_markdown().with_arg("lossy", ArgRef::Inline(b"allow".to_vec()));
let repr = block_on(kernel.issue(consented, &root)).unwrap();
assert_eq!(repr.repr_type.media_type, "text/markdown");
assert!(String::from_utf8_lossy(&repr.bytes).starts_with("## Camel-case (`camel-case`)\n"));
}

The error is an Endpoint error on purpose. The face is not absent, so it is not NotFound; no capability is at issue, so it is not Denied — consent is per request, not per holder; and the arguments are well-formed, so it is not InvalidArgument. A mistyped consent (lossy=alow) is an InvalidArgument, rather than being read as the default and blamed on the face. At the REPL the same refusal is what describe urn:iki:tutorial:camel-case text/markdown prints.

⚠ One thing the declaration still does not do. When no route at all reaches the type a Meta request asked for, the kernel substitutes the canonical Turtle rather than failing: a description in a form you did not ask for, over no description. The type on what comes back says text/turtle, so a caller that reads it knows. The refusal above is only for a route that exists and is lossy; a route that does not exist is a substitution, and that is unchanged.

A format that is not RDF

Everything above went into Turtle or came out of it. Turtle is the pivot of a two-hop route, but it is not the only thing a transreptor converts. Any representation to any representation is the same mechanism: a declaration, and a plan the kernel makes from it.

ikigai-json (0.1) is a module with three resources:

  • urn:json:query runs a jq program over any resource;
  • urn:json:from-csv reads CSV as JSON;
  • urn:json:to-csv goes the other way.

from-csv declares .transreptor(["text/csv"], ["application/json"]), so it is also the kernel’s route from CSV to JSON. Nothing about it involves Turtle.

The data is a small shop’s stock, a CSV table the way a spreadsheet exports it. It lives in the crate crates/shop, which a later chapter, Short names in a space, builds on. The table is an atom: Source answers it as text/csv, and Sink replaces it.

item,shelf,count
flour,A,12
sugar,A,0
rice,B,30
lentils,B,7

urn:json:query reads its src through the kernel, under the caller’s capability. When the answer is not JSON, it asks the kernel for a transreptor chain from what was served to application/json (Invocation::select_transreptor, the kernel’s planner, by the rules above), runs it, and gives the program the result. jq never sees CSV. Each record becomes an object keyed by the header row, and every value is a string, because CSV has no types and the converter does not guess.

The page’s kernel links ikigai-json and the shop, so these cells run here. Elements from the collection, one record, and a value from that record:

item,shelf,count
flour,A,12
sugar,A,0
rice,B,30
lentils,B,7
[computed]
["flour","sugar","rice","lentils"]
[computed]
{"item":"rice","shelf":"B","count":"30"}
[computed]
30
[computed]

A program that yields one result answers that value, and as=text/plain answers a single scalar as bare text, with no quotes. That is the form a template splices, which Tic-tac-toe, V comes back to.

⚠ A Source of the stock with as=application/json still answers CSV. As the note above says, as= on a Source is the endpoint’s own argument, and the stock has one face. The route through the transreptor is planned by whoever asks for JSON. Here that is urn:json:query, and in the REPL it is a pipe: source urn:iki:tutorial:shop:stock | urn:json:from-csv.

Cached, and cut by a write

A query’s answer is cached exactly when its read of src is, and it hangs from the stock’s thread. The conversion is a step inside it, so a second query over the same table finds the answer cached. A query that uses a number converts the string itself (tonumber):

["flour","rice"]
[computed]
["flour","rice"]
[cached]

Now change the table. The line below reads the stock as JSON and keeps only what is on a shelf. Then it writes that back as CSV through urn:json:to-csv and sinks it to the stock. The round trip is three resources and a pipe. The Sink cuts the stock’s thread, as every Sink does (Golden threads in practice), and both cached selections above go with it. The first recomputes to the same answer, since flour and rice are still on their shelves. The list of items, read in the first cell, recomputes without sugar:

ok
[2 computed · 1 uncacheable]
item,shelf,count
flour,A,12
rice,B,30
lentils,B,7
[computed]
["flour","rice"]
[computed]
["flour","rice","lentils"]
[computed]

The pipe line counts what it ran: the query and to-csv computed, and the Sink, which is never cached. The table came back with CRLF line ends, which is what RFC 4180 writes, and from-csv reads it the same as before. to-csv is declared lossy: 36 and "36" write the same. The kernel would not plan it into a route without consent, and it did not have to. The pipe called it by name.

The same listing in Rust, against the shop’s own host:

#![allow(unused)]
fn main() {
extern crate shop;
use shop::{source, Host, STOCK};

let mut host = Host::start("shop");
let shelved = || {
    source(
        "urn:json:query",
        &[("src", STOCK), ("q", "[.[] | select(.count | tonumber > 10) | .item]")],
    )
};
assert_eq!(host.read(shelved()).unwrap(), r#"["flour","rice"]"#);
assert!(host.is_cached(&shelved()));

// A write to the CSV cuts its thread, and the selection recomputes from the new table.
host.write(STOCK, "item,shelf,count\nflour,A,9\nrice,B,30\n");
assert!(!host.is_cached(&shelved()));
assert_eq!(host.read(shelved()).unwrap(), r#"["rice"]"#);
}

jq here is a sandbox: ikigai-json rewrites the program so every step is metered, and bounds its size, its input, its work, its depth and its output. On a native host a bound reached is a typed refusal. In this page it is an abort, because WebAssembly cannot unwind, and it stops the page’s kernel until you reload. Edit these cells freely, but keep programs small: a recursion or a string built a million times over is the way to find that out.

Try it

#![allow(unused)]
fn main() {
extern crate building_endpoints;
extern crate ikigai_core;
extern crate futures;
use futures::executor::block_on;
use ikigai_core::{ArgRef, Capability, Iri, Request, Verb};

let kernel = building_endpoints::kernel_in(None);

// The transreptor, driven by hand, exactly as the kernel drives it: content plus as.
let turtle = "@prefix ik: <https://ikigai-rs.dev/ns#> .
<urn:x> a ik:Endpoint ; ik:id \"x\" ; ik:title \"Example\" ; ik:summary \"Made up.\" .";
let request = Request::new(Verb::Source, Iri::parse("urn:iki:tutorial:markdown").unwrap())
    .with_arg("content", ArgRef::Inline(turtle.as_bytes().to_vec()))
    .with_arg("as", ArgRef::Inline(b"text/markdown".to_vec()));

let repr = block_on(kernel.issue(request, &Capability::root())).unwrap();
assert_eq!(String::from_utf8_lossy(&repr.bytes), "## Example (`x`)\n\nMade up.\n\n");
}

Multi-verb endpoints

camel-case answers one verb, so its flat description is its contract — the 93% case, and the reason the flat form exists. title, in What resolution buys you, answered two and quietly declared two ActionSpecs. This chapter is that move made deliberate: one endpoint, three verbs, three contracts — and a capability on your own endpoint that the kernel enforces, per verb, before your code runs.

Three verbs, three actions

counter is a number the host holds. Source reads it, Sink sets it, Delete resets it. Reading needs no authority; writing and resetting do.

/// `counter`: a number the host holds. `Source` reads it, `Sink` sets it, `Delete`
/// resets it to zero.
///
/// The implementation branches on the verb; the description declares each branch as an
/// action. The two halves have to agree, and the kernel enforces only the declared half:
/// remove `.requires(WRITE)` from the `Sink` action and the write is open to anyone,
/// regardless of what the code below would like — there is no check in it, on purpose,
/// because a check the catalog does not know about is a lie in the other direction.
pub fn counter() -> FnEndpoint {
    let count = Arc::new(Mutex::new(0u64));
    FnEndpoint::new("counter", move |inv: &Invocation<'_>| {
        let mut current = count.lock().expect("counter lock");
        match inv.request.verb {
            Verb::Sink => {
                let text = inv.inline_str("content")?;
                *current = text.trim().parse().map_err(|e| Error::InvalidArgument {
                    name: "content".into(),
                    detail: format!("not a number: {e}"),
                })?;
                Ok(Representation::new(text_plain_utf8(), b"ok".to_vec()))
            }
            Verb::Delete => {
                *current = 0;
                Ok(Representation::new(text_plain_utf8(), b"reset".to_vec()))
            }
            _ => Ok(
                Representation::new(text_plain_utf8(), current.to_string().into_bytes())
                    .cacheable()
                    .depends_on(COUNTER),
            ),
        }
    })
    .with_description(
        Description::new("counter")
            .title("Counter")
            .summary("A number the host holds: read it, set it, or reset it.")
            .verb(Verb::Meta)
            .action(
                ActionSpec::new(Verb::Source)
                    .summary("the current count")
                    .output(TEXT_PLAIN_UTF8),
            )
            .action(
                ActionSpec::new(Verb::Sink)
                    .summary("set the count")
                    .input(
                        ArgSpec::new("content")
                            .summary("the new count, a non-negative integer")
                            .class("http://www.w3.org/2001/XMLSchema#nonNegativeInteger"),
                    )
                    .requires(WRITE),
            )
            .action(
                ActionSpec::new(Verb::Delete)
                    .summary("reset the count to zero")
                    .requires(WRITE),
            ),
    )
}

The implementation is one closure branching on inv.request.verb. The description is three ActionSpecs, and that is not repetition: each verb has a different input list, a different output, and a different requires. Flattening them would make the catalog say counter takes content — on a read, which it does not — and requires the write scope — on a read, which it must not.

The per-verb view is what everything above the endpoint consumes:

    /// The per-verb view is what the catalog, selection and the agent tool list see.
    #[test]
    fn three_verbs_are_three_actions_and_only_two_require_anything() {
        let actions = counter().describe().action_specs();
        let verbs: Vec<Verb> = actions.iter().map(|a| a.verb).collect();
        assert_eq!(verbs, vec![Verb::Source, Verb::Sink, Verb::Delete]);

        let requiring: Vec<Verb> = actions
            .iter()
            .filter(|a| a.requires.contains(&WRITE.to_string()))
            .map(|a| a.verb)
            .collect();
        assert_eq!(requiring, vec![Verb::Sink, Verb::Delete]);
    }

That is the catalog’s ik:Action nodes, the manifold’s rows, and the agent’s tool list — one per verb, each with its own contract. An agent holding a read-only capability is offered Source on counter and not Sink, because the offer is computed from exactly this.

Declared, therefore enforced

The rule Resolution stated three times is now something you can feel. .requires(WRITE) on the Sink and Delete actions is checked by the kernel, before dispatch: a caller whose capability does not satisfy it never reaches the closure.

    /// The declared capability is the enforced one — per verb.
    #[test]
    fn a_narrow_capability_can_read_but_not_write() {
        let kernel = kernel_in(None);
        // Holds something, but not the write scope.
        let reader = Capability::root().attenuate(["urn:cap:kernel:inspect"]);
        let writer = Capability::root().attenuate([WRITE]);

        assert_eq!(issue(&kernel, Verb::Source, None, &reader).unwrap(), "0");

        let denied = issue(&kernel, Verb::Sink, Some("7"), &reader).unwrap_err();
        assert!(matches!(denied, Error::Denied(_)), "{denied:?}");
        assert!(
            denied.to_string().contains(WRITE) && denied.to_string().contains(COUNTER),
            "the message names the scope and the declarer: {denied}"
        );

        // Same request, a capability that grants the scope: the write lands, and the
        // reader — still narrow — sees it, because the Sink cut the counter's thread.
        assert_eq!(
            issue(&kernel, Verb::Sink, Some("7"), &writer).unwrap(),
            "ok"
        );
        assert_eq!(issue(&kernel, Verb::Source, None, &reader).unwrap(), "7");

        let denied = issue(&kernel, Verb::Delete, None, &reader).unwrap_err();
        assert!(matches!(denied, Error::Denied(_)), "{denied:?}");
        assert_eq!(
            issue(&kernel, Verb::Delete, None, &writer).unwrap(),
            "reset"
        );
        assert_eq!(issue(&kernel, Verb::Source, None, &reader).unwrap(), "0");
    }

Four things in that test, in order. A narrow capability reads, because Source declared nothing. The same capability’s write is refused, with a message naming both the scope it lacked and the resource that declared it. A capability that holds the scope writes — and the narrow reader then sees 7, because the Sink cut counter’s golden thread and the reader’s cached 0 went with it. And Delete follows the same rule as Sink, because it declared the same requirement.

Now look at the closure again. There is no capability check in it. There is deliberately no capability check in it, because a check the catalog does not know about is the mirror-image lie: an action that enforces authority it never declared fails callers the catalog told were fine. The description is the only place authority is stated, and the kernel is the only place it is checked — which is what keeps the offer, the pre-flight and the enforcement from ever disagreeing.

⚠ The two authoring forms are exclusive per verb. Had counter also carried a flat .requires(..) on its description, that scope would be dead for every verb with an explicit ActionSpec — an explicit action’s requires replaces the flat one, it does not add to it. Restate on each action what it needs; Who is asking shows the normalized view that decides.

⚠ Delete .requires(WRITE) from the Sink action and run the test. The write goes through for the narrow reader, and no test in the crate but this one notices — the code is unchanged and correct. Declared = enforced means the declaration is the enforcement, and a description is code you have to read with that in mind.

What this is not

It is not a module boundary — though that distinction has narrowed. Where this actually stands records that an endpoint reached through a module has had its declared requires enforced by the host since ikigai-module 0.2.0, and that since 0.3.0 the module’s own descriptions cross at connect time, so the host gates against them exactly as it gates a linked-in one. Everything on this page is about the linked-in case, where the kernel resolving the name is the kernel enforcing the floor with nothing in between. That is the 95% case, and it is the case this book stands you in.

Try it

#![allow(unused)]
fn main() {
extern crate building_endpoints;
extern crate ikigai_core;
extern crate futures;
use futures::executor::block_on;
use ikigai_core::{ArgRef, Capability, Error, Iri, Request, Verb};

let kernel = building_endpoints::kernel_in(None);
let counter = Iri::parse("urn:iki:tutorial:counter").unwrap();
let reader = Capability::root().attenuate(["urn:cap:kernel:inspect"]);

// Reads under a narrow capability.
let repr = block_on(kernel.issue(Request::new(Verb::Source, counter.clone()), &reader)).unwrap();
assert_eq!(String::from_utf8_lossy(&repr.bytes), "0");

// Writes do not: the kernel refuses before the endpoint runs.
let write = Request::new(Verb::Sink, counter)
    .with_arg("content", ArgRef::Inline(b"3".to_vec()));
let err = block_on(kernel.issue(write, &reader)).unwrap_err();
assert!(matches!(err, Error::Denied(_)));
assert!(err.to_string().contains("urn:cap:tutorial:counter:write"));
}

The graph face

What resolution buys you ended with the catalog as one Turtle graph, and Why an endpoint describes itself claimed that “what can I do with these things?” is a query over it. This chapter runs the query. It is the shortest chapter in the part, because the RDF audience already knows what happens next and everybody else only needs to see that it does.

Two endpoints, no new code

Nothing is written here. urn:kernel:catalog already answers as Turtle. The published ikigai-sparql crate binds urn:sparql:select (and ask, construct, describe), and this part’s host mounts its space beside Part I’s — the Fallback in building_endpoints::space(). The SPARQL endpoint holds no store of its own: its graph argument names a resource, and it resolves that resource through the kernel and queries whatever came back.

So the graph you query is the catalog, fetched the ordinary way, by name.

One SELECT over your own endpoints

“Which endpoints take a string?” — asked of the graph:

/// The `ik:id` of every endpoint in `kernel`'s catalog that takes an argument of RDF
/// class `class` — "what can I do with a string?", asked of the graph.
///
/// `graph=urn:kernel:catalog` is the whole trick: the SPARQL endpoint does not hold a
/// store, it *resolves* the graph you name. Any resource that answers as Turtle would do.
///
/// The property path `ik:input | ik:action/ik:input` matters. A single-verb endpoint's
/// inputs hang off the endpoint node; an endpoint with per-verb `ActionSpec`s declares
/// each verb's inputs on that verb's *action* node. Ask only the first and every
/// multi-verb endpoint is invisible to the question.
pub async fn endpoints_taking(kernel: &Kernel, class: &str) -> Result<Vec<String>> {
    let query = format!(
        "PREFIX ik: <https://ikigai-rs.dev/ns#>
         SELECT DISTINCT ?id WHERE {{
           ?endpoint a ik:Endpoint ; ik:id ?id ; (ik:input | ik:action/ik:input) ?input .
           ?input ik:class <{class}> .
         }} ORDER BY ?id"
    );
    let request = Request::new(
        Verb::Source,
        Iri::parse("urn:sparql:select").expect("a constant IRI"),
    )
    .with_arg("graph", ArgRef::Inline(b"urn:kernel:catalog".to_vec()))
    .with_arg("query", ArgRef::Inline(query.into_bytes()))
    .with_arg("as", ArgRef::Inline(b"text/csv".to_vec()));

    let repr = kernel.issue(request, &Capability::root()).await?;
    let csv = String::from_utf8_lossy(&repr.bytes);
    // One column, so each line after the header is an id.
    Ok(csv
        .lines()
        .skip(1)
        .map(|line| line.trim().to_string())
        .collect())
}

And the test that asks it of this host:

    /// One SELECT over the reader's own endpoints.
    #[test]
    fn the_endpoints_that_take_a_string_are_the_ones_that_declared_it() {
        let kernel = kernel_in(None);
        let ids = block_on(endpoints_taking(
            &kernel,
            "http://www.w3.org/2001/XMLSchema#string",
        ))
        .expect("the catalog is a graph and the graph answers");

        // Declared `.class(xsd:string)` on an input, and found by that declaration.
        assert!(ids.contains(&"camel-case".to_string()), "{ids:?}");
        assert!(ids.contains(&"title".to_string()), "{ids:?}");
        // Declared a different class, and correctly absent.
        assert!(!ids.contains(&"counter".to_string()), "{ids:?}");
        // Declared no class at all: an input with no `class` is invisible to this
        // question, which is the argument for declaring one.
        assert!(!ids.contains(&"markdown".to_string()), "{ids:?}");
    }
cargo run -p building-endpoints -- --strings
camel-case
conditional
each
echo
greet
reverseList
sparql-ask
sparql-construct
sparql-describe
sparql-select
split
title
toUpper
wrap

The host asked is the whole of this part’s space: its own endpoints, Part I’s (and the ikigai-fn functions Part I chains in), and the SPARQL face. Every one of them that declared a string input is in the answer, and that is the question working, not noise.

Three things that test pins down are the chapter.

Declaring class is what made the answer possible. camel-case and title declared .class(xsd:string) on an input, so they are in the result. counter declared xsd:nonNegativeInteger, so it is correctly absent. markdown declared no class at all — and it is invisible to the question, not because it takes something else but because it never said what it takes. That is Why an endpoint describes itself, as a query result.

The property path is not decoration. (ik:input | ik:action/ik:input) walks two shapes. A single-verb endpoint’s inputs hang off the endpoint node; an endpoint with per-verb ActionSpecs — title, counter — declares each verb’s inputs on that verb’s action node. Ask only ik:input and every multi-verb endpoint drops out of the answer silently. (The first draft of this chapter did exactly that, and the test caught it.)

The answer is a representation. as=text/csv chose it; application/sparql-results+json is the default, and CONSTRUCT would have come back as Turtle. A query result is a resolution like any other, with a type you asked for.

From a shell

The CLI links the same crate, so the same question works against its much larger catalog:

ikigai --plain -c 'source urn:sparql:select graph=urn:kernel:catalog query="PREFIX ik: <https://ikigai-rs.dev/ns#> SELECT DISTINCT ?id WHERE { ?e a ik:Endpoint ; ik:id ?id ; (ik:input | ik:action/ik:input) ?i . ?i ik:class <http://www.w3.org/2001/XMLSchema#string> } ORDER BY ?id" as=text/csv'

⚠ The ikigai vocabulary is always loaded beside whatever graph names — the query above could have joined ik:Endpoint to its rdfs:subClassOf relatives — and a transreptor shows up as both ik:Endpoint and ik:Transreptor, typed explicitly so a consumer that does not reason over the subclass axiom still finds it. If a query over the catalog returns nothing where you expected something, the first things to check are the property path above and whether the endpoint declared a class at all.

Try it

#![allow(unused)]
fn main() {
extern crate building_endpoints;
extern crate ikigai_core;
extern crate futures;
use futures::executor::block_on;
use ikigai_core::{ArgRef, Capability, Iri, Request, Verb};

let kernel = building_endpoints::kernel_in(None);

// ASK: does anything in this host convert to Markdown?
let request = Request::new(Verb::Source, Iri::parse("urn:sparql:ask").unwrap())
    .with_arg("graph", ArgRef::Inline(b"urn:kernel:catalog".to_vec()))
    .with_arg("query", ArgRef::Inline(
        b"PREFIX ik: <https://ikigai-rs.dev/ns#> ASK { ?t a ik:Transreptor ; ik:transreptsTo \"text/markdown\" }".to_vec(),
    ))
    .with_arg("as", ArgRef::Inline(b"text/csv".to_vec()));

let repr = block_on(kernel.issue(request, &Capability::root())).unwrap();
assert_eq!(String::from_utf8_lossy(&repr.bytes).trim(), "true");
}

Checking a resource

Transreption ended on a rule: the format is a property of the route, not of the resource. Follow it one step further. If a route reaches Turtle, the resource has a Turtle face, whatever it was written in, and anything with a Turtle face can be checked: does it parse, and does what it says fit the shapes it is supposed to fit. This chapter calls that a CHECK. The RDF Toolbox (ikigai-toolbox) is the page that does it in a browser, and this chapter builds what the page reads and how it reads it.

The code is crates/check-resource. Unlike the other chapters of this part, it does not chain onto Part I’s space: its host holds two players of tic-tac-toe, a transreptor, the shapes a player must fit, and two published spaces beside them. Nothing in it starts a server, so there is nothing to stop afterwards.

/// The chapter's host: its own doors, then the toolbox's names, then the shapes check.
pub fn space() -> Fallback {
    let ours = EndpointSpace::new()
        .bind(Exact::new(PLAYER_X), player("player-x", X_TRIPLES))
        .bind(Exact::new(PLAYER_O), player("player-o", O_TRIPLES))
        .bind(Exact::new(SHAPES), shapes())
        .bind(Exact::new(TO_TURTLE), to_turtle());
    Fallback::new(vec![
        Arc::new(ours) as Arc<dyn Space>,
        Arc::new(ikigai_toolbox::space()) as Arc<dyn Space>,
        Arc::new(ikigai_shacl::space()) as Arc<dyn Space>,
    ])
}

A resource with no Turtle in it

Each player answers N-Triples, one triple per line, and nothing else. Reading one requires a scope, declared the way Multi-verb endpoints declared the counter’s write: the kernel enforces the requires, so the endpoint’s code holds no check of its own.

/// The two players, as N-Triples. O carries one property the shapes do not name.
const X_TRIPLES: &str = r#"<urn:iki:tutorial:check:player:x> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://example.org/ttt#Player> .
<urn:iki:tutorial:check:player:x> <http://example.org/ttt#name> "Ada" .
<urn:iki:tutorial:check:player:x> <http://example.org/ttt#mark> "X" .
"#;
const O_TRIPLES: &str = r#"<urn:iki:tutorial:check:player:o> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://example.org/ttt#Player> .
<urn:iki:tutorial:check:player:o> <http://example.org/ttt#name> "Grace" .
<urn:iki:tutorial:check:player:o> <http://example.org/ttt#mark> "O" .
<urn:iki:tutorial:check:player:o> <http://example.org/ttt#wins> "3" .
"#;

/// A player: a graph in N-Triples, readable only by a caller holding [`READ`]. Nothing in
/// it knows Turtle exists.
fn player(id: &'static str, triples: &'static str) -> FnEndpoint {
    FnEndpoint::new(id, move |_: &Invocation<'_>| {
        Ok(Representation::new(ReprType::new(N_TRIPLES), triples.as_bytes().to_vec()).cacheable())
    })
    .with_description(
        Description::new(id)
            .title("A player")
            .summary("One player of tic-tac-toe, as an RDF graph in N-Triples.")
            .verb(Verb::Source)
            .verb(Verb::Meta)
            .output(N_TRIPLES)
            .requires(READ),
    )
}

The Turtle face comes from a transreptor, declared lossless because every triple survives the trip. It is the kind of endpoint Transreption wrote, in the direction that reaches the pivot:

/// A transreptor from N-Triples to Turtle: the same triples, written with prefixes.
/// Lossless (every triple survives), so it is declared with no `.lossy()`.
pub fn to_turtle() -> FnEndpoint {
    FnEndpoint::new("to-turtle", |inv: &Invocation<'_>| {
        let content = inv.inline_str("content")?;
        let invalid = |detail: String| Error::InvalidArgument {
            name: "content".into(),
            detail,
        };
        let mut writer = oxttl::TurtleSerializer::new()
            .with_prefix("ttt", "http://example.org/ttt#")
            .map_err(|e| Error::Endpoint(e.to_string()))?
            .for_writer(Vec::new());
        for triple in oxttl::NTriplesParser::new().for_slice(content.as_bytes()) {
            let triple = triple.map_err(|e| invalid(e.to_string()))?;
            writer
                .serialize_triple(&triple)
                .map_err(|e| Error::Endpoint(e.to_string()))?;
        }
        let turtle = writer
            .finish()
            .map_err(|e| Error::Endpoint(e.to_string()))?;
        Ok(Representation::new(ReprType::new(TURTLE), turtle).cacheable())
    })
    .with_description(
        Description::new("to-turtle")
            .title("N-Triples to Turtle")
            .summary("The same triples, written as Turtle.")
            .verb(Verb::Source)
            .verb(Verb::Meta)
            .input(ArgSpec::new("content").summary("the N-Triples document"))
            .input(
                ArgSpec::new("as")
                    .summary("the target type; only text/turtle")
                    .optional(),
            )
            .output(TURTLE)
            .transreptor([N_TRIPLES], [TURTLE]),
    )
}

The check reads as you

The toolbox reads a resource through one name, urn:toolbox:input:{iri}: the resource sourced with as=text/turtle, and when it answers in another RDF syntax, carried to Turtle by whatever transreptor plan the kernel finds. The name declares no capability of its own. It issues that read under the caller’s capability, so a check never shows a reader more than they could read directly, and the toolbox is not a way around a scope.

/// A resource's Turtle face, as the toolbox page reads it: `urn:toolbox:input:{iri}`,
/// issued under the caller's own capability.
pub async fn turtle_face(kernel: &Kernel, iri: &str, caller: &Capability) -> Result<String> {
    let input = Iri::parse(format!("urn:toolbox:input:{iri}"))
        .map_err(|e| Error::Endpoint(format!("`{iri}` makes no input name: {e}")))?;
    let face = kernel
        .issue(Request::new(Verb::Source, input), caller)
        .await?;
    String::from_utf8(face.bytes).map_err(|e| Error::Endpoint(format!("not UTF-8: {e}")))
}
#![allow(unused)]
fn main() {
extern crate check_resource;
extern crate ikigai_core;
extern crate futures;
use futures::executor::block_on;
use ikigai_core::{Capability, Error};
use check_resource::{kernel, turtle_face, PLAYER_X, READ};

let kernel = kernel();

// A reader holding the scope gets the player's Turtle face: N-Triples in, Turtle out,
// and nothing in the player knows Turtle exists.
let reader = Capability::scoped([READ]);
let face = block_on(turtle_face(&kernel, PLAYER_X, &reader)).unwrap();
assert_eq!(
    face,
    "@prefix ttt: <http://example.org/ttt#> .\n\
     <urn:iki:tutorial:check:player:x> a ttt:Player ;\n\
     \tttt:name \"Ada\" ;\n\
     \tttt:mark \"X\" .\n"
);

// A caller without it is refused, and the refusal names the player, not the toolbox.
let stranger = Capability::scoped(["urn:cap:tutorial:counter:write"]);
let refused = block_on(turtle_face(&kernel, PLAYER_X, &stranger)).unwrap_err();
assert!(matches!(refused, Error::Denied(_)));
assert_eq!(
    refused.to_string(),
    "denied: capability does not grant `urn:cap:tutorial:players:read` \
     (declared by `urn:iki:tutorial:check:player:x`)"
);
}

This page runs the same two reads. Its kernel holds the chapter’s host beside the spaces the book’s other chapters run in, and cap narrows the page’s session the way it narrows a shell’s, until cap reset gives the authority back. Run it, then run it again: the reader’s face comes from the cache the second time, because the cache keys on the capability as well as the name.

narrowed — capability: urn:cap:tutorial:players:read
@prefix ttt: <http://example.org/ttt#> .
<urn:iki:tutorial:check:player:x> a ttt:Player ;
	ttt:name "Ada" ;
	ttt:mark "X" .
[computed]
reset to identity — capability: root (full authority)
narrowed — capability: urn:cap:tutorial:counter:write
error: denied: capability does not grant `urn:cap:tutorial:players:read` (declared by `urn:iki:tutorial:check:player:x`)
reset to identity — capability: root (full authority)

Shapes, in a paragraph

SHACL describes what a graph should look like, in a graph of its own. A node shape picks the nodes it applies to (here, sh:targetClass: every ttt:Player), and its property shapes constrain what hangs off each one: how many values a predicate may have, of which datatype, from which list. A shape is closed when it also says sh:closed true: then a node may carry only the predicates its property shapes name, and those listed in sh:ignoredProperties, and any other triple is a violation. An open shape lets a stray property through unremarked; a closed one turns it into a finding. That is why ikigai-layers requires sh:closed true on every node shape of a layer: an entry carries exactly what its layer declared, and a property nobody declared is refused rather than kept. The player shapes are closed the same way:

/// One closed node shape: a player has exactly one name and exactly one mark, X or O,
/// and nothing else but its type.
pub const PLAYER_SHAPES: &str = r#"@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix sh:  <http://www.w3.org/ns/shacl#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
@prefix ttt: <http://example.org/ttt#> .

ttt:PlayerShape a sh:NodeShape ;
    sh:targetClass ttt:Player ;
    sh:closed true ;
    sh:ignoredProperties ( rdf:type ) ;
    sh:property [ sh:path ttt:name ; sh:datatype xsd:string ; sh:minCount 1 ; sh:maxCount 1 ] ;
    sh:property [ sh:path ttt:mark ; sh:in ( "X" "O" ) ; sh:minCount 1 ; sh:maxCount 1 ] .
"#;

Checking data against shapes

The toolbox page takes a second input for this (shapes=), read exactly like the first, and checks one against the other. The page does the checking in your browser tab, with turtle-lsp compiled to WebAssembly, and no Rust listing can run a browser tab. So here the shapes check runs on the host instead, through urn:shacl:validate from ikigai-shacl. The inputs are the same and are read the same way, as Turtle faces under one caller’s capability, but the engine is a different one:

/// CHECK `iri` against the shapes at `shapes`: both read as Turtle faces under `caller`'s
/// capability, the way the check page reads its two inputs, then `urn:shacl:validate`.
pub async fn check(
    kernel: &Kernel,
    iri: &str,
    shapes: &str,
    caller: &Capability,
) -> Result<Report> {
    let data = turtle_face(kernel, iri, caller).await?;
    let shapes = turtle_face(kernel, shapes, caller).await?;
    let validate = Request::new(
        Verb::Source,
        Iri::parse("urn:shacl:validate").expect("an IRI"),
    )
    .with_arg("data", ArgRef::Inline(data.into_bytes()))
    .with_arg("shapes", ArgRef::Inline(shapes.into_bytes()))
    .with_arg("as", ArgRef::Inline(b"application/json".to_vec()));
    let report = kernel.issue(validate, caller).await?;
    serde_json::from_slice(&report.bytes).map_err(|e| Error::Endpoint(format!("report: {e}")))
}
#![allow(unused)]
fn main() {
extern crate check_resource;
extern crate ikigai_core;
extern crate futures;
use futures::executor::block_on;
use ikigai_core::Capability;
use check_resource::{check, kernel, PLAYER_O, PLAYER_X, READ, SHAPES};

let kernel = kernel();
let reader = Capability::scoped([READ]);

// X has a name, a mark and a type, and nothing else: it conforms.
let x = block_on(check(&kernel, PLAYER_X, SHAPES, &reader)).unwrap();
assert!(x.conforms);

// O carries `ttt:wins`, which the closed shape does not name: one violation.
let o = block_on(check(&kernel, PLAYER_O, SHAPES, &reader)).unwrap();
assert!(!o.conforms);
let found = &o.outcome().violations[..];
assert_eq!(found.len(), 1);
assert_eq!(found[0].focus_node, PLAYER_O);
assert_eq!(found[0].path.as_deref(), Some("http://example.org/ttt#wins"));
assert_eq!(found[0].component, "http://www.w3.org/ns/shacl#ClosedConstraintComponent");
}

The same check runs in this page, written the way a shell writes it: the Turtle face piped into urn:shacl:validate, where it fills data, the one required input left unnamed. X conforms. O’s report is the graph the paragraph below describes, and its one result names the property: sh:resultPath ttt:wins, from sh:ClosedConstraintComponent. The toolbox’s own engine checks the same two players further down, in The page itself.

{
  "conforms": true,
  "violations": []
}
[2 computed]
@prefix sh: <http://www.w3.org/ns/shacl#> .
<urn:ikigai:shacl:report:b3:1258eb4246e33211b9d99fd84328c8723a8aae4c2627e1c1973fca3041184544:report> a sh:ValidationReport ;
	sh:conforms false ;
	sh:result <urn:ikigai:shacl:report:b3:1258eb4246e33211b9d99fd84328c8723a8aae4c2627e1c1973fca3041184544:result.0> .
<urn:ikigai:shacl:report:b3:1258eb4246e33211b9d99fd84328c8723a8aae4c2627e1c1973fca3041184544:result.0> a sh:ValidationResult ;
	sh:focusNode <urn:iki:tutorial:check:player:o> ;
	sh:resultMessage "Closed constraint not satisfied" ;
	sh:resultPath <http://example.org/ttt#wins> ;
	sh:resultSeverity sh:Violation ;
	sh:sourceConstraintComponent sh:ClosedConstraintComponent ;
	sh:sourceShape <http://example.org/ttt#PlayerShape> ;
	sh:value "3" .
[2 computed]

The report is data, like every other answer in this book: as=application/json gives the structure above, and the default, text/turtle, is the same findings as a SHACL validation report graph you can query.

ikigai-shacl is linked here with default-features = false, which builds its subset validator: closed shapes and the common constraints, with no network stack. A term outside the subset is refused by name rather than skipped. The default build links rudof and covers all of SHACL Core.

The page itself

On a host that serves the toolbox, the check is one link. urn:toolbox:check:{iri} is the page checking that resource, shapes= adds the shapes, and a host routes /toolbox/check/{iri} to it, so the check of player O against the player shapes is:

/toolbox/check/urn%3Aiki%3Atutorial%3Acheck%3Aplayer%3Ao?shapes=urn%3Aiki%3Atutorial%3Acheck%3Ashapes#shacl

The page reads both inputs through urn:toolbox:input:{iri}, as your own browser session, so what you saw above is what it sees. What the page needs that this crate does not have is the build: the page, its scripts and the 8.3 MB engine, which ikigai-toolbox’s build.sh writes to a directory a host binds at urn:iki:toolbox:dist:{path}. This crate binds no build, so asking it for the page fails on the first file the page is composed from:

#![allow(unused)]
fn main() {
extern crate check_resource;
extern crate ikigai_core;
extern crate futures;
use futures::executor::block_on;
use ikigai_core::{Capability, Error, Iri, Request, Verb};

let page = Iri::parse("urn:toolbox:check:urn:iki:tutorial:check:player:o").unwrap();
let err = block_on(check_resource::kernel().issue(Request::new(Verb::Source, page), &Capability::root()))
    .unwrap_err();
assert!(matches!(err, Error::Unresolved(ref name) if name.as_str() == "urn:iki:toolbox:dist:page.template.html"));
}

This book does bind one. It publishes a build of ikigai-toolbox 0.1.0 beside its pages, in toolbox/, and the page’s kernel binds urn:iki:toolbox:dist:{path} to it: a door that asks the browser for toolbox/{path} when, and only when, a name needs one of its files.

/// Where the toolbox reads its build, `urn:iki:toolbox:dist:{path}` (`ikigai_toolbox::DIST`):
/// in the page, the published copy of `ikigai-toolbox`'s `build.sh` output, which pages.yml
/// puts in `toolbox/` beside the book's root and which the browser fetches only when a name
/// asks for one of its files. So `urn:toolbox:check:{iri}` composes in the page, from the
/// build's own template, and nothing is fetched until a reader opens a check.
///
/// The kernel's futures must be `Send` and a browser fetch is not, so the fetch runs as a task
/// of the page's own (`spawn_local`) and hands its answer back over a channel, which is.
#[cfg(target_family = "wasm")]
fn dist_space() -> Arc<dyn Space> {
    use ikigai_core::{AsyncFnEndpoint, Description, EndpointSpace, InvokeFuture, UriTemplate};
    let fetched = AsyncFnEndpoint::new(
        "book-toolbox-dist",
        |inv: &ikigai_core::Invocation<'_>| -> InvokeFuture<'_> {
            let path = inv.bindings.get("path").unwrap_or_default().to_string();
            let (answer, answered) = futures::channel::oneshot::channel();
            wasm_bindgen_futures::spawn_local(async move {
                let _ = answer.send(fetch_published(&path).await);
            });
            Box::pin(async move {
                answered
                    .await
                    .unwrap_or_else(|_| Err(Error::Endpoint("the fetch was dropped".into())))
            })
        },
    )
    .with_description(
        Description::new("book-toolbox-dist")
            .title("The toolbox build this book publishes")
            .summary(
                "One file of the RDF Toolbox build published beside this book (`toolbox/`), \
                 fetched by the browser when it is asked for.",
            )
            .verb(Verb::Source)
            .verb(Verb::Meta)
            .output("application/octet-stream"),
    );
    Arc::new(EndpointSpace::new().bind(
        UriTemplate::parse("urn:iki:toolbox:dist:{path}").expect("a constant template"),
        fetched,
    ))
}

/// Built natively there is no published build to fetch, so nothing is bound there: a check
/// page fails on its template exactly as the chapter's own host's does.
#[cfg(not(target_family = "wasm"))]
fn dist_space() -> Arc<dyn Space> {
    Arc::new(ikigai_core::EndpointSpace::new())
}

/// The bytes of `toolbox/{path}` beside the book's root, as the browser fetches them. A name
/// that is not one plain file name is not found without a request: the toolbox asks only for
/// the files on its allowlist, and nothing else under the site is the dist's to serve.
#[cfg(target_family = "wasm")]
async fn fetch_published(path: &str) -> Result<Representation, Error> {
    use js_sys::{Function, Promise, Reflect, Uint8Array};
    use wasm_bindgen_futures::JsFuture;
    #[wasm_bindgen]
    extern "C" {
        #[wasm_bindgen(js_name = fetch)]
        fn fetch_url(url: &str) -> Promise;
    }
    let plain = !path.is_empty()
        && !path.starts_with('.')
        && path
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'));
    if !plain {
        return Err(Error::NotFound(format!(
            "urn:iki:toolbox:dist:{path}: not a file of the published build"
        )));
    }
    let failed = |e: JsValue| Error::Endpoint(format!("toolbox/{path}: {e:?}"));
    // Where the build is published, as `js/toolbox.js` says (`window.ikigaiToolboxBase`, the
    // absolute path of `toolbox/` beside the book's root). mdbook's `path_to_root` would do,
    // but it is a `const` in the page's head, which is not a property of the global object.
    let base = Reflect::get(&js_sys::global(), &JsValue::from_str("ikigaiToolboxBase"))
        .ok()
        .and_then(|v| v.as_string())
        .ok_or_else(|| {
            Error::NotFound(format!(
                "urn:iki:toolbox:dist:{path}: this page does not say where the toolbox build is \
                 published (js/toolbox.js sets window.ikigaiToolboxBase)"
            ))
        })?;
    let url = format!("{base}{path}");
    let response = JsFuture::from(fetch_url(&url)).await.map_err(failed)?;
    let field = |name: &str| Reflect::get(&response, &JsValue::from_str(name));
    if !field("ok").map_err(failed)?.as_bool().unwrap_or(false) {
        let status = field("status").map_err(failed)?.as_f64().unwrap_or(0.0);
        return Err(Error::NotFound(format!("{url}: HTTP {status}")));
    }
    let read: Function = field("arrayBuffer")
        .map_err(failed)?
        .dyn_into()
        .map_err(failed)?;
    let pending: Promise = read
        .call0(&response)
        .map_err(failed)?
        .dyn_into()
        .map_err(failed)?;
    let buffer = JsFuture::from(pending).await.map_err(failed)?;
    Ok(Representation::new(
        ikigai_core::ReprType::new("application/octet-stream"),
        Uint8Array::new(&buffer).to_vec(),
    ))
}

So the page is a host too. Pressing a “Check … here” button below asks this page’s kernel for urn:toolbox:check:{iri} with shapes=, which composes the toolbox page from the build’s own template. The page then does what a server’s routing would do: it points the composed page’s /toolbox/… files at the published build, and answers its two inputs, /toolbox/input/{iri}, with urn:toolbox:input:{iri} from the same kernel. The toolbox runs in a panel that opens in place, and its engine checks the two Turtle faces in your tab. Nothing of the toolbox is loaded until you press one: the first press fetches its scripts and the 8.3 MB engine, and Close (or Escape) puts focus back on the button.

Check player O against the player shapes in the RDF Toolbox, in a new tab, on copies of the two Turtle faces published with this page.

Check player X against the player shapes in the RDF Toolbox, in a new tab, on copies of the two Turtle faces published with this page.

The SHACL tab of O’s check shows the same finding as the cell above, worded by turtle-lsp. In the page, you hold the root of your own kernel, so the panel reads both players. Without the kernel (it did not load, or scripts are off), each button is a link instead, and the toolbox opens in a new tab on copies of the Turtle faces published beside this chapter. A test holds those copies equal to what the kernel answers a reader who holds the scope, but a copy is not a read: nothing checks your capability on a file anyone can download.

gonk, the host The gonk Book is about, serves the page at /toolbox when its configuration names a build (gonk.toolbox.dist).

A CHECK is not urn:kernel:validate

The kernel has a name that sounds like this chapter and is not: urn:kernel:validate. It checks a request against the contract of the action it would invoke, before anything runs: required inputs present, values from the declared lists, no unknown arguments, the capability sufficient. A CHECK reads a resource that already exists and asks whether what it says fits a set of shapes. One asks “may this call run as written?”, the other “is this data what it should be?”. Both answer in SHACL’s report vocabulary, which is where the confusion starts: the focus of a urn:kernel:validate result is an action’s contract, and the focus of a CHECK result is a node in your data. The machine client, later in the book, puts urn:kernel:validate to work as the last step before an agent’s call runs. Here it is pre-flighting a call to urn:shacl:validate that forgot its data and asked for a face the endpoint does not have:

#![allow(unused)]
fn main() {
extern crate check_resource;
extern crate ikigai_core;
extern crate futures;
use futures::executor::block_on;
use ikigai_core::{ArgRef, Capability, Iri, Request, Verb};

let preflight = Request::new(Verb::Source, Iri::parse("urn:kernel:validate").unwrap())
    .with_arg("endpoint", ArgRef::Inline(b"urn:shacl:validate".to_vec()))
    .with_arg("verb", ArgRef::Inline(b"source".to_vec()))
    .with_arg("args", ArgRef::Inline(b"shapes=urn:iki:tutorial:check:shapes&as=text/csv".to_vec()));
let report = block_on(check_resource::kernel().issue(preflight, &Capability::root())).unwrap();
let report = String::from_utf8(report.bytes).unwrap();

assert!(report.contains("sh:conforms false"));
assert!(report.contains("required input `data` is missing"));
assert!(report.contains(
    "`text/csv` is not an accepted value of `as` (one of: text/turtle, application/json)"
));
}

Nothing was validated against shapes there, and no player was read: the request never ran.

What not to assume

  • Three places run a check here, and they are not the same. The listings run under mdbook test, on the host side. The cells run in your tab, in the page’s kernel, with ikigai-shacl’s subset. The panel runs the toolbox page and its engine in your tab, served by the page as its host.
  • Two engines, one question. The page’s SHACL engine is turtle-lsp’s; the listings’ is ikigai-shacl’s subset. Both check SHACL Core’s closed shapes, but they are different code, and their messages differ even where their findings agree.
  • A check sees what you may read. A reader who cannot read a resource cannot check it, and the refusal is the resource’s, as above. That is the design, not a gap.

A caller holding some other scope checks nothing, and the refusal comes from the player, before any shapes are read:

narrowed — capability: urn:cap:tutorial:counter:write
error: denied: capability does not grant `urn:cap:tutorial:players:read` (declared by `urn:iki:tutorial:check:player:o`)
reset to identity — capability: root (full authority)

Testing an endpoint hermetically

Exercise 4 in Part I asked you to write a clock and cache it wrongly, and the hint said the time should come from inv.now() rather than SystemTime::now(). This chapter is the why, and the how: an endpoint that reads the clock, and three tests that never touch one.

The design note this follows is docs/design/hermetic-endpoint-tests.md in ikigai-core. Its diagnosis is worth carrying around: a test written around an ambient read rather than of it asserts only shapes — is_string(), is_number() — because it does not own the value. It passes, it stays passing, and it is not evidence about the endpoint.

The endpoint

This part’s stamp reads only the clock. It is not the two-faced stamp sketched in Scope and alias, which read the time two ways to show a corridor pinning it.

/// The current time in milliseconds since the epoch, per the kernel's clock.
///
/// `inv.now()` is `None` when nothing injected a clock. That is a legal state and it is
/// **not** papered over with `SystemTime::now()`: reading the wall clock behind the
/// caller's back is what would make this resolution unrepeatable, and `std::time` is not
/// there on every target the kernel runs on. A host that wants time installs a clock;
/// one that did not gets told so.
///
/// The answer is cacheable *for one second* — `cacheable_until` names a deadline, and a
/// deadline is only meaningful to a kernel that can read a clock to compare it against.
pub fn stamp_impl(inv: &Invocation<'_>) -> Result<Representation> {
    let now = inv
        .now()
        .ok_or_else(|| Error::Endpoint("this host has no clock".to_string()))?;
    Ok(
        Representation::new(text_plain_utf8(), now.as_millis().to_string().into_bytes())
            .cacheable_until(now.plus_millis(1_000)),
    )
}

Two decisions in eight lines.

inv.now() is an Option, and the None arm is an error, not a fallback. Reading the wall clock behind the caller’s back would make the resolution unrepeatable — the same request, a different answer, and nothing in the request to say why — and std::time is not there on every target this kernel runs on. Core reads the system clock in exactly one place, SystemClock, which a host installs by name. An endpoint that wants time asks the kernel; a kernel that has none says so.

cacheable_until names a deadline. That is only meaningful to a kernel that can read a clock to compare it against — so a kernel without one declines to store the entry rather than storing something it could never judge stale.

Three tests, no wall clock

The full one: a real kernel, a FixedClock, and an exact answer the test chose.

    /// A kernel with a fixed clock: the endpoint answers an exact value, and the value
    /// is one the test chose.
    #[test]
    fn under_a_fixed_clock_the_stamp_is_exactly_that_instant() {
        let clock = FixedClock::at(1_700_000_000_000);
        let kernel = Kernel::new(Arc::new(space(None))).with_clock(Arc::new(clock));

        let repr = block_on(kernel.issue(request(), &Capability::root())).expect("resolves");
        assert_eq!(String::from_utf8_lossy(&repr.bytes), "1700000000000");

        // Cacheable until a deadline, and the kernel has a clock to judge it by — so the
        // second read is served, not computed.
        assert!(kernel.is_cached(&request(), &Capability::root()));
    }

The cheap one: no kernel at all. Invocation::detached builds a context with nothing behind it, with_clock attaches the instant, and the implementation is called as a function.

    /// No kernel at all: the implementation, called directly, with the clock attached to
    /// the invocation. Cheapest possible test — and a partial one, which the chapter
    /// says out loud.
    #[test]
    fn a_detached_invocation_can_carry_its_own_clock() {
        let request = request();
        let bindings = Bindings::new();
        let root = Capability::root();
        let inv = Invocation::detached(&request, &bindings, &root)
            .with_clock(Arc::new(FixedClock::at(42)));

        let repr = stamp_impl(&inv).expect("a clock was attached");
        assert_eq!(String::from_utf8_lossy(&repr.bytes), "42");
    }

⚠ A detached invocation skips argument routing and the kernel’s capability floor, so an endpoint tested only this way is tested only in part. The kernel test above is the fuller one, and FixedClock exists so that the fuller one is cheap too. Use the detached form for the arithmetic; keep one kernel test so the contract is exercised.

And the one nobody writes: the branch with no clock anywhere. It is a branch, so it gets a test, and the test pins the honest answer.

    /// The branch a test usually never takes: no clock anywhere. It is a branch, so it
    /// gets a test, and the test pins the honest answer — an error that says why.
    #[test]
    fn without_a_clock_the_stamp_refuses_rather_than_guessing() {
        let kernel = Kernel::new(Arc::new(space(None)));
        let error = block_on(kernel.issue(request(), &Capability::root())).unwrap_err();
        assert!(error.to_string().contains("no clock"), "{error}");
    }

The design note has a number for why that last test matters: one module in the ecosystem had five endpoints reading inv.now() and twenty-three test kernels, none of which installed a clock — so every one of those five had only ever taken its None branch under test. A branch no test has entered is behavior nobody has verified.

FixedClock does not move

Deliberately. A test that needs two instants builds two kernels, each with its own FixedClock — FixedClock::from_time(last.plus_millis(..)) advances a scenario by handing the next kernel a clock derived from the last one’s. A test that needs time to move past a deadline inside one kernel wants a settable clock, and core does not ship one: three crates carry their own AtomicU64-backed version, all correct, none drifted, and that is the bar for pushing a shape down — a count plus observed drift — not met. The note records what would change it.

Try it

#![allow(unused)]
fn main() {
extern crate building_endpoints;
extern crate ikigai_core;
extern crate futures;
use std::sync::Arc;
use futures::executor::block_on;
use ikigai_core::{Capability, FixedClock, Iri, Kernel, Request, Verb};

let kernel = Kernel::new(Arc::new(building_endpoints::space(None)))
    .with_clock(Arc::new(FixedClock::at(1_000)));

let request = Request::new(Verb::Source, Iri::parse("urn:iki:tutorial:stamp").unwrap());
let repr = block_on(kernel.issue(request, &Capability::root())).unwrap();
assert_eq!(String::from_utf8_lossy(&repr.bytes), "1000");
}

What a module is

Part I built an endpoint and linked it in: your crate exposed a space(), the host depended on it at compile time, and the endpoints ended up in the binary. That is how nearly all of ikigai works today, including every endpoint in the ikigai CLI.

This part is about the other shape — a module: an independently compiled space() that a host routes some names to without linking it.

Read this part for one idea

A module is not a remote kernel, and the difference is not a matter of degree.

When you resolve against an IPC or QUIC peer (Part III builds one), that peer resolves every sub-request on its own side. It has its own catalog, its own cache, its own spaces. You hand it a name and it hands you back an answer; nothing crosses back.

A module is the opposite arrangement. Its endpoints must resolve their resource references — an XSLT stylesheet, a SHACL shapes graph, a configuration file — against the host’s kernel, because the host owns the catalog and the cache. So a module has to call back into the host in the middle of its own invocation.

Everything else here is consequences of that one asymmetry.

What you will build

A module with one endpoint, a host with one resource, and a greeting that can only be produced by the module asking the host a question mid-invocation:

cargo run -p loadable-module
host    resolves urn:iki:tutorial:module:greeting name=urn:host:name
module  asks the host for urn:host:name
out     Hello, Peter!

Before you start

This part assumes Part I — what a space() is, and why binding is separate from defining.

The code is in crates/loadable-module, and as in Part I the listings are pulled from it by anchor rather than copied.

⚠ Read Where this actually stands before you plan around any of this. ikigai-module (0.4.0) still describes itself as “Phase 1: in-process proof”. The callback machinery is real and exercised; the isolation is not there yet.

Two ways to reach a kernel

Linked in

The default, and the right default. Your crate exposes a space(); the host takes a Cargo dependency on it and chains its bindings onto its own:

ikigai_fn::space().bind(Exact::new("urn:iki:tutorial:camel-case"), camel_case())

The endpoints are in the binary. Resolution is a function call away. There is no marshaling, no versioning question at runtime, and nothing to go wrong at load time because there is no load time.

Every endpoint in the ikigai CLI arrives this way. The CLI does not depend on ikigai-module at all.

Routed to a module

A module is compiled separately. The host does not name its endpoints and does not know what they are; it routes a prefix of the name space to a transport and lets the module answer:

/// A resource the **host** owns. The module cannot reach this except by asking.
pub fn host_name() -> AsyncFnEndpoint {
    AsyncFnEndpoint::new("host-name", |_inv: &Invocation<'_>| -> InvokeFuture<'_> {
        Box::pin(async move { Ok(Representation::new(text_plain_utf8(), b"Peter".to_vec())) })
    })
    .with_description(
        Description::new("host-name")
            .title("Who the host is")
            .verb(Verb::Source)
            .verb(Verb::Meta),
    )
}

/// The host's root space: its own resources, plus a [`ModuleSpace`] that routes
/// everything under `urn:iki:tutorial:module:` to the module.
///
/// `ModuleSpace` implements `Space`, so it sits in the `Fallback` exactly where a
/// statically linked `space()` would — the host does not have a special case for
/// "modules", it has one more space.
///
/// The third argument is the mount's **floor**: the capability every request through
/// this mount must satisfy before the module is even consulted. `ModuleFloor::public()`
/// is no floor at all, which is right for a demo — and since ikigai-module 0.2.0 it is
/// not the only gate: an endpoint's own declared `requires` is enforced across the
/// boundary too, exactly as it is for a linked-in one. The test below proves that.
pub fn host_space() -> Fallback {
    let module = InProcessTransport::new(module_space());

    Fallback::new(vec![
        Arc::new(EndpointSpace::new().bind(Exact::new("urn:host:name"), host_name()))
            as Arc<dyn Space>,
        Arc::new(ModuleSpace::new(
            ["urn:iki:tutorial:module:"],
            Arc::new(module),
            ModuleFloor::public(),
        )) as Arc<dyn Space>,
    ])
}

Two things to notice, because they are the reason this composes at all.

ModuleSpace implements Space. It goes into the Fallback exactly where a statically linked space() would go. The host has no special case for “modules” — it has one more space, tried in order like the others.

The host binds nothing under urn:iki:tutorial:module:. It owns that prefix — the name is in the book’s own namespace, as Binding, and a host of your own insists — and it has no Exact for urn:iki:tutorial:module:greeting, because it could not write one without knowing what the module offers. Prefix routing is what lets a host delegate a region of a name space it owns to code it has never seen.

When each is right

Link it in when you can. It is simpler, faster, and has fewer failure modes.

Reach for a module when one of these is true:

  • The dependency is expensive and rarely used. ikigai-xslt exists as a module precisely so that its XSLT engine is not linked into every host that will never transform an XML document.
  • The host cannot link it. A kernel running as WebAssembly in a browser page cannot grow a new statically linked space; it can fetch one.
  • The code arrives after the host was built. Which is the whole point, and also the part that is not finished — see Where this actually stands.

The callback

The problem a module has

Give a module an endpoint that transforms a document with a stylesheet. The caller passes stylesheet=urn:file:report.xsl.

The module cannot resolve that. It has no file space, no catalog, no cache, and no capability of its own. The name means something only in the host’s kernel.

So either the host resolves every argument eagerly before dispatching — which would defeat laziness, break Exists, and force it to know which arguments are resource references — or the module asks. It asks.

Where the seam already was

The good news is that nothing had to be invented. An endpoint never touches the kernel directly in the first place; it reaches it through its Invocation, which holds an Issuer. That indirection was already there so that sub-requests could inherit the caller’s capability and be traced.

So a module is simply handed the host as its issuer. From inside the endpoint, the call is the same one you would write in a linked-in endpoint:

/// `urn:iki:tutorial:module:greeting` — the module's one endpoint.
///
/// It takes a `name` argument that is *an IRI naming another resource*, and resolves it.
/// That resolution is the whole point of this book: the module does not own the resource,
/// cannot see it, and has no catalog of its own to find it in. `inv.source(…)` crosses
/// back into the **host's** kernel to get it.
pub fn greeting() -> AsyncFnEndpoint {
    AsyncFnEndpoint::new("greeting", |inv: &Invocation<'_>| -> InvokeFuture<'_> {
        Box::pin(async move {
            let target = inv.inline_str("name")?;
            let iri = Iri::parse(target).map_err(|e| Error::InvalidArgument {
                name: "name".into(),
                detail: format!("not a valid IRI: {e}"),
            })?;

            // ← THE CALLBACK. This is a resolution against the host, from inside the
            //   module, in the middle of the module's own invocation.
            let resolved = inv.source(&iri).await?;
            let who = String::from_utf8_lossy(&resolved.bytes).trim().to_string();

            let greeting = format!("Hello, {who}!");
            Ok(Representation::new(
                text_plain_utf8(),
                greeting.into_bytes(),
            ))
        })
    })
    .with_description(
        Description::new("greeting")
            .title("Greeting")
            .summary("Greets whoever the `name` resource resolves to.")
            .verb(Verb::Source)
            .verb(Verb::Meta),
    )
}

/// The module's space — everything it offers, independent of any host.
pub fn module_space() -> EndpointSpace {
    EndpointSpace::new().bind(Exact::new("urn:iki:tutorial:module:greeting"), greeting())
}

inv.source(&iri) is the callback. The module is in the middle of its own invocation, and that line resolves a name in the host’s kernel — with the host’s spaces, the host’s cache, and the capability the invocation is already carrying.

That last clause matters: the module does not get authority by being a module. It borrows the caller’s, which can only narrow on the way down.

Why this is the interesting property

A remote peer is autonomous: it answers with its own resources. A module is parasitic — deliberately — it contributes endpoints while continuing to live in the host’s world.

That is what makes a module composable in a way a peer is not. The module’s endpoint can take any name the host can resolve, including endpoints from other modules, without knowing any of them exist.

It is also what makes a module harder to isolate than a peer, since the callback is a hole in whatever boundary you put around it. That tension is the subject of Where this actually stands.

A module, end to end

Everything from the previous two chapters, running.

Run it

cargo run -p loadable-module
host    resolves urn:iki:tutorial:module:greeting name=urn:host:name
module  asks the host for urn:host:name
out     Hello, Peter!

Three resolutions happened, in this order:

  1. The host resolved urn:iki:tutorial:module:greeting. Nothing in the host’s own space matched, so the Fallback reached the ModuleSpace, whose prefix urn:iki:tutorial:module: did.
  2. The module’s endpoint ran, and resolved urn:host:name — back through the host.
  3. The host answered Peter from a space the module cannot see.

"Hello, Peter!" is a string that neither side could have produced alone.

Wiring it yourself

#![allow(unused)]
fn main() {
extern crate loadable_module;
extern crate ikigai_core;
extern crate futures;
use std::sync::Arc;
use futures::executor::block_on;
use ikigai_core::{ArgRef, Capability, Iri, Kernel, Request, Verb};

let kernel = Kernel::new(Arc::new(loadable_module::host_space()));

let request = Request::new(
    Verb::Source,
    Iri::parse("urn:iki:tutorial:module:greeting").unwrap(),
)
.with_arg("name", ArgRef::Inline(b"urn:host:name".to_vec()));

let repr = block_on(kernel.issue(request, &Capability::root())).unwrap();
assert_eq!(String::from_utf8_lossy(&repr.bytes), "Hello, Peter!");
}

Note what the caller did not do: it never mentioned a module. From the outside, resolving a module-backed name is indistinguishable from resolving any other name — which is the point of routing being a host concern.

InProcessTransport

The demo uses InProcessTransport, which runs the module in the same process: it resolves the request in the module’s space and invokes the endpoint with the host as its issuer.

That sounds like it is skipping the hard part, and in one sense it is — there is no marshaling. But it exercises the part that actually carries risk: the re-entrancy. The host is inside a resolution, calls the module, and the module calls back into the host before the first resolution has returned. Deadlocks, borrow problems and lock inversions live there, not in the byte format.

Three tests worth reading

The crate’s tests pin the three properties this book claims, and they are short enough to be worth opening:

  • the_module_resolves_a_host_resource_mid_invocation — the callback works. This single assertion is the difference between a module and a peer.
  • the_host_routes_by_prefix_not_by_knowing_the_endpoint — the host bound no urn:iki:tutorial:module:greeting.
  • a_name_outside_the_module_prefix_does_not_reach_it — routing is bounded; the module does not become a catch-all.

The wire session

InProcessTransport proves the semantics. A real module is on the other side of something — another WebAssembly instance, an embedded wasmtime, a socket — and then the conversation has to be encoded.

Two message types, not one

The obvious design is request-in, response-out. That is not enough, because of the callback: the module’s reply might be “I need something first.”

So the session has two enums:

  • ModuleCall — what the host sends: the invocation, and later the results of host calls the module asked for.
  • ModuleReply — what the module sends: the finished representation, an error, or HostCall — “resolve this for me and call me back.” Since ikigai-module 0.3.0 the finished representation can also carry its declared golden threads, and an error crosses with its type (a denial is a denial on the other side too); the host’s replies to a HostCall carry a typed error the same way, and a Cards call at connect time fetches every binding’s description so the host can list and gate the module’s endpoints as its own.

ModuleReply::HostCall is the interesting variant, and it is why this is a session rather than a call. A single invocation can bounce back and forth several times before it completes.

The loopback

LoopbackTransport runs that session through the codec in one process: every message is genuinely encoded and decoded, but nothing leaves the machine.

This is a nice piece of test design worth stealing. It separates two failure modes that would otherwise be tangled:

  • InProcessTransport — does the re-entrancy work? (no encoding involved)
  • LoopbackTransport — does the encoding round-trip? (no transport involved)

UdsTransport — the same session over a Unix socket, the module in its own process — is the third layer on top of those two, and it is why a bug there is a bug in the transport: the other two have their own proofs.

The browser host

The one host that loads modules today is the web demo, and it uses the wasm-facing side of this: WasmModuleSpace, ModuleSessionTransport, and serve_host_call — the last being the module-side loop that receives a HostCall reply, resolves it against the host, and sends back a ModuleCall::HostResult.

If you want to read one real implementation, that is the one that exists.

Dual-mode crates

Three crates in the ecosystem support being modules — ikigai-xslt 0.1.3, ikigai-jsonld 0.1.2 and ikigai-shacl 0.1.2, the versions published as this was re-checked (2026-09-26) — and none of them is module-only. All three are ordinary linked libraries by default (each has a module Cargo feature), and become loadable WebAssembly when you ask:

cargo build --release --lib --features module --target wasm32-unknown-unknown

That needs the target’s standard library (rustup target add wasm32-unknown-unknown) in the rustc cargo actually runs. If the build fails with E0463, “can’t find crate for core”, after you have added the target, another Rust is ahead of rustup’s on PATH — Homebrew’s, for example, which ships no wasm32 standard library. command -v rustc says which one you have; put ~/.cargo/bin first on PATH.

How it is wired

The module feature turns on optional dependencies rather than changing the crate’s identity:

[dependencies.ikigai-module]
version = "0.1.9"   # the floor that builds against the core these crates use
optional = true

[features]
module = [
    "dep:ikigai-module",
    "dep:wasm-bindgen",
    # …
]

The endpoints are the same endpoints either way. What the feature adds is the wasm-bindgen surface that lets a host instantiate the artifact and drive a session against it.

Why dual-mode is the right default

Because linked or loaded should be the host’s decision, not the library’s.

The same argument as binding authority in Part I: a library that can only be a module has decided something on its consumer’s behalf. A CLI that wants XSLT compiled in should be able to have it; a browser page that cannot link anything should be able to fetch it. One crate, two deployments, one set of endpoints.

The one that is module-only, and why

ikigai-xslt-module is a separate crate that wraps ikigai-xslt as a standalone cdylib, and its description states the reason plainly: “Built separately and lazy-loaded by a host, so xrust isn’t linked into the host’s binary.”

That is the honest edge of the dual-mode story. The feature approach still builds one artifact from one crate; when what you want is a separately shipped artifact with its own build profile, a thin wrapper crate is clearer than another feature flag.

It is publish = false — it is a build product, not a library anyone should depend on.

What this means for your own crate

Write it as an ordinary space, the way Part I did. Add the module feature only when somebody actually needs the loadable shape.

Nothing about the endpoint changes; the module feature is packaging.

Where this actually stands

This chapter exists because a tutorial that leaves you to discover the maturity of a feature by hitting its edges has wasted your afternoon.

Every claim below about a repository other than this one carries the version it was true at, because those repositories move on their own schedules and a sentence about them is a claim, not a fact. This book builds against ikigai-core 0.2.2 and ikigai-module 0.5.1, both stamped on 2026-10-27 (each its crate’s newest release on crates.io at its stamp; the module claims below were written at 0.4.0, carry that stamp, and each still holds at 0.5.1), and its shell examples are replayed against ikigai-cli 0.2.6, the one release pinned in books/ikigai/ikigai-cli.version (the newest on 2026-10-29). A test (crates/book-urns/tests/book_claims.rs) holds those three numbers to the workspace’s lock and that pin, and re-checks each stamped claim below against the published crate it names, so that when one of them stops being true the fix is a diff, not a discovery.

Phase 1

ikigai-module 0.4.0’s own first line still calls it “the dynamically-loadable module format (Phase 1: in-process proof)”. That line undersells the crate — it has grown a socket transport, at 0.2.0 capability enforcement across the boundary, and at 0.3.0 typed errors, golden threads and endpoint cards crossing the wire, since somebody wrote it — which is worth knowing as a habit: a crate’s one-line self-description is the thing least likely to be updated when the crate changes.

What is actually there, at ikigai-module 0.4.0 (0.4.0 over 0.3.2 holds a module’s out-of-band call to the host to its session’s capability, fails a call over an unwired channel at once with a typed error, and has the session transport return the kernel’s own Result; 0.3.2 over 0.3.1 records a failed callback, below; 0.3.1 over 0.3.0 was a constructor form, no new surface):

piecestate
the callback machineryproven — InProcessTransport, exercised end to end
the wire sessionproven through the codec — LoopbackTransport encodes and decodes every message
an out-of-process transportbuilt, Unix only — UdsTransport + serve: the module runs in its own process behind a 0600 socket, and serve refuses peers belonging to another user
a module’s declared capabilityenforced since 0.2.0 — ModuleFloor on the mount, and an endpoint’s own requires honored across the boundary (the crate’s test a_module_endpoints_declared_requirement_is_enforced_like_a_linked_ones); since 0.3.0 the module’s cards cross at connect (ModuleCall::Cards), so the host enforces against the module’s own description and the manifold through a ModuleSpace lists the module’s actions rather than one endpoint named module
the error type across the wiresince 0.3.0 — ModuleReply::ErrorTyped and ModuleCall::HostError: a module-side denial arrives at the host as a Denied, a host resource’s NotFound arrives in the module as a NotFound; before that the wire flattened both to a string
golden threads across the wiresince 0.3.0 — ModuleReply::ResolvedThreaded carries a result’s declared threads; before that a module result through the loopback or wasm session was cached forever with nothing to cut it, and a Sink through the mount left it stale. Since 0.3.2 a callback that failed is a dependency on the wasm path too, by core’s rule for a failed sub-request (record_failure): a NotFound or Unresolved hangs the module’s answer from the missing name, any other failure makes it uncacheable; the native transports (in-process, loopback, Unix socket) already had that, through the host’s own issuer
an embedded wasm runtimenot built — no wasmtime in its manifest; a host embedding one is Phase 2
isolationnot there — a process boundary is not a resource budget
hosts that load modulesone, the browser demo (ikigai-web-demo, WasmModuleSpace, as of 2026-10-10)

Read that table the right way

The order is deliberate and it is not the order most projects build in. The semantics came first — re-entrancy, capability inheritance, the session shape — and every transport since has been that same session with a different pipe under it: a direct call, then the codec over in-memory channels, then a socket.

That is the harder half done first. Marshaling bytes over a socket is well-understood work; a host and a module calling into each other mid-invocation without deadlocking, with authority attenuating correctly across the boundary, is where the design risk lives.

What you should not assume

Do not assume a module is a security boundary. The Unix-socket transport buys you a process boundary — a separate address space, and a socket that refuses peers belonging to another user — and that is worth something. It is not a sandbox: a module reached that way is a native binary with all the ambient filesystem and network access its own process has, the callback is a deliberate hole in whatever isolation you would put around it, and the transport this book demonstrates (InProcessTransport) runs the module inside your own process anyway. A module today is a packaging and lazy-loading mechanism.

Do assume the host enforces what a module declares — since ikigai-module 0.2.0, and not before. At 0.1.10 an endpoint reached through ModuleSpace could declare requires("urn:cap:…") and be invoked by a caller who did not hold it; this book said so, in this paragraph. 0.2.0 closed that: ModuleSpace::new takes a third argument, the mount’s ModuleFloor (every request through the mount must satisfy it), and an endpoint’s own declaration is enforced across the boundary exactly as it is for a linked-in one. Authority attenuates correctly across the boundary as it always did (the module’s callbacks run under the caller’s capability, narrowed — exercise 3 below), and now the module’s own card is a gate too. At 0.3.0 the card itself crosses the wire when the host connects, so the enforcement is host-side against the module’s own description rather than against a floor the host wrote for it. The test that keeps this sentence true:

    /// A module endpoint's declared `requires` is enforced across the boundary — since
    /// ikigai-module 0.2.0. Before it, this declaration was ignored by the host, and the
    /// book said so; the claim flipped when the crate did, and this test is what keeps
    /// the chapter's sentence true.
    #[test]
    fn the_host_enforces_what_the_module_declares() {
        let gated = AsyncFnEndpoint::new(
            "gated-resource",
            |_inv: &Invocation<'_>| -> InvokeFuture<'_> {
                Box::pin(async move { Ok(Representation::new(text_plain_utf8(), b"in".to_vec())) })
            },
        )
        .with_description(
            Description::new("gated-resource")
                .verb(Verb::Source)
                .requires("urn:cap:demo:module"),
        );
        let module =
            EndpointSpace::new().bind(Exact::new("urn:iki:tutorial:module:gated-resource"), gated);
        let host = ModuleSpace::new(
            ["urn:iki:tutorial:module:"],
            Arc::new(InProcessTransport::new(module)),
            ModuleFloor::public(),
        );
        let kernel = Kernel::new(Arc::new(host));
        let request = || {
            Request::new(
                Verb::Source,
                Iri::parse("urn:iki:tutorial:module:gated-resource").expect("iri"),
            )
        };

        let denied = block_on(kernel.issue(
            request(),
            &Capability::root().attenuate(["urn:cap:demo:other"]),
        ))
        .expect_err("the module's own declaration gates the call");
        assert!(matches!(denied, Error::Denied(_)), "{denied:?}");

        let allowed = block_on(kernel.issue(
            request(),
            &Capability::root().attenuate(["urn:cap:demo:module"]),
        ))
        .expect("the right scope reaches the module");
        assert_eq!(String::from_utf8_lossy(&allowed.bytes), "in");
    }

Do not assume you can ship a module to a running host. Nothing loads one at runtime outside the browser demo (as of 2026-10-10).

Do not assume the ABI is stable. Phase 2 exists precisely to change how these messages travel — 0.2.0 changed ModuleSpace::new’s signature once, and 0.3.0 added variants to ModuleCall and ModuleReply, which are exhaustive public enums, so anyone matching on them had a source break while ModuleSpace::new did not move. (The 0.2 bytes on the wire are unchanged; the new messages are appended.)

What is genuinely usable now

  • The dual-mode pattern, as a way to keep a heavy dependency out of hosts that do not need it (Dual-mode crates).
  • The browser case, which is real and running.
  • The InProcessTransport, as a way to develop and test a module’s semantics long before its transport exists — which is what this book’s demo does.

Where isolation would come from (direction, not shipped)

Capabilities constrain authority: which resources a resolution may reach, narrowing as they pass down a call chain. That is what exists today, it is enforced, and it is most of what an agent-facing system needs. What a capability says nothing about is resource consumption — how much memory an endpoint allocates, how long it runs, which syscalls it makes. A module that resolves nothing it was not granted can still allocate until the host dies.

The direction is containment beside the authority model rather than inside it: run the module as WebAssembly under an embedded runtime, so a host can hand it a memory limit, an execution budget and a closed syscall surface as well as a capability. Part of that already exists for an unrelated reason — the browser demo runs modules as wasm, ikigai-xslt-module builds one, and a wasm module has no ambient filesystem or network to begin with. What does not exist is any of the native half: no host in the ecosystem embeds wasmtime, no crate sets a memory budget, and none can stop a computation it has started: the word “fuel” appears in none of them (checked across the organization’s repositories on 2026-10-10; the probe re-checks ikigai-module 0.4.0 and ikigai-cli 0.1.46 by manifest). The nearest thing is a deadline on the answer, not on the work: since ikigai-xslt 0.2.1 every transform is answered within a time budget, five seconds, with a typed Timeout, and the crate says plainly that the work past the deadline is abandoned, never stopped (checked at ikigai-xslt 0.3.0) — the caller is released and the thread keeps running. Since ikigai-core 0.1.108 a deadline can also belong to the whole run: a door that issues with an IssueContext gives it a not_after, every sub-request inherits it and may only shorten it, and an endpoint or an overlay reads what is left with Invocation::time_left (an overlay that would wait for a retry checks the wait fits first). Wire v9, from ikigai-cli 0.1.66, carries the time left across a mount, so a peer’s work is held to the caller’s deadline too. It is still a deadline on the answer: the run is told how long it has, and nothing stops a computation that ignores it, so abandoned-never-stopped holds. Treat this section as the direction and the table above as the state, and do not plan a deployment on the difference.

The kernel since this chapter’s last stamp

The chapters above build against ikigai-core 0.2.2. This chapter was stamped at 0.1.78 on 2026-09-27, and seventeen releases, 0.1.79 to 0.1.95, landed in the thirteen days after it. The list below stops at 0.1.95. The eight releases from 0.1.96 to 0.1.103 are taught in the chapters they touch. Of the nine since, 0.1.104 (the principal vocabulary), 0.1.106 and 0.1.107 change nothing a page shows, and six are worth a line, because they change what an endpoint you write can rely on, or what a page shows:

  • 0.1.105 changed behavior: a declared default is filled in. The kernel fills a declared default_value into the request before dispatch, so an endpoint reads an omitted optional argument as its default rather than as MissingArgument. A default makes its input optional, and Description::validate refuses a description that marks a defaulted input required anyway. Before it, every endpoint applied its own default by hand. Exercise 2 of Exercises runs it.
  • 0.1.108 — failures have a class. Error::class() sorts every error into transient, retry-after, over-budget, denied, refused, permanent or unknown (new variants RetryAfter, OverBudget and Refused among them); Verb::is_idempotent says which verbs may be re-issued; an action may state the classes it fails with (fails(..), ik:mayFail in its contract); a run may carry a shared retry budget, granted as urn:cap:retry:<n>, and a deadline, above. ikigai-throttle 0.2.0 decides by the class, which Preference shows.
  • 0.1.109 — dynamic spaces. A running kernel’s arrangement can gain and lose doors without replacing its root: a DynamicSpace is rebuilt from a declaration resource, and each successful bind cuts the one thread every answer it served hangs from. A space that changes while it runs builds one, and A spreadsheet, VII shows its sibling, Threaded::session.
  • 0.1.110 — views. An alias rule may resolve its rewritten name at the root, under the caller’s own capability (ik:resolveAt "root"), so a dynamic space can hold another name for something the host serves elsewhere, and grant nothing by it. A space that changes while it runs declares one.
  • 0.1.111 — Turtle faces for the scheduler and the constraint window. urn:kernel:scheduler and urn:kernel:constraint answer as=text/turtle as well as text.
  • 0.1.112 — a face that is not offered is refused, and Turtle literals are shared. The kernel’s own operations declare the faces they serve, and refuse any other as= rather than answering text under another name; a trace therefore shows a face the caller left out as defaulted=as. Since ikigai-engine 0.2 the REPL’s trace prints those notes only when asked, as trace --defaults, which A space that changes while it runs asks under its view. turtle_literal and escape_literal are exported, so a module writes a Turtle string the way core does instead of carrying an RDF library for it.
  • 0.2.0 — streams. A representation’s bytes may be a body read as it arrives (Representation<Body>, Endpoint::invoke_body, Kernel::issue_body) rather than one whole buffer. A body that can be read only once is never stored in the cache, and one still being read past the run’s deadline ends with OverBudget. Streams, I: bytes are a representation starts the part about them. Every module this book links moved to 0.2 with it, since under 0.x rules a 0.1 pin could not admit it.

The seventeen from 0.1.79 to 0.1.95 are one line each here, and taught where a chapter teaches them:

  • 0.1.79 — documents and tests only. The check of what a wall keeps out answers only for what the graph shows in full, and a second query lists the paths it cannot answer for.
  • 0.1.80 — Conflict. A typed refusal for a request the resource’s current state says no to, the 409 of the taxonomy, found by this book’s game: a move to a taken square (Tic-tac-toe, VI). The spreadsheet’s market and its read-only past refuse with it (Spreadsheet, V, VI).
  • 0.1.81 — levels and sealed names. Level, the resolved scope a hit reports, and a host’s seals on names, checked when a space is built (Tic-tac-toe, VII, “What a declaration may and may not do”).
  • 0.1.82 — corridors stack, and a read says why it was not cached. Scope::stack (Spreadsheet, VI); every uncacheable read names its reason (Tracing a resolution); a fallback over a composite’s NotFound is cut when the atom changes, and the cache readout marks each row live, cut or expired (Spreadsheet, III); and cut listeners.
  • 0.1.83 — a space built from its declaration. Registry, build and Topology::from_turtle (the declare feature), the inverse of urn:kernel:topology, with every door naming the endpoint that answers it (Tic-tac-toe, VII).
  • 0.1.84 — declarations are bounded. A declaration past a depth, node or text bound is refused rather than overflowing the stack; and Kernel::root_topology, the root’s own arrangement, which Tic-tac-toe, VII’s test compares the coded game with the declared one through.
  • 0.1.85 — the audit release. The capability floor covers every verb an endpoint can be asked for, not only the ones it declares; nothing but the kernel answers inside the kernel’s own namespace; and a seal names a level by the object registered, not by its name.
  • 0.1.86 — exclusions are sticky. A deny-shaped scope survives attenuate and clamp: grants only shrink, exclusions only accumulate.
  • 0.1.87 — the “why” tools. urn:kernel:explain, a dry run of resolution (Tracing a resolution); urn:kernel:dependents, what a cut would recompute (Golden threads in practice); and Description::unsatisfied_scopes, the floor’s own predicate (Who is asking).
  • 0.1.88 and 0.1.90 — vocabulary only. Natural-language drafting terms move into ik:, and ikigai_vocab::space() names itself.
  • 0.1.89 — a space’s name survives only what keeps its doors. Binding onto a named space drops its name, and a module space that needs no configuration names itself. This book binds every such space behind doors of its own, so no page printed differently.
  • 0.1.91 — an action match has its own IRI, and a contract is content-addressed. A match is named by its verb and door, and the contract behind it by a digest of what it declares, which every Meta and catalog graph in this book now prints (What resolution buys you).
  • 0.1.92 — a failed invocation is a trace node. An invocation that ran and failed records an event with the error’s kind, so the trace and the cache agree about what a resolution touched. No chapter teaches it yet.
  • 0.1.93 — who a request is for, and the thread a failure hangs from. A principal is a capability scope, urn:cap:principal:<iri>: a door mints the party it authenticated into the capability (Capability::with_principal), and an endpoint reads it back with Capability::principal (Who is asking, “Identity is not authority”). And Invocation::depends_on lets an atom whose absence is another name’s state hang its NotFound from that name’s thread, so a composite that caught the NotFound and cached a fallback is cut when that name changes; it rides only a NotFound or Unresolved, and any other failure stays uncached. No chapter teaches depends_on yet: the spreadsheet falls back only on an atom’s own NotFound, which the atom’s own thread already covers (Spreadsheet, V, “fall back on an atom’s own NotFound”).
  • 0.1.94 — a percent escape is two hex digits. A match or contract IRI whose escape carries a sign (%+1) is refused where it used to name a door; nothing else about the parse moved.
  • 0.1.95 — lenient in, canonical out; a write that ran and failed cuts. A match or contract IRI is parsed in any spelling that names the same door (an upper-case digest, an unnecessary escape), and every one core emits is the canonical spelling (canonical_match_iri, canonical_contract_iri); every IRI this book prints was already canonical, so none moved. And a Sink or Delete the kernel dispatched cuts its target whether it returned success or an error; a request refused before it ran cuts nothing (Golden threads in practice).

The stamp before this one, 0.1.78, covered the six releases after 0.1.72: the two cache-soundness holes closed and a depth budget (0.1.73), one thread for “the binding set changed” (0.1.74; both in Golden threads in practice), the first temporal corridor (0.1.75) and Limit (0.1.76; both in Scope and alias), lossless by declaration (Transreption, 0.1.77), and the arrangement as a resource, urn:kernel:topology (Tic-tac-toe, VII, 0.1.78).

Exercises

Four, and all four run against your module — crates/your-endpoints/src/module.rs, the same shape as the loadable-module crate this part quotes, with a greeting endpoint under urn:iki:tutorial:yours:module: that calls back to the host’s urn:iki:tutorial:yours:name. The loop is cargo test -p your-endpoints; the test module at the bottom of that file already has the greet helper that builds a kernel and resolves a name under a capability, so a new question is a copy of it with something changed.

1. Break the prefix

Change ModuleSpace::new([MODULE_PREFIX], …) in host_space() to a prefix the request does not match.

  • Right when — the_module_resolves_a_host_resource_mid_invocation fails with no endpoint resolved for urn:iki:tutorial:yours:module:greeting, and a_name_outside_the_module_prefix_does_not_reach_it still passes. Then put it back.
Hint

Notice what did not change when it broke: the module is still compiled in, still constructed, still perfectly able to answer that name. It has an endpoint bound at urn:iki:tutorial:yours:module:greeting in its own space, and the host cannot reach it.

That is routing being a host decision rather than a module’s claim on a name space — the same separation as Binding, and a host of your own, one level up. A module does not get names by asking for them.

2. Chain a callback

Bind a second endpoint in module_space() — one that takes no arguments and returns a constant — and then resolve urn:iki:tutorial:yours:module:greeting with name pointing at that name instead of urn:iki:tutorial:yours:name.

  • Right when — the greeting names whatever your second module endpoint returns, and greeting itself is untouched.
Hint

Follow the path before you write it: the host routes urn:iki:tutorial:yours:module:greeting to the module, the module calls back to the host for a name, and the host routes that straight back into the module — while the first invocation is still open. Re-entrancy into the same module, mid-invocation.

It works, and it works on the loopback transport too. The reason it works is the same reason exercise 1 broke: the module has an IRI and an Issuer, and no way to ask where a name will end up. It cannot tell that the answer came back from itself.

The second endpoint is the shorter half of the exercise: name() in the same file already shows the shape of an endpoint that takes nothing and returns a constant — and remember the prefix: a name the host does not route to the module never reaches it, which is exercise 1 arriving from the other side.

3. Attenuate

Give the host’s urn:iki:tutorial:yours:name a declared capability — .requires("urn:cap:yours:name") on its Description — then resolve the greeting under Capability::root().attenuate(["urn:cap:yours:name"]), and again under an attenuation that grants something else. The greet helper already takes the capability.

  • Right when — with the right scope you get Hello, Ada!; with the wrong one the resolution fails with denied: capability does not grant `urn:cap:yours:name` (declared by `urn:iki:tutorial:yours:name`).
Hint

The interesting part is where that denial happens. It is not the module being refused at the door — the module ran. It is the module’s callback being refused, mid-invocation, because the capability the invocation is carrying is the caller’s, narrowed, and it does not grant what the host resource demands.

That is the property this part has been claiming: a module does not get authority by being a module, it borrows the caller’s, and the borrowing can only narrow. Here it is failing closed in front of you.

⚠ Try the mirror image too: put .requires(…) on the module’s own greeting endpoint instead. Since ikigai-module 0.2.0 that is enforced — the caller is refused before the module runs, with the same message shape. It was not at 0.1.10, and the first edition of this exercise said so; see “Do assume the host enforces what a module declares”, above, for the version and the test.

4. Take the loopback

Swap InProcessTransport::new(module_space()) for LoopbackTransport::new(module_space()) in host_space().

  • Right when — every test passes unchanged, including the chained callback from exercise 2.
Hint

Nothing about your code changes, which is the observation. What changes underneath is that every message in the session — the invocation, each HostCall, each HostResult, the final representation — is now genuinely encoded and decoded, in one process, over an in-memory channel.

So the two transports partition the failure modes. If a test passes in-process and fails on the loopback, the bug is in the encoding: something in the request, the capability or the representation does not survive a round trip. If it fails on both, it is the semantics. That split is worth stealing for anything else you build with a wire format — it is much cheaper than debugging a socket.

A kernel behind a socket

The wire session opened on a sentence this part has to answer. A real module is on the other side of something — another WebAssembly instance, an embedded wasmtime, a socket — and then the conversation has to be encoded.

Take the socket. It is the smallest thing that is genuinely on the other side: another process, its own address space, its own memory, gone when it crashes. Everything in this part is downstream of that one step, because once a resolution can leave the process, four questions arrive at once — who is asking, what may they do, what happens when the far side is not there, and who benefits. This chapter is the first step and the easiest of the four. The rest of the part is the other three.

Serving

A server is a kernel and a path:

/// Serve `space` as a kernel on the Unix socket at `path`, until an unrecoverable accept
/// error. **Blocks** — this is a server's main loop, not a spawn.
///
/// There is no authentication code here and that is the design: the socket is created
/// `0600` inside a `0700` directory, and `ikigai_ipc::serve` additionally refuses any
/// peer whose kernel-verified UID is not the server's own. On one machine, between one
/// user's processes, the operating system already knows who is asking.
pub fn serve(space: Arc<dyn Space>, path: &Path) -> std::io::Result<()> {
    ikigai_ipc::serve(Kernel::new(space), path)
}

That is the whole server. ikigai_ipc::serve binds the socket, replaces a stale one left by a previous run, and hands each connection to a thread that reads framed messages and answers them against the kernel.

Connecting

/// Connect to a kernel server on `path`.
///
/// The returned [`IpcResolver`](ikigai_ipc::IpcResolver) implements the same `Resolver`
/// trait an embedded kernel does, which is the whole reason a mount can be pointed at
/// either without the caller knowing. It is also what makes the next chapter possible:
/// a thing that answers requests is a thing a local kernel can compose.
pub fn connect(path: &Path) -> std::io::Result<ikigai_ipc::IpcResolver> {
    ikigai_ipc::connect(path)
}

And this is the part worth stopping on. connect returns something that implements the same Resolver trait an embedded kernel implements. Not a similar one, not a client API that mirrors it — the same trait, with the same issue.

That is why there is no “remote resolution” concept anywhere in this book. A caller that holds a Resolver cannot tell whether the answer came from a function call in this process or a round trip to another one, and neither can the engine, which is what makes Preferential resolution possible at all: a thing that answers requests is a thing a local kernel can compose.

The book’s test says it flatly. Serve hello_camel::space() — the space Part I built — on a socket, connect to it, and resolve urn:iki:tutorial:camel-case. The answer is resourceOrientedComputing, the same string the in-process test gets, and the two calls differ only in which constructor built the resolver.

Failure arrives typed

The second test is the one that matters more than it looks. Ask the served kernel for a name it does not bind, and the error that comes back is Unresolved — the server’s own typed error, not a generic “the call failed”.

Keep that in view until Preferential resolution. A client that can tell “your kernel has no such resource” from “your kernel is not there” from “your kernel says you may not” can make a policy decision about each. A client that gets one opaque failure for all three cannot, and every degradation rule in this part is built on the distinction.

The transport does not authenticate, and that is not an oversight

There is no credential in either listing. ikigai-ipc puts the socket in a 0700 directory, makes it 0600, and then — belt and braces — refuses any connection whose kernel-verified UID is not the server’s own.

That is the whole authentication story, and it is complete: on one machine, between one user’s processes, the operating system already knows who is asking. Adding a certificate here would re-implement something the kernel underneath already does better. The next chapter is what happens when that sentence stops being true.

A second boundary, underneath the capability model

The one thing in the ecosystem that exists purely to be a server on a socket is gonk (ikigai-gonk), the work-ledger and repository-browser host: a durable RDF store, the work ledger, repositories browsed as resources and SPARQL over all of it, behind a socket, QUIC and HTTP. It took that job over from ikigai-dev-server, which this chapter used to show here and which is now retired. The gonk Book teaches it door by door; from this chapter’s side it is one more socket. With gonk installed (its install line is in Which gonk), start it in one terminal and connect from another:

ikigai-gonk                      # its socket: ~/.ikigai/gonk.sock
ikigai --connect ~/.ikigai/gonk.sock
ikigai --connect ~/.ikigai/gonk.sock -c 'source urn:kernel:catalog'

Its interesting property is not in any of its code. It is in its Cargo.toml, which says in its first comment that it is the module manifest: the binary links only what it serves. There is no calendar in it, no contacts, no EventKit, not even an HTTP client — not disabled, not hidden behind a flag, absent. A flaw in code that was never compiled in cannot be reached from this process whatever any capability says. The catalog that last line reads is the proof you can run: the store, the ledger, compression, backup and render rules, the kernel’s own resources, and (once repositories are configured) the browse and repository families. Nothing else — and gonk’s tests pin that list id by id, so a dependency bump that widens it fails the build.

That is a second boundary, and it sits underneath the capability model rather than inside it. The contrast is with the omnibus ikigai binary run as ikigai mcp --grant dev, which config-gates: it hides the calendar tools from the tool list, and the calendar code is still linked, still in the address space, still one bug away from being reachable. Linkage gating removes the option.

Two honest qualifications, because a boundary you overestimate is worse than one you do not have:

  • It is coarse. The unit is a dependency, not a resource and certainly not a caller. It can say “this binary has no calendar in it”; it cannot say “this client may read free/busy and nothing else”. That is what capabilities are for, and neither replaces the other.
  • It is a judgment, not a rule. The dev server gonk replaced did link ikigai-llm, deliberately, and its manifest argued the case: an outbound HTTP client to local inference is not the platform-authority class the gating exists to exclude. gonk made the other call. The model it explains with is mounted — one gonk.mount line, one prefix, urn:llm: — so the only outbound connection it can make is to one configured peer, over ikigai’s own transports, at an address no caller can choose. Two defensible answers to the same question; what matters is that each manifest argues its own.

What a broken connection is allowed to do

One last thing that will matter in Preferential resolution. A long-lived client — a daemon holding a standing mount, an interactive --connect session — outlives server restarts, so IpcResolver heals on use: a connection that broke is dropped and redialed, version handshake and all, rather than failing every subsequent call forever.

Healing is not the same as retrying, and the crate is careful about the difference. If the write failed, the request was never fully delivered — the server reads whole frames and cannot dispatch half of one — so nothing executed and replaying anything is safe. If the read failed, the request was delivered and may well have run, with only the answer lost. In that case only read-only calls are replayed; a Sink or a Delete surfaces its transient error and lets the caller decide.

Notice the shape of that rule, because it is about to recur one level up with a different answer: what may be re-issued is not a property of the failure, it is a property of the verb.

Who is asking

The previous chapter got away with having no authentication code, because the operating system had already answered the question. Put the same kernel on a network and nothing answers it. A UDP datagram arrives from an address; addresses are not identities, and the 0600 on a socket file protects nothing that is not a file.

So the QUIC transport has to answer three questions the IPC one could delegate: who is on the other end, what may they do, and what happens to a certificate nobody has decided about. The third is the one worth the first half of the chapter. The second half is the distinctions a reader needs once who and what may they do stop being the same question — and they stop being the same question the moment a kernel is served.

Trust without an authority

ikigai-quic runs the same session over QUIC — TLS 1.3, one bidirectional stream per call — with mutual certificate pinning and no certificate authority. Each side has a self-signed identity and the exact peer certificate it will accept: the client pins the server’s, the server requires and pins the client’s.

Both commands, and every quic:// address in this part, need a CLI built with the quic feature, which the front door’s install leaves out: cargo install ikigai-cli --locked --features quic.

ikigai cert generate            # a server identity and a client identity
ikigai cert add-client laptop   # an ADDITIONAL client identity, and its fingerprint

A CA would buy delegation — the ability to trust certificates you have never seen because somebody you trust vouched for them. Between a handful of machines one person owns, that is a cost with no matching benefit: you would run the CA, so trusting it is trusting yourself with extra steps. Pinning says the same thing with no infrastructure.

There is a second gate in the handshake worth knowing about, because its failure mode is confusing if you do not: the wire version is the ALPN protocol id. Each side offers the range of wire versions it speaks, newest first (ikigai/8 then ikigai/7 in the 0.1.63 crates this book builds; ikigai/9 leads from ikigai-cli 0.1.66), and the handshake settles on the newest both share. Only a peer whose versions share none with yours fails, and it does not connect and get a polite error; it fails the TLS handshake.

The certificate is the credential

Once the handshake succeeds, the certificate stops being a login and becomes the principal. Two ids come off it, and the crate is emphatic that they are not interchangeable:

  • fingerprint — lowercase hex SHA-256 of the leaf certificate’s DER. This is the identity: what an operator writes in a config file. Two hard requirements follow from that one sentence. It has to be stable forever, which rules out Rust’s DefaultHasher (explicitly not guaranteed stable across releases — keying a config on it would silently re-map every enrolled client on some future toolchain upgrade). And it has to be obtainable, so it is byte-for-byte what openssl x509 -noout -fingerprint -sha256 prints, minus the colons and the case.
  • segment_id — a namespace, not an identity. It names the tenant directory this client’s urn:file: names land in, so it can never be recomputed differently without orphaning data on disk. It is still the old unstable hash, deliberately and with the reason written next to it.

They are passed as a struct rather than as two positional arguments precisely so a call site has to say which it means.

The minter, and the ?

A Minter turns that identity into the session — the capability every call on the connection is bounded by — or refuses the connection outright:

/// A minter over an enrollment: certificate fingerprint → the capability scopes that
/// certificate is granted.
///
/// The `?` is the whole security posture. An unenrolled fingerprint makes the closure
/// return `None`, and `None` **refuses the connection** — the server closes it with a
/// distinct `UNAUTHORIZED` code rather than serving the request under some fallback.
/// There is deliberately no `unwrap_or(everyone)` here: a host that cannot decide what a
/// certificate may do must not fall back to a shared ceiling or to root.
pub fn minter(enrolled: BTreeMap<String, Vec<String>>) -> Minter {
    Arc::new(move |peer: &PeerIdentity| {
        let scopes = enrolled.get(&peer.fingerprint)?;
        Some(Session {
            capability: Capability::root().attenuate(scopes.iter().cloned()),
            // The peer's own namespace segment: its `urn:file:` names land inside it, so
            // one client cannot address another's workspace even by guessing the path.
            file_segment: peer.segment_id.clone(),
            // Who the server says is asking, stamped on every request as a NAME — never
            // authority, which is `capability` alone. This minter names no one; gonk names
            // each client `urn:iki:host:gonk:client:<fingerprint>` here.
            principal: None,
        })
    })
}

The session’s principal is a NAME the transport stamps on every request the connection carries (gonk puts each client’s urn:iki:host:gonk:client:<fingerprint> there, for its log and its authors), never authority: what a call may do is capability alone. This minter names no one.

The signature is Arc<dyn Fn(&PeerIdentity) -> Option<Session>>, and None refuses. The server closes the connection with a distinct application code, UNAUTHORIZED, whose message says exactly what happened: the certificate authenticated, and no authority is configured for it. That is a different event from a failed handshake, and it is reported as one, because “I do not know you” and “I know you and nobody said what you may do” have different fixes.

The rule the whole design hangs from, in one sentence from the source:

A host that cannot decide what a certificate may do must not fall back to a shared ceiling or to root.

There is no unwrap_or in that listing and there is not supposed to be one. The tempting alternative — hand an unrecognized client the same ceiling everyone else gets — is how a forgotten config entry silently becomes an over-grant, and it fails in the direction where nothing goes wrong until it goes very wrong.

The session is minted per connection and never cached, which turns revocation into a file edit: change the enrollment and the client loses its authority on its next connection, rather than at the end of some token’s lifetime.

Carrying a capability, and being clamped

A client can also carry a capability with a request — IssueAs — and the server does not take it at face value. It resolves under session.capability.clamp(&carried).

clamp keeps only what both grant. A peer carrying root gets the session’s ceiling; a peer carrying scopes gets the intersection. There is no operation anywhere in the capability type that widens one, so a caller can attenuate itself and can never exceed the principal it authenticated as.

Which raises the obvious question — why carry one at all, if it can only take away? Because taking away is the point. A client that holds broad authority and is about to hand work to an agent, a script, or a mount can spend a narrow slice of what it holds and know the server will enforce the narrowing even if its own process is compromised a second later.

A mount carries it for you. When a resolution in your kernel crosses a mount into a peer, the request goes with the capability it was issued under, and the peer clamps that against your session like any other carried capability: a sealed or narrowed session stays narrow on the far side of a mount. That was not always so. The Resolver trait’s issue_as had a default that dropped the capability and resolved as the peer’s own principal, so a mount whose wrapper forgot to override it ran every narrowed request at the peer’s full authority, while the scopes the local kernel checked still looked enforced (ledger item 849). Since ikigai-resolve 0.1.40 that default refuses any capability but root, naming the type that did not override it, so a forgetful wrapper fails closed; a_mount_carries_a_narrowed_capability_to_its_peer is the test that pins it.

The book’s tests state both halves: an unenrolled fingerprint gets None, and a carried capability naming a scope the session does not hold comes back not holding it.

Three postures, chosen by what the operator configured

A served kernel decides authority in one of three ways, most specific first:

  1. Per-identity grants — a clients.json maps fingerprint to a named grant, and the session capability is a function of which certificate authenticated. Unenrolled means refused; a shared default exists only if the operator wrote one explicitly, because absence must never imply one.
  2. A fixed ceiling — serve --cap urn:cap:personal:calendar:read:freebusy gives every authenticated client the same authority and nothing else. It also remains the outer bound under posture 1.
  3. Neither — each client gets its own filesystem workspace, transparently rooted at its own segment, so a tenant addresses files as if its segment were the root and cannot name another’s.

Two details in there are worth carrying away as habits rather than as facts about this crate. A clients.json that exists but does not parse stops the server at startup: a broken authority config must not be allowed to degrade into posture 2 or 3. And a grant whose file scopes name paths no client of that server could ever address is also refused at startup — it looks like a narrow grant and grants nothing, and a silently inert authority config is the exact failure the posture exists to prevent.

One limitation, stated plainly: the served surface is chosen once, at startup, from the union of --cap and every enrolled grant. Enrolling a grant that needs an endpoint family the server is not currently offering takes a restart. What is per-connection is the authority, not the manifold; the clamp is what makes serving one surface to differently scoped clients safe.

And a warning about names

The transport can also find its peers by name, announcing over mDNS, so a mount can say peer:plasma instead of an address — useful, because addresses move and names do not.

The rule that comes with it belongs in this chapter rather than in Preferential resolution: discovery supplies an address, never trust. An announced name is attacker-controlled; anything on the network can claim to be plasma. So a mount by name still requires a pinned certificate for that name, and an impostor gets a failed handshake rather than a conversation. What the name buys is ergonomics — it determines the address by announcement and the identity by convention — and nothing else.

Identity is not authority

Everything above turned a certificate into a session capability, and it is easy to leave the chapter believing the two are one thing. They are not, and the rest of this part depends on keeping them apart.

The principal — who asked — is a fact a door establishes: the certificate that authenticated, the passkey that signed, the socket’s peer credential. The capability — what may happen — is a value that gates. Neither derives from the other. An anonymous caller over the loopback socket has full authority and no identity at all; a signed-in caller holding a narrow grant has an identity and very little authority. What the door does with the principal is stamp it: the HTTP transport in ikigai-cli 0.1.27 attaches the authenticated principal to every mutating request as provenance, read off the connection and refused from the query string, so a write arriving from a browser carries who made it and nothing the caller typed can forge it. A ledger records that stamp as the author.

Since ikigai-core 0.1.93 the identity travels too, and it travels as a scope. A door that authenticated a party mints urn:cap:principal:<iri> into the capability the request runs under (Capability::with_principal), and an endpoint that needs to know who it is serving reads it back with inv.capability.principal(), or asks acts_as(iri) for a gate scoped to an author. Being a scope is what made it cheap: the cache already keys on the capability, so two principals’ answers are two cache entries; the wire already carries the scope set, so the name crosses a mount; the trace already records the capability. And it widens nothing. A capability names at most one principal (one holding two names neither), narrowing never adds or changes it and attenuate may shed it, clamp keeps the name the channel authenticated rather than one the peer carried, and root names nobody. The doors in ikigai-cli 0.1.46 mint it: the HTTP transport on every verb, reads included, and the QUIC door from the client certificate, with the stamped provenance on a write unchanged beside it. Before 0.1.93 an identity stopped at the door that established it, and an endpoint that wanted to know who asked had to be handed the fact by its host.

Authority only shrinks

A capability is a set of scopes, and the only operations on one are intersections. attenuate narrows a held capability to the scopes it names; clamp intersects a carried capability with the ceiling a channel authenticated. There is no join. So along any chain of sub-requests authority is a staircase that only goes down, and an endpoint can choose to step down on purpose: inv.issue_attenuated(request, scopes) and inv.source_attenuated(&iri, scopes) (core 0.1.71) issue a sub-request under this invocation’s capability narrowed to scopes. The case that wants it is a module dereferencing an IRI its caller named — resolving a target it did not choose, with every scope its caller happens to hold. Dropping the scopes it does not need turns “the caller named the secret and my caller may read secrets” from an exfiltration into a refusal:

#![allow(unused)]
fn main() {
extern crate ikigai_core;
extern crate futures;
use std::sync::Arc;
use futures::executor::block_on;
use ikigai_core::{
    ArgRef, AsyncFnEndpoint, Capability, Description, EndpointSpace, Error, Exact,
    FnEndpoint, Iri, Kernel, ReprType, Representation, Request, Verb,
};

// A resource that requires a scope to read.
let secret = FnEndpoint::new("secret", |_| {
    Ok(Representation::new(ReprType::new("text/plain"), b"s3cr3t".to_vec()))
})
.with_description(
    Description::new("secret").verb(Verb::Source).requires("urn:cap:tutorial:secret:read"),
);
// Two endpoints that fetch whatever IRI the caller passes in `from`.
let careless = AsyncFnEndpoint::new("careless", |inv| {
    Box::pin(async move {
        let from = Iri::parse(inv.inline_str("from")?).map_err(|e| Error::Endpoint(e.to_string()))?;
        inv.source(&from).await
    })
});
let careful = AsyncFnEndpoint::new("careful", |inv| {
    Box::pin(async move {
        let from = Iri::parse(inv.inline_str("from")?).map_err(|e| Error::Endpoint(e.to_string()))?;
        // Fetching a caller-named resource needs no secret-reading authority.
        inv.source_attenuated(&from, ["urn:cap:tutorial:fetch"]).await
    })
});
let kernel = Kernel::new(Arc::new(
    EndpointSpace::new()
        .bind(Exact::new("urn:iki:tutorial:secret"), secret)
        .bind(Exact::new("urn:iki:tutorial:careless"), careless)
        .bind(Exact::new("urn:iki:tutorial:careful"), careful),
));
// The caller may read secrets — and points both endpoints at one.
let caller = Capability::root().attenuate(["urn:cap:tutorial:secret:read", "urn:cap:tutorial:fetch"]);
let fetch = |through: &str| {
    Request::new(Verb::Source, Iri::parse(through).unwrap())
        .with_arg("from", ArgRef::Inline(b"urn:iki:tutorial:secret".to_vec()))
};

let leaked = block_on(kernel.issue(fetch("urn:iki:tutorial:careless"), &caller)).unwrap();
assert_eq!(String::from_utf8_lossy(&leaked.bytes), "s3cr3t");

let refused = block_on(kernel.issue(fetch("urn:iki:tutorial:careful"), &caller)).unwrap_err();
assert!(matches!(refused, Error::Denied(_)));
}

The narrowing is voluntary — careful still holds the caller’s capability and could have called source — so it defends the endpoint’s downstream, not the endpoint. That is the only thing an in-process, self-applied restriction can honestly claim, and it is worth having because the sub-request is the one place an endpoint hands control of the target to somebody else while keeping control of the authority.

Two ways authority could grow, and why both are absent. Delegation — an endpoint acting under authority it holds rather than its caller’s — is the confused deputy by another name: a module with store-write authority that passes a caller’s string into a privileged sub-request has handed its authority to the caller. Core has the design (a grant table only the host can write, keyed on the canonical target, replacement never union) and deliberately has not built it; the cost today is that a module built on a gated module makes its callers hold the underlying grant too. Ambient authority — an endpoint reaching the disk or the network without a request — is structurally impossible for a WebAssembly module, whose only import is the host callback, and is a matter of review for in-process Rust, where std::fs is one line away. Both are stated here because a reader who has only “capabilities gate authority” will go looking for them.

The fact underneath all of it: an endpoint has no way to hand a capability to the kernel. Capability::root() is a public constructor, so any code can build a strong capability; what it cannot do is issue under it, because the one path back into the kernel — the invocation — has never offered to take one. An authority-carrying sub-request takes its authority from the kernel, never from its caller. That single private seam is what makes the staircase structural rather than polite.

Declared is enforced — and who enforces

The description is the contract. The kernel checks every scope an action requires before dispatch and before the cache lookup — so a refused caller neither enters the endpoint nor is served a cached answer somebody else computed (core 0.1.49). An endpoint may add a finer ceiling of its own on top: the file workspace’s jail is one, a store’s per-graph effect check is another (A query’s authority is its dataset, next, is that one in full). What it may not do is enforce a scope it never declared, because the offer, the pre-flight and the enforcement are all read from the same declaration.

One trap in the declaration itself, and it has shipped: the two authoring forms are exclusive per verb. A flat .requires(..) on the description is the contract for every verb that has no explicit ActionSpec. Declare an ActionSpec for a verb and its requires replaces the flat one for that verb — an explicit action with an empty requires declares that the verb needs no authority, and the flat scope beside it is dead for that verb. Reading “one action per verb” as “one action per verb, in addition” is how a published module demanded a broad read token on one of its actions while its flat sibling was right. The normalized view says which is which:

#![allow(unused)]
fn main() {
extern crate ikigai_core;
use ikigai_core::{ActionSpec, ArgSpec, Description, Verb};

let mixed = Description::new("note")
    .verb(Verb::Source)
    .requires("urn:cap:tutorial:note:read")
    .action(ActionSpec::new(Verb::Sink).input(ArgSpec::new("content")));

let specs = mixed.action_specs();
let source = specs.iter().find(|a| a.verb == Verb::Source).unwrap();
let sink = specs.iter().find(|a| a.verb == Verb::Sink).unwrap();

// Source had no explicit action: the flat `requires` is its contract.
assert_eq!(source.requires, vec!["urn:cap:tutorial:note:read".to_string()]);
// Sink was declared explicitly and said nothing about authority — so it needs none.
assert!(sink.requires.is_empty());
}

Restate on each explicit action whatever it still needs, and no more.

The other half of a module’s contract is the grant list it publishes: the scopes an operator should hand a caller who is meant to use it. That list is prose until something checks it against the declaration, and the check should not be a copy of the kernel’s rule, which has a subtlety (a …:* family scope is satisfied by holding any grant under its prefix) that a copy gets wrong. Description::unsatisfied_scopes(verb, &capability) is the kernel’s own predicate, the one its floor, urn:kernel:actions and urn:kernel:validate share: the scopes that capability would be refused for, empty exactly when the floor would admit it. So a module can test, per verb, that what it publishes covers what it declares. Here it is for counter from Multi-verb endpoints:

#![allow(unused)]
fn main() {
extern crate building_endpoints;
extern crate ikigai_core;
use building_endpoints::multi_verb::{counter, WRITE};
use ikigai_core::{Capability, Endpoint, Verb};

// What the module tells operators to grant a caller who may set the counter.
const PUBLISHED: &[&str] = &["urn:cap:tutorial:counter:write"];

let contract = counter().describe();
let granted = Capability::scoped(PUBLISHED.iter().copied());
for verb in [Verb::Source, Verb::Sink, Verb::Delete] {
    assert!(
        contract.unsatisfied_scopes(verb, &granted).is_empty(),
        "the published grant does not cover {verb:?}"
    );
}

// A grant that never mentions the counter covers the read, and is refused both writes
// for the one scope it lacks.
let reader = Capability::scoped(["urn:cap:kernel:inspect"]);
assert!(contract.unsatisfied_scopes(Verb::Source, &reader).is_empty());
assert_eq!(contract.unsatisfied_scopes(Verb::Sink, &reader), [WRITE]);
assert_eq!(contract.unsatisfied_scopes(Verb::Delete, &reader), [WRITE]);
}

When a declaration gains a scope and nobody updates the published list, this is the test that goes red, before an operator’s caller is refused in production.

The reader-facing consequence: the tool list an agent is handed is exactly what it may invoke — urn:kernel:actions computes reach intersected with authority from the same declarations the kernel enforces — so the manifold cannot lie by omission. Since core 0.1.75 that holds inside a confinement too; what is still not chain-aware is listed in Scope and alias.

What the cache knows about authority

A cached answer is filed under the authority it was computed under, so two callers with different grants never share an entry, and a result computed inside a confinement is never served outside it — nor the other way round. That is why .cacheable() is safe on an endpoint whose answer depends on who is asking. Two callers holding the same scopes do share, which is the point: the fingerprint is of the authority, not the identity.

The hole, stated: a result built on a denial is cached like any other. A change of grant has no golden thread, so an endpoint that catches Denied and returns a cacheable fallback serves that fallback after the grant is widened. The fix — no caching at all for a result built on a refusal — is designed with the rest of the cache-soundness work and not built.

Denied, or nowhere

Resolution runs before the capability floor. That order is deliberate — the floor has to check the backing name after any rewrite, and it has to gate cached answers too — and it has a consequence worth knowing when you serve a kernel to strangers: an unbound name answers Unresolved to everyone, a bound-but-refused one answers Denied, so a caller with no authority can still learn which names exist. Names, not content; and on every surface served today the callers are the owner or an enrolled peer.

Where that leak matters, the answer is structural, not a smarter refusal: a mount fence or a confining corridor that leaves the name with nowhere to go, indistinguishable from a name nobody ever bound. A refusal is a decision, and a decision can be misconfigured; an absence cannot. Capabilities are ikigai’s decisions; the chain is its structure; use the one whose failure mode you can live with.

A query’s authority is its dataset

Who is asking mentioned a store’s per-graph check in passing, as one example of an endpoint adding a finer ceiling of its own. This chapter is that example, in full, because it is where the capability model meets the question people actually ask a shared store: may this caller run this query?

The usual answer looks at the query. Which graphs does the text mention, and may the caller read each of them? That answer is a parser’s opinion of a language with sub-selects, property paths, GRAPH ?g, FROM, and SERVICE, and every one of those is a way to reach data the text does not spell out. ikigai-store answers a different question. The caller names a dataset, a set of graphs, and the store opens exactly that dataset, through the SPARQL engine’s own API, to a caller holding a grant for every graph in it. The text then runs inside the dataset and cannot get out, because nothing outside it is there.

So the same SPARQL text is three different queries in the hands of three different callers. This chapter runs that, and then tries to break it:

cargo run -p graph-authority

The program runs all eight sections every time; each block below prints one of them.

Two tenants, one store

A workshop keeps its orders in one named graph, and its supplier keeps a price list in another. An order names a part by the supplier’s own IRI for it, so pricing an order is a join across the two graphs:

/// Three orders in the workshop's graph, three parts in the supplier's catalog. An order names a
/// part by the supplier's IRI for it, so the price of an order is a join across the two graphs.
/// The washer is not in the catalog yet: its order has no price in ANY dataset.
pub const SEED: &str = r#"PREFIX schema: <https://schema.org/>
INSERT DATA {
  GRAPH <urn:iki:tutorial:graph:north> {
    <https://north.example/order/1> a schema:Order ;
        schema:orderedItem <https://south.example/part/bolt> ; schema:orderQuantity 40 .
    <https://north.example/order/2> a schema:Order ;
        schema:orderedItem <https://south.example/part/gasket> ; schema:orderQuantity 12 .
    <https://north.example/order/3> a schema:Order ;
        schema:orderedItem <https://south.example/part/washer> ; schema:orderQuantity 500 .
  }
  GRAPH <urn:iki:tutorial:graph:south> {
    <https://south.example/part/bolt> a schema:Product ; schema:price 0.25 .
    <https://south.example/part/gasket> a schema:Product ; schema:price 3.15 .
    <https://south.example/part/hinge> a schema:Product ; schema:price 4.50 .
  }
}"#;

The callers are capabilities, the kind a host’s door mints for whoever authenticated:

/// The workshop: reads and writes its own graph.
pub fn north() -> Capability {
    caller(
        "urn:iki:tutorial:tenant:north",
        &[READ_NORTH, WRITE_NORTH, RUN_COSTED],
    )
}

/// The supplier: reads and writes its own graph.
pub fn south() -> Capability {
    caller(
        "urn:iki:tutorial:tenant:south",
        &[READ_SOUTH, WRITE_SOUTH, RUN_COSTED],
    )
}

/// An auditor: reads both graphs and writes neither.
pub fn auditor() -> Capability {
    caller(
        "urn:iki:tutorial:auditor",
        &[READ_NORTH, READ_SOUTH, RUN_COSTED],
    )
}

/// The supplier's price feed: writes the supplier's graph and reads nothing.
pub fn feed() -> Capability {
    caller("urn:iki:tutorial:feed", &[WRITE_SOUTH])
}

/// An old job that holds the store's BROAD write key, the one that means `DROP ALL` of
/// everything.
pub fn job() -> Capability {
    caller("urn:iki:tutorial:job", &["urn:cap:store:write"])
}

A grant names exactly one graph: urn:cap:store:read:graph:<iri> to read it through the store’s graph-scoped doors, urn:cap:store:write:graph:<iri> to write it. There is no prefix form, and reading and writing one graph are two grants. The broad keys, urn:cap:store:read and urn:cap:store:write, are the authority for the whole-dataset doors (urn:iki:store:select, urn:iki:store:update), and a graph-scoped door does not take one in place of the graph grant it declares.

The kernel is Scripts are resources’ host, the script space in front of an in-memory store, seeded with the two graphs instead of that chapter’s work items. Scripts are a later part of this book; the one fact about them this chapter needs comes in its own section.

/// "Scripts are resources"' kernel (the script space in front of an in-memory store), with the
/// store seeded by [`SEED`]. The host sets no ceiling on scripts: a run may touch what its
/// publisher held and its runner holds.
pub fn kernel() -> Arc<Kernel> {
    script_host::kernel_over(same_for_all(Ceiling::unbounded()), Vec::new(), Some(SEED))
}

Which graphs may I read?

Before a caller can name a dataset it needs to know what it may name. urn:iki:store:graphs answers that, and only that:

cargo run -p graph-authority -- 1
north> source urn:iki:store:graphs
urn:iki:tutorial:graph:north

south> source urn:iki:store:graphs
urn:iki:tutorial:graph:south

auditor> source urn:iki:store:graphs
urn:iki:tutorial:graph:north
urn:iki:tutorial:graph:south

The answer is exists and may read, never one without the other. A tenant learns nothing about a graph it holds no grant for: not that it exists, not that it does not, not how many there are. Its lines are always a subset of the graphs its own grants name, so the listing is not an oracle. A broad reader (urn:cap:store:read, or root) gets every named graph in the store, and both kinds of caller get the same bytes for the part of the store they can both see. The store’s default graph is never listed: it has no IRI, so no grant can name it, and no scoped read can reach it.

One text, three answers

Here is the query. It names no graph at all, so its bare patterns read the dataset’s default graph, which under graph= is the merge of the graphs the caller named:

/// Every order, with its part's price where the dataset has one. No graph is named: a bare
/// pattern reads the dataset's default graph, which under `graph=` is the merge of the graphs
/// the caller named.
pub const COSTED_ORDERS: &str = r#"PREFIX schema: <https://schema.org/>
SELECT ?order ?quantity ?price WHERE {
  ?order schema:orderedItem ?part ; schema:orderQuantity ?quantity .
  OPTIONAL { ?part schema:price ?price }
} ORDER BY ?order"#;

Each caller runs it over the dataset its listing gave back:

cargo run -p graph-authority -- 2
north> source urn:iki:store:graph-select graph=urn:iki:tutorial:graph:north as=text/tab-separated-values   (the query above)
?order	?quantity	?price
<https://north.example/order/1>	40
<https://north.example/order/2>	12
<https://north.example/order/3>	500

south> source urn:iki:store:graph-select graph=urn:iki:tutorial:graph:south as=text/tab-separated-values   (the query above)
?order	?quantity	?price

auditor> source urn:iki:store:graph-select graph="urn:iki:tutorial:graph:north urn:iki:tutorial:graph:south" as=text/tab-separated-values   (the query above)
?order	?quantity	?price
<https://north.example/order/1>	40	0.25
<https://north.example/order/2>	12	3.15
<https://north.example/order/3>	500

Three answers, all correct. The workshop sees its orders and no prices, because no price is in its graph. The supplier sees no orders, because no order is in its graph. The auditor, holding both grants, names both graphs (whitespace between them, quoted as one argument) and gets the join: two orders priced, and the washer unpriced because the catalog does not list it yet.

The text did not change and nobody checked it against a list of permitted graphs. What changed was graph=, and the store set that dataset through the engine’s API before evaluation, exactly as if the query had said FROM <north> FROM NAMED <north>, without the query saying it.

Refuse, never narrow

Now the workshop asks for the join it cannot have:

cargo run -p graph-authority -- 3
north> source urn:iki:store:graph-select graph="urn:iki:tutorial:graph:north urn:iki:tutorial:graph:south" as=text/tab-separated-values   (the query above)
error: denied: reading <urn:iki:tutorial:graph:south> through `urn:iki:store:graph-select` needs the grant `urn:cap:store:read:graph:urn:iki:tutorial:graph:south`, 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.

south> source urn:iki:store:graph-select graph="urn:iki:tutorial:graph:north urn:iki:tutorial:graph:south" as=text/tab-separated-values   (the query above)
error: denied: reading <urn:iki:tutorial:graph:north> through `urn:iki:store:graph-select` needs the grant `urn:cap:store:read:graph:urn:iki:tutorial:graph:north`, 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.

This is the point of the chapter. A dataset with one grant missing is a typed Denied that names the grant, and it is never answered over the graphs the caller does hold.

Look at what the narrowed answer would have been. Drop the supplier’s graph from the workshop’s join and the query runs over the workshop’s graph alone: three orders, no prices. That is exactly the first answer in the previous section, which was correct there. Here it would be a lie, and a plausible one. “Our supplier has no prices for any of these” is a sentence somebody could act on, and nothing in the rows says that it is a statement about the caller’s grants and not about the supplier.

Why narrowing is the usual failure. Graph-level security is most often built as a filter: take the graphs the query would read, drop the ones the caller may not, and run what is left. It is easy to build, it never errs on the side of disclosure, and it feels safe because the failure it risks (reading too much) is the one everybody tests for. Its own failure is the quiet one: an OPTIONAL that stops matching, a FILTER NOT EXISTS that starts passing, a COUNT that comes back smaller, each of them a well-formed answer to a question the caller did not ask. An absent row and a forbidden row look the same in a result set, so the caller cannot tell from the answer that anything was taken out. A refusal names the missing grant, and the caller can see what happened and what would fix it.

Before ikigai-store 0.2.5 a scoped read took one graph, and the join a caller wrote came back empty rather than refused. The several-graph dataset and the refusal arrived together, because one without the other would have been the filter described above.

Confinement by construction

The workshop now tries to reach the supplier’s graph from a read whose dataset is its own. Six ways in:

/// Six ways a query run over the workshop's graph alone might try to read the supplier's, each
/// with the label the program prints for it.
pub const ESCAPES: &[(&str, &str)] = &[
    (
        "GRAPH <south>",
        "PREFIX schema: <https://schema.org/>
SELECT ?part ?price WHERE {
  GRAPH <urn:iki:tutorial:graph:south> { ?part schema:price ?price }
}",
    ),
    (
        "GRAPH ?g",
        "SELECT DISTINCT ?g WHERE { GRAPH ?g { ?s ?p ?o } }",
    ),
    (
        "a sub-select naming south",
        "PREFIX schema: <https://schema.org/>
SELECT ?order ?price WHERE {
  ?order schema:orderedItem ?part .
  { SELECT ?part ?price WHERE { GRAPH <urn:iki:tutorial:graph:south> { ?part schema:price ?price } } }
}",
    ),
    (
        "a property path from an order to a price",
        "PREFIX schema: <https://schema.org/>
SELECT ?order ?price WHERE { ?order schema:orderedItem/schema:price ?price }",
    ),
    (
        "FROM <south>",
        "PREFIX schema: <https://schema.org/>
SELECT ?part ?price FROM <urn:iki:tutorial:graph:south> WHERE { ?part schema:price ?price }",
    ),
    (
        "SERVICE",
        "SELECT * WHERE { SERVICE <https://south.example/sparql> { ?s ?p ?o } }",
    ),
];
cargo run -p graph-authority -- 4
north> source urn:iki:store:graph-select graph=urn:iki:tutorial:graph:north as=text/tab-separated-values   (GRAPH <south>)
error: invalid argument `query`: this query names <urn:iki:tutorial:graph:south> in a `GRAPH` block, and it is not in this read's dataset, `graph=urn:iki:tutorial:graph:north`. `urn:iki:store:graph-select` reads exactly the graphs `graph=` names, so that block would match nothing; the query is refused rather than answered empty, because an empty answer to a mistyped or forgotten graph looks exactly like an empty graph. Nothing was evaluated. Correct the IRI, or add the graph to `graph=` (separated by whitespace) under a grant for it

north> source urn:iki:store:graph-select graph=urn:iki:tutorial:graph:north as=text/tab-separated-values   (GRAPH ?g)
?g
<urn:iki:tutorial:graph:north>

north> source urn:iki:store:graph-select graph=urn:iki:tutorial:graph:north as=text/tab-separated-values   (a sub-select naming south)
error: invalid argument `query`: this query names <urn:iki:tutorial:graph:south> in a `GRAPH` block, and it is not in this read's dataset, `graph=urn:iki:tutorial:graph:north`. `urn:iki:store:graph-select` reads exactly the graphs `graph=` names, so that block would match nothing; the query is refused rather than answered empty, because an empty answer to a mistyped or forgotten graph looks exactly like an empty graph. Nothing was evaluated. Correct the IRI, or add the graph to `graph=` (separated by whitespace) under a grant for it

north> source urn:iki:store:graph-select graph=urn:iki:tutorial:graph:north as=text/tab-separated-values   (a property path from an order to a price)
?order	?price

north> source urn:iki:store:graph-select graph=urn:iki:tutorial:graph:north as=text/tab-separated-values   (FROM <south>)
error: invalid argument `query`: this query carries its own `FROM` / `FROM NAMED` clauses, and `urn:iki:store:graph-select` already fixes the dataset: `graph=` IS the dataset, exactly `FROM <urn:iki:tutorial:graph:north> FROM NAMED <urn:iki:tutorial:graph:north>`. The clauses are refused rather than overridden, because answering a `FROM` naming another graph with this graph's rows would be a wrong answer that looked right. Drop them, and name every graph you mean in `graph=`

north> source urn:iki:store:graph-select graph=urn:iki:tutorial:graph:north as=text/tab-separated-values   (SERVICE)
error: invalid argument `query`: `SERVICE` is not available through this store: a federated call would be an outbound request from inside a SPARQL string, gated by no network capability, and no grant opens it here. Nothing was evaluated. Fetch remote data through the kernel, where the net capability applies, and sink it into `urn:iki:store:load`
the attemptwhat the workshop getswhy
GRAPH <south> { … }refused, naming the grapha constant graph outside the dataset can only match nothing, and an empty answer would look like an empty graph
GRAPH ?g { … }its own graph, and nothing else?g ranges over the dataset’s named graphs, which is north alone
a sub-select naming southrefused, naming the graphthe check walks the whole query: sub-selects, OPTIONAL, MINUS, UNION and every EXISTS
a property path, order to priceno rowsthe path names no graph; it walks the dataset, and the price is not in it
FROM <south>refusedgraph= is the dataset; a second way to name one is refused rather than silently overridden
SERVICE <…>refused, in every builda federated call would be a network request from inside a string, under no network grant

Two kinds of answer are in that table, and the difference matters. The refusals are for things the text says: a graph it names, a dataset it declares, a service it calls. The rest are for things the text does, and those are not checked at all, because they do not need to be. ?g and the property path ran, and ran correctly, inside a dataset that holds one graph. The refusals make mistakes legible (a misspelled graph IRI is the commonest of them), and the dataset is what makes the boundary hold. If the store’s walk of the query missed a shape tomorrow, that shape would still match nothing outside the dataset, because the engine was never given anything outside it.

That is “by construction”. Nothing in the boundary depends on understanding SPARQL, which is why it can be exact.

The write side

Writing is the other half, through urn:iki:store:graph-update graph=<iri>, under the write grant for that one graph. A query’s dataset can be set through the engine’s API; an update’s effects cannot, so the store confines updates differently. It runs the update against a private copy holding only the part of the granted graph the update can observe, compares the result, and applies the difference to the real graph in one transaction. If anything landed anywhere else, the whole update is refused and nothing is applied:

/// What the workshop sends to `urn:iki:store:graph-update graph=<north>`, each with the label
/// the program prints for it.
pub const WRITES: &[(&str, &str)] = &[
    (
        "an order of its own",
        "PREFIX schema: <https://schema.org/>
INSERT DATA { GRAPH <urn:iki:tutorial:graph:north> {
  <https://north.example/order/4> a schema:Order ;
      schema:orderedItem <https://south.example/part/hinge> ; schema:orderQuantity 2 } }",
    ),
    (
        "a better price, in the supplier's graph",
        "PREFIX schema: <https://schema.org/>
INSERT DATA { GRAPH <urn:iki:tutorial:graph:south> {
  <https://south.example/part/bolt> schema:price 0.01 } }",
    ),
    (
        "the same price, in the default graph",
        "PREFIX schema: <https://schema.org/>
INSERT DATA { <https://south.example/part/bolt> schema:price 0.01 }",
    ),
    (
        "WITH <south>, priced from its own orders",
        "PREFIX schema: <https://schema.org/>
WITH <urn:iki:tutorial:graph:south>
INSERT { ?part schema:price 0.01 }
WHERE { GRAPH <urn:iki:tutorial:graph:north> { ?order schema:orderedItem ?part } }",
    ),
    (
        "WITH <south>, reading south",
        "PREFIX schema: <https://schema.org/>
WITH <urn:iki:tutorial:graph:south>
DELETE { ?part schema:price ?old } INSERT { ?part schema:price 0.01 }
WHERE { ?part schema:price ?old }",
    ),
    (
        "CREATE GRAPH",
        "CREATE GRAPH <urn:iki:tutorial:graph:elsewhere>",
    ),
    ("DROP ALL", "DROP ALL"),
];
cargo run -p graph-authority -- 5
north> sink urn:iki:store:graph-update graph=urn:iki:tutorial:graph:north   (an order of its own)
updated <urn:iki:tutorial:graph:north>: +3 -0 quads

north> sink urn:iki:store:graph-update graph=urn:iki:tutorial:graph:north   (a better price, in the supplier's graph)
error: denied: this write is scoped to graph <urn:iki:tutorial:graph:north> and the update would have written graph <urn:iki:tutorial:graph:south>. Nothing was applied. A statement with no `GRAPH` block goes to the default graph, which is not the scoped graph — wrap it in `GRAPH <urn:iki:tutorial:graph:north> { … }`, or hold `urn:cap:store:write` and use `urn:iki:store:update`

north> sink urn:iki:store:graph-update graph=urn:iki:tutorial:graph:north   (the same price, in the default graph)
error: denied: this write is scoped to graph <urn:iki:tutorial:graph:north> and the update would have written the DEFAULT graph. Nothing was applied. A statement with no `GRAPH` block goes to the default graph, which is not the scoped graph — wrap it in `GRAPH <urn:iki:tutorial:graph:north> { … }`, or hold `urn:cap:store:write` and use `urn:iki:store:update`

north> sink urn:iki:store:graph-update graph=urn:iki:tutorial:graph:north   (WITH <south>, priced from its own orders)
error: invalid argument `content`: this update reads <urn:iki:tutorial:graph:south> (in `WITH` or `USING`), which is not this write's graph, `graph=urn:iki:tutorial:graph:north`. `urn:iki:store:graph-update` evaluates an update against a private copy of that one graph and nothing else, so reading it would match nothing; the update is refused rather than applied, because `+0 -0` from a mistyped or forgotten graph looks exactly like an update with nothing to do. Nothing was applied: the update was not evaluated. Correct the IRI, or, holding `urn:cap:store:write` and `urn:cap:store:read`, use `urn:iki:store:update`, which reads the whole dataset

north> sink urn:iki:store:graph-update graph=urn:iki:tutorial:graph:north   (WITH <south>, reading south)
error: invalid argument `content`: this update reads <urn:iki:tutorial:graph:south> (in `WITH` or `USING`), which is not this write's graph, `graph=urn:iki:tutorial:graph:north`. `urn:iki:store:graph-update` evaluates an update against a private copy of that one graph and nothing else, so reading it would match nothing; the update is refused rather than applied, because `+0 -0` from a mistyped or forgotten graph looks exactly like an update with nothing to do. Nothing was applied: the update was not evaluated. Correct the IRI, or, holding `urn:cap:store:write` and `urn:cap:store:read`, use `urn:iki:store:update`, which reads the whole dataset

north> sink urn:iki:store:graph-update graph=urn:iki:tutorial:graph:north   (CREATE GRAPH)
error: denied: this write is scoped to graph <urn:iki:tutorial:graph:north> and the update created graph <urn:iki:tutorial:graph:elsewhere>. Nothing was applied. Creating or dropping another graph needs `urn:cap:store:write`

north> sink urn:iki:store:graph-update graph=urn:iki:tutorial:graph:north   (DROP ALL)
updated <urn:iki:tutorial:graph:north>: +0 -12 quads

feed> sink urn:iki:store:graph-update graph=urn:iki:tutorial:graph:south   (a new price)
updated <urn:iki:tutorial:graph:south>

feed> sink urn:iki:store:graph-update graph=urn:iki:tutorial:graph:south   (DELETE WHERE: every price)
error: denied: this update has a `WHERE` clause, which reads graph <urn:iki:tutorial:graph:south>, and reading it needs the grant `urn:cap:store:read:graph:urn:iki:tutorial:graph:south` (or `urn:cap:store:read`), which this capability does not hold. Nothing was evaluated. A write grant alone covers updates that read nothing: `INSERT DATA`, `DELETE DATA`, `CLEAR`, `DROP`, `CREATE`

job> sink urn:iki:store:graph-update graph=urn:iki:tutorial:graph:south   (CLEAR GRAPH, under the broad write key)
error: denied: capability does not grant `urn:cap:store:write:graph:*` (declared by `urn:iki:store:graph-update`; any grant under `urn:cap:store:write:graph:` satisfies it)

auditor> source urn:iki:store:graph-select graph="urn:iki:tutorial:graph:north urn:iki:tutorial:graph:south" as=text/tab-separated-values   (triples per graph)
?g	?triples
<urn:iki:tutorial:graph:south>	7

Taking them in order:

  • An order of its own lands, three quads in the workshop’s graph.
  • A price in the supplier’s graph is refused, and so is the same triple with no GRAPH block, which would have gone to the store’s default graph: that is not the workshop’s graph either.
  • Both WITH <south> updates are refused before they run, and not as a denial: as an invalid argument. WITH (and USING) names the graph an update reads, and an update scoped to the workshop’s graph that names the supplier’s is asking for something the write cannot mean, whatever the caller holds, because its WHERE runs in a private copy of the workshop’s graph and nothing else. That is true of the one that would have written the supplier’s graph and of the one whose WHERE only reads it. The second used to be the trap of this chapter: it ran, matched nothing and reported +0 -0, an empty success where the read side had refused by name since 0.2.22. Since ikigai-store 0.2.24 the write side names the graph too, and nothing was evaluated. The other refusals in this table are still checked on effects, so they do not care which of SPARQL’s several spellings put a quad in the wrong place.
  • CREATE GRAPH writes no quad, and it is still refused: it registers a graph, and that is an effect outside the grant.
  • DROP ALL is applied, and empties the workshop’s graph alone. The private copy held nothing else, and emptying a graph is within a grant to write it. Twelve quads: the nine it was seeded with and the three it just added.
  • The feed holds the write grant for the supplier’s graph and nothing to read with. It may add a price, and it is not told the +N -M counts, because a count is a read (+0 after an INSERT DATA would say the quad was already there). It may not run DELETE WHERE, which reads the graph to decide what to delete: an update with a WHERE needs the read grant too, decided on the text before anything is evaluated, so the refusal itself says nothing about the graph.
  • The old job holds urn:cap:store:write, the broad key, which means DROP ALL of everything. The narrow door refuses it. The broad key opens the broad door, urn:iki:store:update, which sees the whole dataset; letting it through the narrow door as well would make the narrow door’s declared grant something other than the one it enforces.

The auditor’s count at the end is the evidence: the workshop’s graph is gone, and the supplier’s has its six seeded triples and the feed’s one new price.

Authority from the text

A query run through graph-select is told its dataset by whoever runs it. A query published is not: it is run later, by other people, and the runner does not get to pick its graphs. So a published query names them in its text, and Who a script runs as, in a later part, shows what follows from that: its authority is derived from the names, urn:cap:store:read:graph:<iri> for each graph the text reads.

/// [`COSTED_ORDERS`] as a published script. A script's dataset is not chosen by whoever runs it,
/// so this text names its graphs, and its authority is derived from the names.
pub const COSTED_SCRIPT: &str = r#"# Every order, with its part's price.
PREFIX schema: <https://schema.org/>
SELECT ?order ?quantity ?price WHERE {
  GRAPH <urn:iki:tutorial:graph:north> {
    ?order schema:orderedItem ?part ; schema:orderQuantity ?quantity
  }
  OPTIONAL { GRAPH <urn:iki:tutorial:graph:south> { ?part schema:price ?price } }
} ORDER BY ?order"#;
cargo run -p graph-authority -- 6
host> sink urn:script:costed-orders language=sparql   (the text above)
urn:script:costed-orders:version:sha256:…

north> source urn:kernel:actions   (the lines naming urn:script:costed-orders)
(none)

south> source urn:kernel:actions   (the lines naming urn:script:costed-orders)
(none)

auditor> source urn:kernel:actions   (the lines naming urn:script:costed-orders)
urn:script:costed-orders:result
urn:script:costed-orders:runs

north> source urn:script:costed-orders:result as=text/tab-separated-values
error: denied: capability does not grant `urn:cap:store:read:graph:urn:iki:tutorial:graph:south` (declared by `urn:script:costed-orders:result`)

auditor> source urn:script:costed-orders:result as=text/tab-separated-values
?order	?quantity	?price
<https://north.example/order/1>	40	0.25
<https://north.example/order/2>	12	3.15
<https://north.example/order/3>	500

All three callers hold the grant to run the script, so the graphs are the only difference between them. The published query’s own entry declares both graph grants, and that declaration is what the action manifold reads: urn:kernel:actions offers the query only to the caller who could run it. An agent handed the workshop’s tool list never sees costed-orders, and so never tries it and fails. If the workshop asks anyway, the kernel’s floor refuses it before the script host runs anything, naming the grant, the same refusal as the join in “Refuse, never narrow”, reached by a different door.

The cache keeps tenants apart

Every read in this chapter is cacheable, and sharing a cache between tenants is exactly where a boundary like this would leak if it were going to. Two rules keep it shut.

cargo run -p graph-authority -- 7
north> source urn:iki:store:graph-select graph=urn:iki:tutorial:graph:north as=text/tab-separated-values   (the query above)
3 orders, 0 priced   (computed)

north> source urn:iki:store:graph-select graph=urn:iki:tutorial:graph:north as=text/tab-separated-values   (the query above)
3 orders, 0 priced   (served from the cache)

auditor> source urn:iki:store:graph-select graph=urn:iki:tutorial:graph:north as=text/tab-separated-values   (the query above)
3 orders, 0 priced   (computed)

(a store whose price feed writes the supplier's graph through its own handle)

north> source urn:iki:store:graph-select graph=urn:iki:tutorial:graph:north as=text/tab-separated-values   (the query above)
3 orders, 0 priced   (computed)

north> source urn:iki:store:graph-select graph=urn:iki:tutorial:graph:north as=text/tab-separated-values   (the query above)
3 orders, 0 priced   (served from the cache)

auditor> source urn:iki:store:graph-select graph="urn:iki:tutorial:graph:north urn:iki:tutorial:graph:south" as=text/tab-separated-values   (the query above)
3 orders, 2 priced   (computed)

auditor> source urn:iki:store:graph-select graph="urn:iki:tutorial:graph:north urn:iki:tutorial:graph:south" as=text/tab-separated-values   (the query above)
3 orders, 2 priced   (computed)

feed> (loads the washer's price straight into the store)

auditor> source urn:iki:store:graph-select graph="urn:iki:tutorial:graph:north urn:iki:tutorial:graph:south" as=text/tab-separated-values   (the query above)
3 orders, 3 priced   (computed)

The cache is keyed on the capability. The workshop’s second read is served from the cache; the auditor’s read of the same text over the same graph is computed, because the auditor’s capability is a different key. Two callers never share an entry, so an answer computed under one tenant’s authority is never served under another’s, whatever the two answers happen to be. (Two callers holding the same scopes do share, which is the point: the key is the authority, not the identity.)

A read the kernel cannot keep fresh is not cached at all. The second half runs a store with a second writer: the supplier’s price feed loads straight into the store’s handle, which the kernel cannot see, so no write of the feed’s cuts a golden thread.

/// A store with a second writer the kernel cannot see: the supplier's price feed, which loads
/// straight into the store's handle and writes only the supplier's graph. The host SAYS so,
/// which is what lets a read of any other graph be cached. The handle comes back for the
/// program to play the feed with.
pub fn shared_kernel() -> (Arc<Kernel>, Arc<Store>) {
    let writes = SharerWrites::only_the_default_graph().and_named_graph(SOUTH);
    let (store, handle) = DurableStore::in_memory_shared_declaring(writes).expect("a store");
    let kernel = Kernel::new(Arc::new(Fallback::new(vec![Arc::new(
        ikigai_store::space(store),
    )])));
    script_host::issue(
        &kernel,
        &Capability::root(),
        ikigai_core::Verb::Sink,
        "urn:iki:store:update",
        &[("content", SEED)],
    )
    .expect("seeding the store");
    (Arc::new(kernel), handle)
}

/// The feed's write, through the handle: no request, so no thread is cut.
pub fn feed_writes(handle: &Store, nquads: &str) {
    handle
        .load_from_slice(RdfFormat::NQuads, nquads.as_bytes())
        .expect("the feed's write");
}

The host says where that writer writes (SharerWrites, naming the supplier’s graph), and the store uses the promise per read. A read of the workshop’s graph alone cannot be changed by the feed, so it is still cached. The auditor’s join includes the supplier’s graph, so it is never cached, the second identical read is computed again, and the feed’s price for the washer shows up on the very next read, with nothing to invalidate. A read over several graphs is cached only if every graph in it is one no invisible writer touches.

That second rule has a cost that is easy to misread as a bug. A join that includes a shared graph is as slow on the hundredth read as on the first, while each of its graphs read alone is fast. That is correct; the alternative is a cached answer that is silently out of date for as long as the process lives. The promise is also only as good as the host that makes it: a SharerWrites that leaves out a graph the sharer does write makes reads of that graph stale with no error at all, which is why it names every graph and has no “trust me” form.

Fair shares of the machine

Authority over a graph is authority to make the store work, and one tenant’s expensive query is every other tenant’s latency. So the same doors bound the work as well as the reach:

cargo run -p graph-authority -- 8
north> source urn:iki:store:graph-select graph=urn:iki:tutorial:graph:north max_rows=1 as=text/tab-separated-values   (the query above)
error: invalid argument `query`: the answer exceeds 1 rows; add LIMIT, narrow the query, or ask the host for more. It was refused, not truncated: no part of it was sent (ledger #970). A request's `max_rows=` can only lower this bound

north> source urn:iki:store:graph-select graph=urn:iki:tutorial:graph:north as=text/tab-separated-values   (a join of 33 triple patterns)
error: invalid argument `query`: this SPARQL text joins 33 patterns in one group, and this endpoint refuses more than 32 (MAX_JOIN_OPERANDS) before planning: the planner's join ordering grows about with the cube of that number and cannot be interrupted (ledger #964). A sequence path counts one pattern a step. Split the query, or move a list of values into VALUES

The size of an answer is bounded, and past the bound the answer is refused, not truncated, for the same reason a join is refused rather than narrowed: the first thousand rows of a larger answer look exactly like a complete answer of a thousand rows. A query the planner would choke on is refused before planning, because planning is the one stage nothing can interrupt.

Inside those bounds, every evaluation has a time budget, and it is set the way everything else in this chapter is: through the capability. A host gives every caller a base (five seconds by default) and a ceiling (two minutes, which root gets); a door that means some callers to have longer mints them a urn:cap:store:budget:<milliseconds> grant; and a request may carry budget=, which can only lower what its capability gets:

#![allow(unused)]
fn main() {
extern crate graph_authority;
extern crate ikigai_core;
extern crate ikigai_store;
use std::time::Duration;

use graph_authority::{auditor, north, READ_NORTH, READ_SOUTH};
use ikigai_core::Capability;
use ikigai_store::budget::{cap_budget, effective_budget, TimeBudget};

let host = TimeBudget::default();
assert_eq!(host.for_capability(&north()), Duration::from_secs(5));
assert_eq!(host.for_capability(&auditor()), Duration::from_secs(5));
assert_eq!(host.for_capability(&Capability::root()), Duration::from_secs(120));

// A door that mints its batch auditor half a minute.
let batch = Capability::scoped([READ_NORTH.to_string(), READ_SOUTH.to_string(), cap_budget(30_000)]);
assert_eq!(host.for_capability(&batch), Duration::from_secs(30));

// A grant past the ceiling gets the ceiling.
let greedy = Capability::scoped([READ_NORTH.to_string(), cap_budget(3_600_000)]);
assert_eq!(host.for_capability(&greedy), Duration::from_secs(120));

// A request's own `budget=` lowers what its capability gets, and can never raise it.
let five = host.for_capability(&north());
assert_eq!(effective_budget(Some("250"), five).unwrap(), Duration::from_millis(250));
assert_eq!(effective_budget(Some("60000"), five).unwrap(), five);
}

A caller cannot raise its own budget: attenuating a capability only removes grants, and removing a budget grant only lowers the budget towards the base. At the budget the caller is answered with a typed Timeout, never a partial answer, and an update that runs out of time writes nothing.

What a deadline cannot do is stop every evaluation. Some loops inside the SPARQL engine, an aggregate over rows already in memory among them, do not check for cancellation, so the caller is answered on time while a core stays busy until the loop ends. The store therefore caps how many reads evaluate at once (TimeBudget::with_max_overdue; by default a quarter of the machine’s cores, at least one), and a read that finds every slot taken waits within its own budget or is refused with a transient Unavailable that names the cap.

That cap is one pool for everyone in the published releases, so one caller able to start evaluations the engine cannot stop can fill it, and then every other tenant, the host included, is refused until those evaluations end. The next release of ikigai-store divides it: a reserve only the host’s own work may use, a pool for every other caller together, and a share no single caller may exceed, where a caller is a principal (every capability that names none counts as one caller). That change is merged and not yet published, so this book’s crate cannot run it; until it ships, a host that serves callers it does not trust keeps their budgets short.

The rule, once more

the questionthe answerwhat this chapter showed
what may a query read?the dataset its caller named, set through the engine’s APIone text, three answers
what if one graph is not granted?refused, naming the grant, never answered over the restthe workshop’s join
can the text reach past the dataset?no: what it names is refused, what it does stays insidethe escape table
what may an update write?its one graph, judged on effectsWITH, CREATE GRAPH, DROP ALL
which graphs may I name?urn:iki:store:graphs: exists and may readthree listings
who is offered a published query?a caller holding every graph its text namesthe manifold
can a cached answer cross tenants?no: keyed on the capability, and never cached over a shared graphcomputed, cached, computed
can one tenant starve another?bounded by size, shape and time, through the capabilitythe bounds, and the budget

None of this is a second permission system beside the kernel’s. Every grant here is an ordinary capability scope, checked by the kernel’s floor where the description declares it and by the store where the declaration cannot be exact (a family declared, one graph enforced). What the store adds is the one idea in the title: it does not ask what a query means. It asks which graphs the caller named, refuses if any one is not granted, and gives the query nothing else to read.

The gonk Book applies this chapter to a server: Two teams, one gonk gives two teams a ledger each in one gonk, and runs the same questions through its doors, with an agent per team, a manager over both, and a signed-out browser.

Preferential resolution

A connected peer is a Resolver, and a Resolver is a thing a local kernel can compose. So the interesting question was never how do I call another machine — that was A kernel behind a socket and Who is asking, and the answer is a function call. It is: what do that machine’s answers mean here?

There are exactly three answers, they are three different intentions rather than three settings, and the difference between them is entirely about what happens when the peer is not there.

Alias — a local name for somebody else’s namespace

/// **Alias** — `prefix` is a *local name* for the peer's namespace. `<prefix>rest` is
/// rewritten to `urn:rest` before it is forwarded, and the peer's catalog comes back
/// re-prefixed and tagged with `origin`.
///
/// Compose this **after** the local spaces: an alias only ever catches what this kernel
/// lacks. A prefix the local kernel already serves would win, and the mount would
/// silently never be used.
pub fn alias(prefix: &str, origin: &str, peer: Arc<dyn Resolver>) -> Arc<dyn Space> {
    Arc::new(MountedRemote::new(peer, prefix, origin))
}

An alias mount is composed after every local space, so it only ever catches what this kernel lacks. <prefix>rest is rewritten to urn:rest on the way out, so the peer — which serves urn:* like everyone else — recognizes the name, and its catalog comes back re-prefixed and tagged with where it came from, so a federated listing shows which machine each resource lives on.

⚠ This is not core’s Alias from Scope and alias, and the difference is the one field that matters. Core’s table makes two names one resource in this kernel and reports the backing name, so the cache and the golden thread fuse them. This mount rewrites a name on its way out to another kernel, where the rewritten spelling is a wire address — so it reports nothing, because this kernel may serve an unrelated resource under that spelling. Same word, opposite contract.

The failure mode is worth naming, because everything about it looks fine: point an alias at a prefix the local kernel already serves, and the local binding wins every time — requests under the mount resolve, answers come back, and the peer is never asked. The composition warns about exactly that at startup, and the fix is either an alias prefix this kernel does not serve or one of the next two kinds.

Override — the same namespace, served elsewhere

/// **Override** — the *same* namespace, served remotely. The IRI is forwarded unchanged,
/// so nothing at the call site changes.
///
/// Compose this **before** the local spaces; precedence is the other half of the
/// semantics, and a mount composed after a local binding of the same name is not an
/// override of anything. If the peer is down the resolution *fails*, and that is the
/// point — you asked for that machine.
pub fn overriding(prefix: &str, origin: &str, peer: Arc<dyn Resolver>) -> Arc<dyn Space> {
    Arc::new(MountedRemote::overriding(peer, prefix, origin))
}

The IRI is forwarded unchanged and the mount is composed before the local spaces, so nothing at the call site changes: urn:llm:ask is still urn:llm:ask, and it now happens somewhere else even though this kernel binds it too. Precedence is not an implementation detail here — it is the semantics. A mount composed after a local binding of the same name is not an override of anything.

And if the peer is down, the resolution fails. That is not a missing feature. You asked for that machine.

Prefer — an override that degrades

/// **Prefer** — an override wrapped in a [`Failover`] over the local spaces: the peer
/// when it answers, this machine when it does not.
///
/// The `PrefixGuard` is not decoration. `Failover` resolves *every* target, so an
/// unguarded `[peer, local]` pair would also answer for IRIs the local spaces bind and
/// this mount never claimed — hitting before a less-specific override behind it and
/// silently defeating it.
///
/// The catalog comes from the peer alone, so listing a prefer-mount shows what is
/// mounted there rather than re-listing the whole local kernel under the peer's name.
pub fn prefer(
    prefix: &str,
    origin: &str,
    peer: Arc<dyn Resolver>,
    local: Arc<dyn Space>,
) -> Arc<dyn Space> {
    let remote: Arc<dyn Space> = Arc::new(MountedRemote::overriding(peer, prefix, origin));
    Arc::new(PrefixGuard {
        prefix: prefix.to_string(),
        inner: Arc::new(Failover::new(vec![Arc::clone(&remote), local])),
        catalog: remote,
    })
}

The peer when it answers, this machine when it does not. It is built out of the parts already in play: an override, paired with the local space under a Failover, and the pair confined to the mount’s prefix.

That confinement is the least obvious line in the file and the one worth reading twice. Failover resolves every target, so an unguarded [peer, local] pair would answer for every IRI the local spaces bind — including names this mount never claimed, hitting before a more specific mount behind it and silently defeating it. The guard is small:

/// Confines a composed space to one IRI prefix.
struct PrefixGuard {
    prefix: String,
    inner: Arc<dyn Space>,
    catalog: Arc<dyn Space>,
}

impl Space for PrefixGuard {
    fn resolve(&self, request: &Request, scope: &Scope) -> Resolution {
        if !request.target.as_str().starts_with(&self.prefix) {
            return Resolution::Miss;
        }
        self.inner.resolve(request, scope)
    }

    fn entries(&self) -> Option<Vec<SpaceEntry>> {
        self.catalog.entries()
    }
}

Its entries deserve a glance too: the catalog comes from the peer alone, so listing a prefer-mount shows what is mounted there rather than re-listing the whole local kernel under the peer’s name.

Composition then sorts by prefix length, so the most specific mount wins regardless of the order the flags were written in:

/// Compose one kernel's root space: the mounts that front the local namespace, most
/// specific prefix first, then everything this machine serves itself.
///
/// Sorting by prefix *length* is what makes a single-resource override work: a whole IRI
/// is simply the most specific prefix there is, so `urn:llm:ask` mounted at one peer and
/// `urn:llm:` at another route the way an operator would expect regardless of the order
/// they wrote the flags in.
pub fn compose(mut fronting: Vec<(String, Arc<dyn Space>)>, local: Arc<dyn Space>) -> Fallback {
    fronting.sort_by_key(|(prefix, _)| std::cmp::Reverse(prefix.len()));
    let mut ordered: Vec<Arc<dyn Space>> = fronting.into_iter().map(|(_, space)| space).collect();
    ordered.push(local);
    Fallback::new(ordered)
}

A whole IRI is simply the most specific prefix there is, which is what makes overriding a single resource work: send urn:llm:ask to one peer and the rest of urn:llm:* to another, and the sort puts them in the right order for you.

What makes a degrading mount honest

A mount that falls back is a mount that can answer a different question than the one you asked, and the three rules below are what keep that from happening. They are the heart of this chapter, they are each a test in this book’s own crate, and the source states the principle in one line:

“graceful” never means “silently ignored the answer the peer actually gave”.

Only a failure that another target could fix falls through

a_prefer_mount_falls_back_to_the_local_binding_when_the_peer_is_down — the peer is tried first, it is unreachable, and this machine answers.

Which failures those are is a typed property, not a guess about the message: every error has a class (Error::class(), since ikigai-core 0.1.108), and Failover (from ikigai-throttle 0.2.0) advances on two of them. Transient, Timeout and Unavailable: the peer may answer now, and so may this machine. Retry-after, a peer saying “not before thirty seconds”: the peer is limited, and the next target is not (a_prefer_mount_falls_back_when_the_peer_says_retry_later). Everything else is returned unchanged: a bad argument, a resource that is not there, a state that says no, an input over its budget, and an endpoint error whose class is unknown, because nobody can say re-issuing it is safe (a_prefer_mount_does_not_swallow_a_refusal_or_an_unknown_failure). Re-issuing those somewhere else would only produce a second wrong answer.

Notice that this is the payoff for the chapter about the socket: the taxonomy survives the wire, so the fallback rule can be about what kind of failure it was rather than about the fact that something failed. (The three newest variants, RetryAfter, OverBudget and Refused, cross the wire typed from wire v9, ikigai-cli 0.1.66; this book’s transport crates are still 0.1.63, so the tests above hand Failover the error directly rather than through a socket.)

A denial still propagates

a_prefer_mount_does_not_swallow_a_denial — the peer answered Denied, and the local binding does not quietly answer instead.

This is the one that would be easy to get wrong and hard to notice. A denial is a real answer, delivered by an authority that considered the request; treating it as a failure to route around turns a capability boundary into a suggestion. Every argument in this book about authority attenuating correctly would be worth nothing if any client could get the answer anyway by having a local copy.

A mutating verb is never replayed

a_prefer_mount_never_replays_a_sink — the peer is transiently down, the verb is Sink, and the error surfaces rather than the write landing locally. The peer may have applied it before the connection broke, and writing twice is not writing once.

But Delete does fall through — a_prefer_mount_does_replay_a_delete_because_deleting_twice_is_deleting_once, which is a long name for a real distinction. The line is drawn at idempotence, not at mutation: Source, Exists, Meta and Delete may be re-issued to the next target, and Sink may not.

Worth comparing with the same question one layer down, where the answer is different. The IPC client redialing a broken connection to the same kernel replays only read-only calls and lets a Delete surface its error. Two different questions: “may I ask a different machine this?” versus “may I re-ask the machine that might already have done it?” Two defensible lines. When you build something like this, the useful discipline is not picking the right rule once — it is knowing which of the two questions you are answering.

What it looks like on a real machine

None of this is hypothetical; it is how the ecosystem’s own topology is written. Mounts are usually not flags at all but lines in a host’s config, so every kernel-building mode on that machine — the REPL, one-shot commands, the daemon, the MCP server — composes the same topology:

mount = "prefer urn:repo:=~/.ikigai/gonk.sock"

That line points at gonk, the server from A kernel behind a socket, which owns a durable store held under an exclusive lock — exactly one process may have it open, so every other process on the machine reaches it through the socket. Until it was retired, the same line named the dev server’s dev.sock, and moving a whole machine from one server to the other was a change to lines like this one and a restart, not a data migration. (The gonk Book teaches the same line from gonk’s side.) prefer is what keeps the machine working when that server is stopped for an upgrade, and it is also how a topology can name a peer that is normally not running: one started on demand is absent most of the time, and a prefer-mount is not broken while it is.

Across machines the target is an address or a discovered name rather than a path — --prefer urn:llm:=peer:plasma puts a heavier machine’s inference behind the same resource names, and falls back to this one when it is asleep.

One trap that config lines walk into, and it is the string matching again: if a family also mints under its bare name — a Sink that creates a new resource and hands back its IRI — then a prefix written with the trailing colon covers every read and covers no write at all. Reads keep working, so nothing looks wrong; new resources are silently created on the local kernel instead of the one holding the archive. Write the prefix that covers both.

Putting it together

This part’s plumbing in one wiring: dial the peer, decide what its answers mean, compose it in front of what this machine serves itself. (/tmp/gonk.sock below stands in for a socket; gonk’s own is the ~/.ikigai/gonk.sock above.)

#![allow(unused)]
fn main() {
extern crate two_hosts;
extern crate ikigai_core;
extern crate ikigai_resolve;
extern crate hello_camel;
use std::path::Path;
use std::sync::Arc;

use ikigai_core::{Kernel, Space};
use ikigai_resolve::Resolver;
use two_hosts::mounts::{compose, prefer};
use two_hosts::socket::connect;

// A peer is a Resolver — this one over a Unix socket, but the type is what matters.
let peer: Arc<dyn Resolver> = Arc::new(connect(Path::new("/tmp/gonk.sock"))?);

// Everything this machine serves itself.
let local: Arc<dyn Space> = Arc::new(hello_camel::space());

// `urn:repo:` there when it answers, here when it does not.
let root = compose(
    vec![(
        "urn:repo:".to_string(),
        prefer("urn:repo:", "/tmp/gonk.sock", peer, Arc::clone(&local)),
    )],
    local,
);

let kernel = Kernel::new(Arc::new(root));
let _ = kernel;
Ok::<(), std::io::Error>(())
}

no_run, because it dials a socket nothing has bound — but it compiles, every time this book is built, which is the part that catches an API moving underneath the prose. The listings above it are ignored for a different reason and lose nothing by it: each one is included from crates/two-hosts, which CI compiles and runs the tests of in the job next door.

Two cautions

Write the canonical name. A kernel may carry an alias table that rewrites names before resolution, and mounts are composed inside it — so a mount matches the canonical spelling, not the one you type. A mount prefix written in a spelling the alias table rewrites stops matching the moment that table ships, and no alias can save it.

A prefer-mount is not replication. The two sides are different kernels with different state; falling back means getting this machine’s answer, which may legitimately differ from the peer’s. That is fine when the resources are equivalent — a function library, a model behind a facade — and wrong when they are not. Prefer is for resources you would be equally happy to get from either side. When you would not be, you wanted an override, and you wanted it to fail.

The editor as a client

The chapters so far in this part were plumbing, and the reasonable question at the end of them is who benefits. This chapter and the next are the two answers, and they are the same answer twice: a client that reads the manifold gets a command surface it did not have to write.

Start with the human one. ikigai-emacs is a thin client — it shells out to the ikigai binary and does nothing heavier — and its most interesting command is a dozen lines long, half of which are the message it prints afterwards.

One function per resource you may call

M-x ikigai-refresh-aliases writes a file of elisp and loads it. What is in the file is one function per resource this capability may invoke, with the arguments that resource declares. Not a hand-written wrapper library: a projection of the live manifold, so (ikigai-fn-toUpper "hi") exists because an endpoint says it takes an argument called in, and it will keep existing in that shape for exactly as long as the endpoint does.

The whole of it is a resolution:

ikigai -c 'source urn:lisp:aliases as=text/x-emacs-lisp'

Read that line for what it is not. There is no code-generation tool, no schema file, no build step, and nothing in the editor that knows what a calendar or a repository is. There is a resource whose representation happens to be a program, and as= picks which language it comes out in — the same projection has a Scheme face (text/x-scheme, its default) for the kernel’s own Lisp. That Lisp is the sandboxed one the front door describes, and the Scheme face is written for it: each definition is a define that conses its arguments into a list and hands them to the verbs’ own primitive, %verb-args, which the allowlist carries for exactly this. Two representations of one resource, which is the oldest idea in this book applied to tooling.

Why it cannot drift

The generator does not read a config file listing what to emit. It resolves urn:kernel:actions — the capability-scoped manifold, already narrowed to what the caller may invoke — and joins it against urn:kernel:catalog, which knows each action’s declared arguments. Every function in the output is therefore backed by an endpoint’s own Description, the same one Why an endpoint describes itself introduced.

Three consequences, and the third is the one that makes this more than convenience:

  • A newly bound endpoint gets a function the next time you run the command. Nobody maintains a list.
  • The functions take the arguments the endpoint declares, positionally for the required ones, with the optional ones still reachable — so a shortcut never narrows what the endpoint can do.
  • A scoped session gets a smaller file. Capability filtering is not a filter bolted on top of the projection; it is the surface the kernel says you have. Reading the manifold is itself an act of inspection, which is why the command requires urn:cap:kernel:inspect — and why a narrowed session sees fewer functions rather than functions that fail when called.

A refused name is a comment, not a gap

Projecting a URI into an identifier can fail. urn: names are not elisp symbols, and the generator will not emit something it has not verified reads in the target language — not “contains only legal characters”, which proves nothing, but parses.

When it cannot, it emits a comment saying which name was skipped, why, and the generic call that still reaches the resource. That choice is worth stealing. This file is loaded, and load is all-or-nothing: one unreadable form takes the entire generated surface down with it. A refused alias costs a shortcut; a refused file costs the user their editor integration, and they will blame the editor.

It is the same rule the projection to agent tools follows, and the next chapter meets it again: certify the output, not the input.

The topology is a defcustom

The other half of the package is where the previous chapter shows up in a user’s config. ikigai-mounts is an ordinary Emacs customization variable holding a list of mounts — prefix, target, and which of the three kinds — so a user writes their own topology in their own init file, and every command the package runs composes it.

Sitting beside it is ikigai-connect, an IPC socket path, and the two are deliberately mutually exclusive: when it is set, the package passes --connect and no mounts at all, because a connected host owns its own mounts. The reasoning in that docstring is worth lifting out, because it is a good architectural instinct in one sentence: a machine’s transport and topology are a property of that machine, so putting them in the host’s config rather than the editor’s leaves one Emacs configuration that is identical everywhere.

There is a second reason on macOS, and it is the practical one. Calendar access is granted to the launching application, so a kernel spawned by Emacs is a different principal from the daemon that holds the grant. Routing urn:personal: through that daemon is not an optimization; it is the only way the resource resolves at all. A mount is doing something here that no amount of local configuration could.

What is genuinely small about this

It is worth being clear that the package is not much code. It runs a subprocess, it quotes its arguments, it keeps cache tags out of stdout, and it loads a generated file. Every interesting thing in this chapter is happening on the other side of that subprocess.

That is the claim, though: the client is small because the manifold is machine-legible. An editor integration that has to know what your system offers is a project; one that asks is an afternoon.

The next page, The aliases, day to day, is the practical half: setting the package up, finding the functions you have, calling them, and knowing when to refresh.

The aliases, day to day

The editor as a client explained why the generated aliases exist and why they cannot drift. This page is the other half: how you get them, how you find out what you have, how you call them, and when to regenerate.

Every transcript here is from one scratch run: ikigai-cli 0.1.41, ikigai-emacs at 6f03b98, Emacs 30.2 started with -Q, and an empty config home, so no mounts. Your counts will differ, because they depend on what your kernel offers you, and that is the point of the whole exercise.

Setting up

ikigai.el needs the ikigai binary on exec-path and nothing else. Three lines in your init file:

(add-to-list 'load-path "~/src/ikigai-emacs")   ; wherever you cloned it
(require 'ikigai)
(load ikigai-aliases-file t t)                  ; the last refresh, if there has been one

ikigai-aliases-file is where the generated file lives. It defaults to ikigai-aliases.el in your Emacs directory; customize it if you want the file somewhere else. The load line passes noerror, so a machine that has never refreshed starts cleanly, and every later session gets its functions without asking the kernel again.

The first time, run M-x ikigai-refresh-aliases. It resolves the alias resource, writes the file, loads it, and says how many functions it defined:

ikigai: 129 aliases loaded from ~/.emacs.d/ikigai-aliases.el

The top of the file it wrote:

;;; -*- lexical-binding: t -*-
;;; Generated from an ikigai kernel's manifold — do not edit.
;;;
;;; One function per resource this capability may invoke, with the arguments that
;;; resource declares. Regenerate with:
;;;   ikigai -c 'source urn:lisp:aliases as=text/x-emacs-lisp' < /dev/null
;;;
;;; Transport, mounts and quoting come from ikigai.el.

(require 'ikigai)

and one of its 129 definitions:

(defun ikigai-fn-toUpper (in &rest args)
  "Upper-cases the UTF-8 text supplied in the `in` argument."
  (apply #'ikigai-invoke 'source "urn:iki:fn:toUpper" "in" in args))

Each alias is that same shape: a docstring and one call to ikigai-invoke. So an alias goes wherever every other call from ikigai.el goes: through ikigai-connect when it is set, otherwise through an embedded kernel composed with ikigai-mounts.

Finding what you have

From a name to a function

The function name is the resource’s name, made into an elisp symbol by fixed rules:

  1. Drop the prefix: urn:iki: if the name has it, otherwise urn:.
  2. Replace every character that is not an ASCII letter, digit or hyphen with -. Case is kept, so toUpper stays toUpper.
  3. Mark the verb: Source is bare, Sink adds !, Exists adds ?, Delete adds -delete!.
  4. Put ikigai- in front. The few names ikigai.el already defines (ikigai-eval, ikigai-invoke, ikigai-repl and the like) get -resource added, so a resource can never replace the function every alias calls through.

From the file above:

ResourceVerbFunction
urn:iki:fn:toUpperSourceikigai-fn-toUpper
urn:text:grepSourceikigai-text-grep
urn:host:historySource and Sinkikigai-host-history, ikigai-host-history!
urn:httpHeadExistsikigai-httpHead?
urn:httpDeleteDeleteikigai-httpDelete-delete!

The two HTTP rows are the spelling that run printed, recorded on 0.1.41 and not re-recorded. Since ikigai-http 0.2.0 those resources are urn:http:head and urn:http:delete (the ikigai-cli the book now replays against, 0.2.6, links ikigai-http 0.5.0); the old names still resolve there, as deprecated aliases.

The first row is also why urn:fn:toUpper and urn:iki:fn:toUpper give one function and not two. The catalog lists only the canonical name, and dropping urn:iki: means the function keeps its name while the namespaces move under urn:iki:.

Looking them up

The aliases are ordinary functions. They are not commands: no generated definition is interactive, so M-x does not offer them. M-x ikigai- completes to the package’s own commands and stops there:

ikigai-eval-dwim  ikigai-eval-elisp-dwim  ikigai-org-email-invite
ikigai-org-schedule-zoom  ikigai-refresh-aliases  ikigai-repl  ikigai-repl-mode

To browse the aliases, use the help commands that work on functions. C-h f ikigai-text- then TAB completes over every alias in that family, and M-x apropos with ^ikigai-text- lists them with their docstrings:

ikigai-text-grep
  Function: Keeps the input lines that contain ‘pattern‘ (a literal
            substring, not a regex). ‘i=true‘ matches case-insensitively;
            ‘v=true‘ inverts the match (keep non-matching lines).
ikigai-text-head
  Function: Returns the first ‘n‘ lines of the input (default 10).
ikigai-text-nl
  Function: Numbers every input line from 1: a right-aligned (6-wide) number,
            a tab, then the line.

C-h f ikigai-text-head shows one in full:

ikigai-text-head is a interpreted-function in
‘~/.emacs.d/ikigai-aliases.el’.

(ikigai-text-head IN &rest ARGS)

Returns the first ‘n‘ lines of the input (default 10).

Read the two parts of that for what each one tells you, because they come from different places. The argument list is the endpoint’s declared contract: IN is there because urn:text:head declares a required input called in. The docstring is the first line of the endpoint’s own summary, copied in, and nothing more. It is not generated from the arguments, so an optional argument appears there only when the endpoint’s author mentioned it in the summary: n above, i and v for grep. For the full contract, ask the resource itself: describe urn:text:head, run with ikigai-eval-dwim (below), prints every declared argument with its type and whether it is required, which is how you learn that n is an optional integer that defaults to 10.

Whose functions these are

The file is generated from urn:kernel:actions, the manifold under the capability you called with, so two people refreshing against the same kernel can get different files. The narrowing is easiest to see from the shell, where cap narrows a session:

ikigai -c 'cap urn:cap:kernel:inspect' -c 'source urn:lisp:aliases as=text/x-emacs-lisp'

Holding only the right to inspect, the same kernel gave 78 functions instead of 129. The 51 that went are the resources that need some other grant: the repository facades, the HTTP verbs, the language models, the secret store and the scheduler among them. Narrow further, below the inspection right itself, and there is no file at all:

narrowed — capability: urn:cap:fn
error: denied: capability does not grant `urn:cap:kernel:inspect` (declared by `urn:lisp:aliases`)

ikigai.el has no command to narrow a session, so from Emacs the capability is whatever the kernel you reach gives you. Change which kernel that is, and refresh: a different ikigai-connect host gives that host’s surface. A mount in ikigai-mounts adds the mounted kernel’s resources under the mount’s prefix, so mounting a peer at urn:narrow: gave functions like ikigai-narrow-kernel-catalog. A mount only adds: where the peer binds an endpoint the local kernel binds too, both functions are defined, so ikigai-narrow-iki-fn-toUpper sits beside the local ikigai-fn-toUpper (checked against ikigai-cli 0.1.56, the release this book was pinned to when this page was written).

Calling them

From Lisp, or from M-:

An alias is a function, so you call it as one: from your own code, from M-: (eval-expression), or from a scratch buffer. The value is a string, the resource’s representation with surrounding whitespace trimmed, so M-: shows it in the echo area:

(ikigai-fn-toUpper "hello, resource")
⇒ "HELLO, RESOURCE"

Arguments

The required arguments are positional, in the order the endpoint declares them:

(ikigai-text-grep "apple\nbanana\ncherry" "an")
⇒ "banana"

Everything else goes after them as name and value pairs, both strings. This is how you reach an optional argument, as= for a different representation, or the content of a Sink:

(ikigai-text-head "one\ntwo\nthree" "n" "2")
⇒ "one
two"

(ikigai-host-history! "content" "on")
⇒ "history on (0 entries)"

The pairs are not keyword arguments, and the values are not converted for you. Both of these fail in Emacs, before the kernel is asked:

(ikigai-text-head "one\ntwo\nthree" :n "2")    ; signals (wrong-type-argument sequencep :n)
(ikigai-text-head "one\ntwo\nthree" "n" 2)     ; signals (wrong-type-argument sequencep 2)

And spell the names exactly. A pair whose name the endpoint does not declare is ignored on this path rather than refused: (ikigai-text-head "one\ntwo\nthree" "lines" "2") returns all three lines.

Where the result goes

The return value is all you get: nothing is written to a buffer. Two commands in ikigai.el do the rest.

  • ikigai-eval-elisp-dwim evaluates the Lisp form before point (or the region), shows the result, and puts it on the kill ring, so C-y pastes it. With C-u it also inserts the result after point. This is the one to bind for alias calls. A buffer holding the grep call above, after C-u M-x ikigai-eval-elisp-dwim with point at the end of the form:

    (ikigai-text-grep "apple\nbanana\ncherry" "an")
    banana
    
  • ikigai-eval-dwim is its sibling for the REPL grammar instead of Lisp: it runs the region or the current line as an ikigai command, so a line reading source urn:fn:toUpper in=hello shows HELLO (copied). Reach for it when you want a resource there is no alias for, or a describe.

When it fails

A failure is a user-error whose message is the kernel’s own error, prefixed with ikigai:. A name nothing binds:

(ikigai-invoke 'source "urn:fn:nothing-here")
;; signals (user-error "ikigai: error: no endpoint resolved for urn:iki:fn:nothing-here")

A refusal reads the same way. Here one resource was routed by an override mount to a second kernel on the same machine, serving under a ceiling of urn:cap:kernel:inspect alone (ikigai serve quic://127.0.0.1:47946 --cap urn:cap:kernel:inspect, which needs a CLI built with the quic feature, as Who is asking says):

(setq ikigai-mounts
      '((:prefix "urn:host:posture" :target "quic://127.0.0.1:47946" :mode override)))

(ikigai-host-posture)
;; signals (user-error "ikigai: error: denied: capability does not grant `urn:cap:host:posture` (declared by `urn:host:posture`)")

That example also shows the limit of “a narrowed session sees fewer functions”. The file describes the kernel you generated it against; an override sends one call somewhere else, and the function stays in the file because the local kernel would have answered it.

Two failures never reach the kernel at all: a value that is not a string (above), and a required argument left out, which Emacs reports as wrong-number-of-arguments.

Keeping them current

The file is a snapshot of one manifold at one moment. Run M-x ikigai-refresh-aliases again when that manifold changes:

  • you changed ikigai-connect or ikigai-mounts;
  • your grant changed, wider or narrower;
  • you installed a new ikigai, or a module changed what it binds.

A refresh does not remove a function from the running Emacs. load defines what is in the new file and leaves alone what is not, so an alias that has gone from the file stays defined until you restart Emacs (or fmakunbound it), and calling it still sends the request to the kernel. And the refresh message counts only what was defined.

What it did not define is in the file as a comment. A name is refused when it would not read back as an elisp symbol or, the case you are likeliest to meet, when two resources project to the same function. The scratch run below bound two endpoints at urn:text:head and urn:text.head with a small --arrangement file. Both project to ikigai-text-head. The file defines the first in sorted order and records the second:

(defun ikigai-text-head (in &rest args)
  "Returns the last `n` lines of the input (default 10)."
  (apply #'ikigai-invoke 'source "urn:text.head" "in" in args))

;; SKIPPED urn:text:head (Source): `ikigai-text-head` is already defined above — two IRIs, one name.
;; Still reachable as (ikigai-invoke 'source "urn:text:head").

Nothing is lost: the comment gives the generic call that still reaches the resource. But the refresh message does not mention it, so after a refresh that changed your topology, search the file for SKIPPED.

Long-lived sessions, org-babel scripting and an editor workbench over the kernel are material for a developer-oriented book, not this one.

Tracing a resolution

trace has appeared twice already as a command — in the front door and in What resolution buys you, where issue_traced drew a tree with one child. This chapter makes it the subject, because the plumbing chapters earlier in this part have given a resolution more ways to go somewhere you did not expect: a socket, a clamp, a mount that fell back. The trace is how you find out what actually happened, and — the part worth a chapter — it is where the kernel records the facts that name something which never ran.

An event per invocation

While a tracer is installed, the kernel hands it one TraceEvent per invocation of the resolution being traced. The fields are the questions you would ask afterwards:

fieldwhat it answers
targetwhich name — the canonical one, after any rewrite
span, parentwhere in the tree; (span, parent) pairs rebuild it
cache_hitserved, or computed
started, endedwhen, if the kernel has a clock; None if it does not
threadwhich worker ran it
capabilitythe authority it ran under — None for root, the scopes otherwise
notesfacts attached to this hop: by the endpoint, or by the kernel

Nothing is recorded when no tracer is installed; an untraced resolution pays one atomic load. The events are plain, serializable data, which is why they can cross a wire.

Two ways to install one, and they are exclusive

Kernel::set_tracer installs a tracer on the kernel. Every resolution after it records there until clear_tracer — one collector for the whole process, which is what the REPL’s trace does around exactly one call. Kernel::issue_traced(request, cap, tracer) records this resolution into tracer and nothing else, with its own span numbering starting at 0.

The two are exclusive, not additive. A resolution routed through issue_traced does not land in the global collector, and a host that sets one and traces some calls per-request will not see those calls globally. That is deliberate: the alternative is a double write, and the per-call form exists for a server tracing one connection’s call while other tenants’ resolutions run beside it — with a single global slot their events would interleave into whichever collector was installed last. The wire servers use the per-call form for that reason. It is a contract you would otherwise discover by reading the kernel, which is not where a host author looks, so it is written here.

What a denial looks like

The kernel enforces a declared requires before dispatch, so a refused endpoint is never entered — and nothing inside it can report the refusal. The kernel records it instead: an event whose notes carry ("denied", <the scope the caller lacked>), with started and ended both None and cache_hit false, because nothing ran. counter from Multi-verb endpoints declares a write scope on its Sink; here a narrow caller tries to write, through issue_traced:

#![allow(unused)]
fn main() {
extern crate building_endpoints;
extern crate ikigai_core;
extern crate futures;
use std::sync::{Arc, Mutex};
use futures::executor::block_on;
use ikigai_core::{
    ArgRef, Capability, Error, Iri, Request, TraceEvent, Tracer, Verb, DENIED_NOTE,
};

#[derive(Default)]
struct Recorder(Mutex<Vec<TraceEvent>>);
impl Tracer for Recorder {
    fn record(&self, event: TraceEvent) {
        self.0.lock().unwrap().push(event);
    }
}

let kernel = building_endpoints::kernel_in(None);
let reader = Capability::root().attenuate(["urn:cap:kernel:inspect"]);
let write = Request::new(Verb::Sink, Iri::parse("urn:iki:tutorial:counter").unwrap())
    .with_arg("content", ArgRef::Inline(b"3".to_vec()));

let recorder = Arc::new(Recorder::default());
let err = block_on(kernel.issue_traced(write, &reader, recorder.clone())).unwrap_err();
assert!(matches!(err, Error::Denied(_)));

// One event, for something that never ran.
let events = recorder.0.lock().unwrap();
assert_eq!(events.len(), 1);
let denial = &events[0];
assert_eq!(denial.target, "urn:iki:tutorial:counter");
assert!(denial.started.is_none() && denial.ended.is_none() && !denial.cache_hit);
assert_eq!(denial.capability.as_deref(), Some(&["urn:cap:kernel:inspect".to_string()][..]));
assert!(denial
    .notes
    .iter()
    .any(|(key, scope)| key == DENIED_NOTE && scope == "urn:cap:tutorial:counter:write"));
}

Why record it at all? Because a refused authority is a security fact, and the one action that could log it is the one that was refused. A log built on this seam sees every denial; without it a denial reaches no observer inside the kernel.

In the REPL the same refusal draws as a tree. Since ikigai-cli 0.1.46 trace renders the nodes it collected even when the resolution failed (core 0.1.92 made a failed invocation a trace node, and the floor’s refusal was already an event), then prints where it stopped:

ikigai> cap urn:cap:kernel:inspect
narrowed — capability: urn:cap:kernel:inspect
ikigai> trace urn:file:notes.txt
trace  urn:file:notes.txt
  client      ikigai repl  ·  capability: urn:cap:kernel:inspect
  transport   embedded · in-process

urn:file:notes.txt   file · denied · main · — · cap ✗ · denied=urn:cap:fs:read:*
urn:file:notes.txt   cap ✗ denied: denied: capability does not grant `urn:cap:fs:read:*` (declared by `urn:file:notes.txt`; any grant under `urn:cap:fs:read:` satisfies it)

The first line is a denial event, the kind the listing above records, drawn: the endpoint that would have answered (file), the outcome (denied), no duration (—, it never ran), the authority mark, and the scope the floor found missing as the event’s note. The last line is the error, and its cap ✗ is the authority dimension made visible: the message names the scope lacked and the resource that declared it.

The node’s mark says what happened to the session’s authority at that hop. Under a narrowed session, cap ✓ is a hop that ran under that capability unchanged; cap ↓ followed by scopes is a hop that ran under less (an attenuation, or a mount’s clamp), and names what it held; and cap ✗ is a hop the floor refused, as here, so the mark and the outcome agree. A refused hop that had been attenuated first keeps its scopes (cap ↓ … ✗), because which authority was refused is the diagnosis. A root session draws no mark at all. Before ikigai-cli 0.1.48 a refused hop under an unchanged capability was drawn cap ✓, which read as “authorized”, the one thing that node was not; and before 0.1.46 the REPL printed the last line alone, and the event existed in the kernel undrawn.

Before it runs: the dry run

A trace is an autopsy: it tells you what happened to a request you already sent, and for a write that means the write has happened (or has been refused) by the time you read it. urn:kernel:explain asks the same question without sending anything. Give it a target and a verb, and it walks the chain a real request would walk, asking each space whether it would answer, and stops short of invoking the endpoint: no endpoint runs, no cache entry is touched, no alias counter moves. What comes back is the route and the verdict: which space answered, which endpoint at which door, the scopes its declaration requires for that verb, and which of them the capability lacks.

Here is the denial above, asked about first, by the same narrow reader:

#![allow(unused)]
fn main() {
extern crate building_endpoints;
extern crate ikigai_core;
extern crate futures;
use futures::executor::block_on;
use ikigai_core::{ArgRef, Capability, Iri, Request, Verb};

let kernel = building_endpoints::kernel_in(None);
let reader = Capability::root().attenuate(["urn:cap:kernel:inspect"]);
let explain = |args: &[(&str, &str)], cap: &Capability| {
    let mut ask = Request::new(Verb::Source, Iri::parse("urn:kernel:explain").unwrap());
    for (name, value) in args {
        ask = ask.with_arg(*name, ArgRef::Inline(value.as_bytes().to_vec()));
    }
    String::from_utf8(block_on(kernel.issue(ask, cap)).unwrap().bytes).unwrap()
};

let would = explain(&[("target", "urn:iki:tutorial:counter"), ("verb", "sink")], &reader);
assert_eq!(
    would,
    "\
explain sink urn:iki:tutorial:counter
  capability  urn:cap:kernel:inspect
  chain       root
  spaces, in the order they are consulted:
    1. root  fallback        answered
  endpoint    counter (urn:ikigai:endpoint:counter)
  door        urn:iki:tutorial:counter (exact)
  bindings    (none)
  requires    urn:cap:tutorial:counter:write
  denied      yes: the capability lacks urn:cap:tutorial:counter:write — a real request is refused before the endpoint is entered
  cacheable   never: a sink is never stored
"
);

// An operator holding more can ask on someone else's behalf: `scopes=` answers for a
// capability ATTENUATED from the asker's, so root sees what the reader would see…
let for_reader = explain(
    &[("target", "urn:iki:tutorial:counter"), ("verb", "sink"), ("scopes", "urn:cap:kernel:inspect")],
    &Capability::root(),
);
assert!(for_reader.contains("  capability  attenuated to urn:cap:kernel:inspect\n"));
assert!(for_reader.contains("  denied      yes: the capability lacks urn:cap:tutorial:counter:write"));

// …and never more: a reader cannot ask what root would be allowed to do.
let mut wider = Request::new(Verb::Source, Iri::parse("urn:kernel:explain").unwrap());
for (name, value) in [("target", "urn:iki:tutorial:counter"), ("scopes", "urn:cap:tutorial:counter:write")] {
    wider = wider.with_arg(name, ArgRef::Inline(value.as_bytes().to_vec()));
}
assert!(block_on(kernel.issue(wider, &reader)).is_err());

// A name nothing answers says so, member by member.
let typo = explain(&[("target", "urn:iki:tutorial:countr")], &Capability::root());
assert!(typo.contains("    1. root  fallback        declined\n"));
assert!(typo.ends_with("  verdict     unresolved: nothing in the chain answers this name\n"));
}

Read the answer as three questions. Where would it go? The root space answers, with counter at its exact door, and no grammar bindings. Would it be let in? No: the Sink’s declaration requires the write scope and the reader lacks it. Would the answer be kept? A Sink never is; for a read, the line says the endpoint decides per answer, and points at urn:kernel:cached and urn:kernel:uncached, which say after the fact.

Three things to know about it, so it is not mistaken for more than it is:

  • It reports what each space did, not why it declined. A space that reports no structure of its own is marked opaque: explain can say it was consulted, not what happened inside it.
  • It needs urn:cap:kernel:inspect, like every other look inside the kernel, and it is cacheable under urn:kernel:bindings, per capability and chain, so rebinding anything recomputes it.
  • The name of the narrowing argument is scopes=, not as=. as= stays what it is everywhere else, the face: as=text/turtle gives the same answer as a graph.

From the REPL (ikigai-cli 0.1.43 and later), the same question is a command, explain <iri> [verb], a thin view over this resource that works the same in process and over --connect. Ask it about the old spelling from the alias section below, and it shows the rewrite before any space is consulted:

ikigai> explain urn:fn:toUpper
explain source urn:fn:toUpper
  capability  root
  chain       root
  rewrite     prefix urn:fn: -> urn:iki:fn:   (urn:fn:toUpper => urn:iki:fn:toUpper)
  resolves as urn:iki:fn:toUpper
  spaces, in the order they are consulted:
    1. root  alias           answered — encloses 2 opaque spaces: past them, the chain cannot be inspected
  endpoint    toUpper (urn:ikigai:endpoint:toUpper)
…
  cached now  no   (urn:kernel:cached, live)

The opaque line is the first limit above, in a real host: the CLI’s root is an alias table over spaces that report no structure, so explain can say the root answered and which endpoint, and not what the spaces inside it did. The REPL adds the last line itself, from urn:kernel:cached: explain reports the route, and whether an answer is in the cache right now is a separate, live question.

What an alias hop looks like

A request whose target arrived through a logical rewrite — Scope and alias — carries ("alias", "logical -> canonical") on every event of that resolution: the computed one, the cache hit, the denial, and the miss. target is the canonical name, because that is the name the kernel resolved, keyed and gated; the alias is disclosed as provenance rather than by editing anything. The CLI’s own table rewrites urn:fn: to urn:iki:fn:, so an old spelling traces like this:

ikigai> trace urn:fn:toUpper in="a b"
trace  urn:fn:toUpper
  client      ikigai repl  ·  capability: root (full authority)
  transport   embedded · in-process

urn:iki:fn:toUpper   toUpper · computed · main · 0ms · alias=urn:fn:toUpper -> urn:iki:fn:toUpper   → 3b  A B

Two more channels carry the same fact with no tracer installed, because a rewrite the operator cannot see is the bad afternoon this was built to prevent: source urn:kernel:aliases lists the rules with per-rule counters, and a denial on a rewritten target names the hop in its message. A rewrite that lands on nothing is traced too, under ("alias-unresolved", <the backing name>) — an ordinary miss records no event, but this one is the case where the caller asked for a name that looks bound.

What a confined miss looks like

Inside a severed chain a name outside it is Unresolved, and that is indistinguishable from “no such resource” by design — the whole point of Confine is that there is no decision to read back. The trace is the one place the fact “this name is bound, just not in here” can show. Every event of a resolution in a non-empty chain carries ("scope", <the chain>), innermost first, ending in root or severed; a miss inside one adds ("scope-unresolved", <the name>):

#![allow(unused)]
fn main() {
extern crate hello_camel;
extern crate ikigai_core;
extern crate futures;
use std::sync::{Arc, Mutex};
use futures::executor::block_on;
use ikigai_core::{
    AsyncFnEndpoint, Capability, Confine, EndpointSpace, Error, Exact, FnEndpoint, Iri,
    Kernel, ReprType, Representation, Request, TraceEvent, Tracer, Verb, DENIED_NOTE,
    SCOPE_MISS_NOTE, SCOPE_NOTE,
};

#[derive(Default)]
struct Recorder(Mutex<Vec<TraceEvent>>);
impl Tracer for Recorder {
    fn record(&self, event: TraceEvent) {
        self.0.lock().unwrap().push(event);
    }
}

// The confined endpoint from "Scope and alias": it may see the document, not `title`.
let excerpt = AsyncFnEndpoint::new("excerpt", |inv| {
    Box::pin(async move { inv.source(&Iri::parse(hello_camel::TITLE).unwrap()).await })
});
let corridor = Arc::new(EndpointSpace::new().bind(
    Exact::new("urn:iki:tutorial:document"),
    FnEndpoint::new("document", |_| {
        Ok(Representation::new(ReprType::new("text/plain"), b"the document".to_vec()))
    }),
));
let kernel = Kernel::new(Arc::new(hello_camel::space().bind_arc(
    Exact::new("urn:iki:tutorial:excerpt"),
    Arc::new(Confine::new(
        Iri::parse("urn:iki:tutorial:ctx:document").unwrap(),
        corridor,
        Arc::new(excerpt),
    )),
)));

let recorder = Arc::new(Recorder::default());
let request = Request::new(Verb::Source, Iri::parse("urn:iki:tutorial:excerpt").unwrap());
let err = block_on(kernel.issue_traced(request, &Capability::root(), recorder.clone()))
    .unwrap_err();
assert!(matches!(err, Error::Unresolved(_)));

// The miss is traced: the chain it happened in, and the name that had no door there.
let events = recorder.0.lock().unwrap();
let miss = events
    .iter()
    .find(|e| e.notes.iter().any(|(key, _)| key == SCOPE_MISS_NOTE))
    .expect("the confined miss is recorded");
let note = |key: &str| miss.notes.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str());
assert_eq!(note(SCOPE_NOTE), Some("urn:iki:tutorial:ctx:document severed"));
assert_eq!(note(SCOPE_MISS_NOTE), Some(hello_camel::TITLE));
assert_eq!(note(DENIED_NOTE), None);
}

Read the last three lines as the three questions an operator asks: where was it standing (a chain named for the document, root cut off), what did it ask for (the title), and was it refused (no — it was nowhere to be found). A resolution in the empty chain carries no scope note at all, so events from before the chain existed are byte-identical to now.

Across the wire

The events are data, and the wire carries them: a traced call to a served kernel comes back with the spans that kernel recorded, and the connected trace renders them as-is. The Python track reads the same events from the other side. A mount inside a local resolution is stitched under the mount’s own node, so the remote execution shows where the local one handed off.

What not to assume

  • Before ikigai-cli 0.1.46 the REPL drew a tree only on success, and a denial or a miss was one line there; since then it draws what it collected (above). Either way the event itself is in the kernel, and a Rust host or a log sees it.
  • The REPL has no scope, so ("scope", …) notes are visible only from a host that resolves in a chain — issue_in records them through the global tracer, Confine through either.
  • A successful urn:kernel:* operation records no event. Reading the catalog is introspection, not throughput, and it returns before the trace path; only its denial is recorded. An observer therefore sees refusals from that namespace and never successes, which is the fact worth keeping.
  • Installing a tracer to see denials means building an event for every resolution. There is no denial-only reporter yet; a host that wants only refusals filters in its own record. Stated so the cost is not a surprise.

The next chapter is the client that never traces anything and needs none of this to work: it reads the manifold instead.

The machine client

The editor as a client generated a command surface for a person. This one generates the same thing for a program, out of the same catalog, and the fact that it is the same is the point the whole book has been walking toward.

Take the two halves in order: a resource that fronts a family of interchangeable implementations, and then the manifold as a tool list.

One grammar, several backends

ikigai-llm binds a facade at urn:llm:ask and a directly addressable endpoint per configured backend. Asking is the front grammar; a specific model is still a name you can resolve if you want that model and no other.

Both answer only with a backend configured in your config home’s llm.json, which no earlier chapter sets up; without one, urn:llm:models lists nothing to ask.

ikigai -c 'source urn:llm:ask prompt="name three uses for a paperclip"'
ikigai -c 'source urn:llm:models'

The facade picks a backend — an explicit provider= argument, else a needs= expression resolved against what each backend can actually do, else the configured default — and then does something worth noticing:

Rewrite the target; carry every argument through unchanged. Going via inv.issue records the backend result as a dependency, so its expiry and golden threads propagate to this facade result.

It re-issues through the kernel. It does not call a backend object it holds; it names the backend as a resource and asks for it, exactly as any other caller would. Which means the facade inherits the backend’s cacheability and invalidation for free — and, less obviously, that everything in Preferential resolution applies inside it. Override urn:llm: and the facade itself lives on a peer. Override one backend name and a local facade routes to a remote model. The composition happens between the two halves because they were never wired together in the first place.

That is the general shape, not a trick this one crate plays: a facade that re-issues through the kernel is composable; a facade that holds its implementations is not.

The provider-specific names follow the pattern urn:llm:{provider}:ask — which providers exist is a matter of local configuration, so urn:llm:models is how you ask rather than guess.

An agent’s tool list is the manifold under its capability

Now the second half. The Model Context Protocol asks a server for three things: a list of tools, a typed input schema for each, and a way to call one.

ikigai has all three already, under other names, and had them before MCP existed — because they are what a resolution needs, not what a protocol asked for:

MCP wantsikigai already has
tools/listurn:kernel:actions — the manifold, already narrowed to this capability
an input schemathe ArgSpec contracts on each action’s Description
tools/callkernel invocation

So the projection is a translation, not a feature: an action becomes a tool descriptor, and the tool name maps back to the endpoint and verb it came from. Nothing about an endpoint changes to make it available to an agent. The endpoint you wrote in Part I, with the description you wrote for the engine’s benefit, is an agent tool already.

Two properties fall out of that, and both are worth more than the integration itself.

Affordance equals authorization. The list an agent sees is the manifold under the capability its session holds — not the full catalog with the forbidden entries filtered out at call time. A scoped agent cannot enumerate what it may not invoke, so a whole class of “the model kept trying the thing it is not allowed to do” simply does not arise. Narrowing authority narrows the tool list, live, and since core 0.1.75 inside a confinement too (Scope and alias lists what is still not chain-aware).

Federation is free at the tool layer. The manifold is projected from the composed kernel, mounts and all. A prefer mount pointing urn:llm: at a heavier machine puts that machine’s models behind exactly the tool names the local kernel would have projected, with no client-side configuration anywhere. The agent never learns there is a second machine.

The funnel, and where a model is actually needed

The projection is one way to hand an agent a surface; the kernel also offers a narrowing path for choosing within it, which is worth knowing exists because of what it says about where inference belongs:

  1. urn:kernel:actions — deterministic narrowing. Which actions this capability may invoke, and which of those match the shape of what you have.
  2. urn:agent:select — a model, used only for what is left over after step 1.
  3. urn:kernel:validate — a pre-flight check of the arguments against the declared contract, before anything is invoked.

The ordering is the argument. Inference is the residual step, not the first one: the expensive, non-deterministic, unauditable component is asked the smallest question that remains after the deterministic machinery has done what it can, and its answer is validated before it has any effect.

Step 3 is not optional advice, either. The MCP server re-checks the capability on every tools/call and pre-flights the arguments through urn:kernel:validate before it invokes anything — so a model that hallucinates an argument gets a contract violation back rather than a half-executed action.

What not to assume

Part II ended with a list like this and this part should too, for the same reason: a tutorial that leaves you to discover a limit by hitting it has wasted your afternoon.

  • Generation is not cached by default. An LLM call is not a pure function of its inputs, so urn:llm:ask does not claim to be one. The caching that Part I taught applies to the deterministic parts of a pipeline, which is most of it, and deliberately not to this part.
  • An MCP session with no grant runs as root, loudly (ikigai-cli 0.1.28). ikigai mcp with no --grant or --scope prints running UNRESTRICTED (root) and then does. A named grant that resolves to no scopes lands in the same branch. That is the opposite of the served kernel’s rule from Who is asking, where an unrecognized certificate is refused — the difference being that an MCP server is spawned by the human whose authority it runs under, and a QUIC server is not. Convenient, and exactly the sort of asymmetry to know about rather than discover.
  • The tool list is a projection, not a promise. Endpoints appear and disappear as capability, configuration and mounts change. That is the design working, and a client that caches a tool list across sessions is fighting it.

Where that leaves the book

Part I bound a resource. Part II routed a prefix of the name space to code the host had never seen. This part put a kernel behind a socket, gave it a way to know who was asking, and gave a caller three honest things to mean by “resolve this over there”.

What connects them is that none of it required a resource to know where it would be used. The camel-case endpoint from the first chapter can be linked into a host, reached through a module boundary, served over a socket, mounted from another machine, called from an editor by a generated function, or offered to an agent as a tool — and it is the same short function throughout, because it was never asked to care.

Exercises: beyond one host

Three, and all three are written in your crate — crates/your-endpoints/tests/beyond.rs — against the code this part quotes, crates/two-hosts, which you use and do not edit. The file already holds the scaffolding an exercise would otherwise spend its time on: a socket path nothing collides with, a dial loop that waits for a server thread to bind, a peer that is always down, and one test proving the scaffolding works. The loop is:

cargo test -p your-endpoints --test beyond

⚠ Unix only. ikigai-ipc is #![cfg(unix)], and so is that file; on another platform the whole test target compiles to nothing and passes vacuously, which is the one way a test file can lie to you. If --test beyond reports zero tests, that is why.


1. Serve Part I, and reach it

Serve hello_camel::space() behind a socket, connect, and resolve urn:iki:tutorial:camel-case through the connection. Then ask the served kernel for a name it does not bind.

  • File — crates/your-endpoints/tests/beyond.rs; serve_in_background and connect_when_up are already there.
  • Run — cargo test -p your-endpoints --test beyond
  • Right when — the answer is resourceOrientedComputing, the same bytes the in-process test in Part I gets; and the unbound name fails with ikigai_core::Error::Unresolved, not with a transport error.
  • Read again — A kernel behind a socket, both halves: the same Resolver trait, and failure arriving typed.
Hint

The connected peer is a Resolver, and a Resolver’s issue takes a Request and gives back a (Representation, CacheStatus) pair — the second half is the cache verdict the CLI prints as [cached] or [computed], and you can ignore it here. Bring the trait into scope (use ikigai_resolve::Resolver;) or the method will not be found, which is the first thing that goes wrong.

The second assertion is the one worth writing carefully. matches!(err, Error::Unresolved(_)) is the claim; a looser is_err() would also pass if the socket had simply not been there, and then the test would be asserting nothing this chapter said. The error crossed a process boundary and kept its type — that is what makes the next exercise possible.


2. Break the prefer-mount, and watch what falls through

Build a prefer-mount over your own space with a peer that is always down, and resolve urn:iki:tutorial:yours:word-count through it. Then make the same mount an override. Then make the peer refuse rather than fail.

  • File — crates/your-endpoints/tests/beyond.rs; dead(Error::Unavailable) gives you the peer and a counter, and two_hosts::mounts::{prefer, overriding, compose} are the three shapes.
  • Run — cargo test -p your-endpoints --test beyond
  • Right when — the prefer-mount answers 3 for "a b c" and the counter shows the peer was tried first; the override fails with Error::Unavailable; and a prefer-mount over dead(Error::Denied) fails with Error::Denied — local never answers for a refusal.
  • Read again — Preferential resolution, the three intentions and the rules for the third.
Hint

No socket is involved, which is the point of DeadPeer: every rule here is about what happens when the answer is not a representation, so the only peer you need is one that never gives one. Wrap the mount in a Kernel::new(..) and issue through it as Part I did.

Part I issued under Capability::root(), and here that matters. DeadPeer overrides only issue, and since ikigai-resolve 0.1.40 a resolver that does not override issue_as refuses every capability but root with Error::Denied (the fail-closed default in Who is asking). Under a narrowed capability, then, the prefer-mount over dead(Error::Unavailable) fails with Denied and never falls back, which looks exactly like the third outcome for the wrong reason. To run the exercise under a narrowed capability, give DeadPeer an issue_as that does what its issue does.

The three outcomes are three different questions, and it helps to name them before you assert them. Unavailable on a prefer-mount is “the peer is not there, so this machine answers” — the counter proves preferring happened before falling back. Unavailable on an override is “you named that machine, and it is not there” — a silent local answer would be a different question answered. Denied on a prefer-mount is “the peer said you may not” — and a fallback here would turn a capability boundary into a suggestion, which is why it is the one failure the rule never swallows.

If your override test passes the resolution instead of failing it, check the order compose put things in: an override composed after the local space overrides nothing.


3. Deny a callback, across the socket

Serve a host whose urn:iki:tutorial:yours:name declares .requires("urn:cap:yours:name"), with your module routed under urn:iki:tutorial:yours:module:. Connect, and resolve the module’s greeting with name pointing at that resource — twice: under a capability that grants the scope, and under one that grants something else.

  • File — crates/your-endpoints/tests/beyond.rs. Build the host in the test rather than editing module.rs: name() there has no requires (unless you left Part II’s exercise 3 in place), and that exercise is about adding one in-process.
  • Run — cargo test -p your-endpoints --test beyond
  • Right when — with Capability::root().attenuate(["urn:cap:yours:name"]) the answer is Hello, Ada!; with attenuate(["urn:cap:yours:other"]) the resolution fails with Error::Denied, and the message names both the scope the caller lacked and the resource that declared it.
  • Read again — Who is asking on carrying a capability and being clamped, and Where this actually stands, exercise 3, which is this denial without the socket.
Hint

issue sends no capability, so it runs as the socket’s principal — the owner, root. The method that carries one is issue_as(request, &capability), and the server resolves under what you sent, clamped against what the connection is entitled to. Over a Unix socket that entitlement is root, so the clamp is your attenuation and nothing else; over the QUIC transport it is what the certificate was enrolled for.

Now look at where the denial happens. The module is reached — nothing about the module’s own endpoint requires anything. It is the module’s callback to the host’s name that is refused, mid-invocation, because the capability the invocation is carrying is the one you sent, narrowed on the way down, and it does not grant what that resource declared. The message says so:

denied: capability does not grant `urn:cap:yours:name` (declared by `urn:iki:tutorial:yours:name`)

Three things crossed the process boundary intact for that sentence to arrive: the capability you carried, the attenuation down the call chain inside the served kernel, and the typed error on the way back. That is Part II’s claim — a module borrows the caller’s authority and can only narrow it — holding across a socket.

Tic-tac-toe, I: the model and the atoms

Everything so far has been one idea at a time. This part builds one small application end to end, in the order resource-oriented design says to: name the resources first, write code for the few that have to hold something, and get the rest by composing names. The application is tic-tac-toe (noughts and crosses, in Britain), because everyone already knows its rules, so every ounce of attention goes to the shape rather than the domain.

The design is not new. It follows Resource Oriented Analysis and Design, a nine-part series by Peter Rodgers in NetKernel News, issues 3.34 to 3.42 (August to October 2012), reprinted in 1060 Research’s 2013 collection of NetKernel articles. The series builds the same game on NetKernel, resources first and code last, and argues each step as it goes. This part keeps its order and its main decisions. The names, the code and the sentences are ikigai’s own, and where ikigai does something differently the text says so, because the differences are where the lessons are.

It comes in seven parts, one increment each. This one draws the whole model on paper and then builds only the atom: a single cell.

This is deliberately more machinery than tic-tac-toe needs. A game this small could be forty lines of ordinary code, and the parts that follow will sometimes feel like overkill: a name for every cell, row and rule, templates that are resources, a game that is a corridor rather than an object. The game is the excuse, not the point. What these parts build is a way of working in which only the few things that have to hold something are code, and everything else is a name composed from other names. That is what pays for itself as a system grows. Because every intermediate result is a resource, the kernel caches it, knows what it was computed from, and recomputes what a change affects, with no caching code in the application. A new rule, view or question is a new name, not an edit to the code already there. The same composition answers whether the state lives in this page, in a host process, or in a Python or TypeScript peer, and a thousand games share one set of rules. Change the domain and the shape stays: the board becomes a portfolio, a line becomes a group of positions, “who won” becomes a risk figure, and resolution, caching, invalidation and authority work exactly as they do here. Tic-tac-toe is small enough to hold the whole model in your head while you watch that happen.

Part IV makes each game a corridor, a term the Scope and alias chapter introduces; a look there first will help.

The model, on paper

Before any code, list the sets. A game of tic-tac-toe is small enough to name every kind of thing in it:

the setits namewhat it isbuilt in
a cellurn:iki:tutorial:ttt:cell:{x}:{y}the mark at one place, or emptythis part
a line of cellsurn:iki:tutorial:ttt:cells:{list}any cells, in order, named by their coordinatespart II
a rowurn:iki:tutorial:ttt:row:0 (and :1, :2)a line of cells, by name — no codepart II
a columnurn:iki:tutorial:ttt:column:0 (and :1, :2)the same, the other waypart II
a diagonalurn:iki:tutorial:ttt:diagonal:0 (and :1)the same, corner to cornerpart II
the boardurn:iki:tutorial:ttt:boardevery rowpart II
which lines a cell is onurn:iki:tutorial:ttt:checkset:{x}:{y}a list of the names of other resourcespart III
whose turnurn:iki:tutorial:ttt:turna question about the boardpart III
who wonurn:iki:tutorial:ttt:winneranotherpart III
a moveurn:iki:tutorial:ttt:move:{x}:{y}a mark played, if the rules allow itpart III
many games—the game as context: a corridor around the names, not a namepart IV
a board you can playurn:iki:tutorial:ttt:view:board (and view:status, view:play:{x}:{y})HTML over all of the above, from templates that are resources toopart V

Read down the second column and notice how little of it is code. Only the cell holds state: everything above it is a set of cells, or a question about sets of cells, and a set of names can be named rather than computed. That is the bet this part and the next are testing. The names past the first row are plans, not promises; each part settles its own.

Two cells, not one

The cell is really two resources, because two different questions get asked of it.

The stored cell, urn:iki:tutorial:ttt:stored:{x}:{y}, answers “what has been played here?” It holds a mark in memory. Source reads it, Sink plays one, Delete clears it — and where nothing has been played, Source answers NotFound, because nothing is stored there.

The platonic cell, urn:iki:tutorial:ttt:cell:{x}:{y}, answers “what does this cell look like?”, and it is the one anything else will read. Where a mark has been played it answers the mark; where none has, it answers the empty cell, -. So every cell of every board already exists, before any game has started: nothing has to create a board before reading one, and a fresh game is simply the absence of stored marks. (The name is the series’ own: the empty cell is the idea of a cell, the one every played cell is a departure from.)

The empty cell is a hyphen rather than a space for one practical reason: it gets read as text. A lone space is invisible in a terminal and in this page, where it looks exactly like nothing came back; a hyphen is visible, and it still lines up in a monospaced board.

/// The stored cell: what has been played at `(x, y)`.
pub const STORED: &str = "urn:iki:tutorial:ttt:stored:{x}:{y}";

/// The platonic cell: what a reader asks for.
pub const CELL: &str = "urn:iki:tutorial:ttt:cell:{x}:{y}";

/// What the platonic cell answers where nothing has been played.
///
/// A hyphen rather than a space, because this is read as text: a single space is
/// invisible in a terminal and in the page's output pane, where it cannot be told apart
/// from "nothing came back". A hyphen also lines up in a monospaced board — `X - O`.
pub const EMPTY: &str = "-";

Writes go to the stored cell and reads go to the platonic one. That split is deliberate, and the next section is why.

The stored cell

/// The marks that have been played, and how often the stored cell has been read.
///
/// The state lives outside the endpoint, where a test can hold it — the same shape as
/// `hello-camel`'s `TitleState`. The read counter is what lets a test prove that a cached
/// answer was *served*, with no code running, rather than recomputed to the same bytes.
#[derive(Debug, Default)]
pub struct CellStore {
    marks: Mutex<BTreeMap<(i64, i64), String>>,
    /// How many times the stored cell has actually been read — its `Source` or `Exists`
    /// run, rather than a cached answer served.
    pub reads: AtomicUsize,
}
/// `ttt-stored`: the mark at `(x, y)`, held in memory.
///
/// `Source` answers the mark, `.cacheable()`, and says nothing about golden threads: the
/// kernel hangs every cacheable read from the thread named after the resource it read,
/// and cuts that thread after every successful `Sink` or `Delete` to the same name. A
/// cell nobody has played is [`Error::NotFound`] — the endpoint is bound, and there is
/// nothing there.
///
/// `Exists` answers `true` or `false`: is a mark played here? Never `NotFound`, since the
/// name is bound either way. Cacheable like the mark, so the same write cuts it.
///
/// `Sink` takes the mark as `content` and keeps any mark — `X`, `O`, or a Connect-Four
/// `R` — refusing only an empty one, since "no mark" is what `Delete` says. Whether this
/// player may play this cell now is a rule, and rules are for a later increment.
pub fn stored_cell(store: Arc<CellStore>) -> FnEndpoint {
    FnEndpoint::new("ttt-stored", move |inv: &Invocation<'_>| {
        let at = (coordinate(inv, "x")?, coordinate(inv, "y")?);
        let mut marks = store.marks.lock().expect("the cell store's lock");
        match inv.request.verb {
            Verb::Sink => {
                let mark = inv.inline_str("content")?.trim();
                if mark.is_empty() {
                    return Err(Error::InvalidArgument {
                        name: "content".to_string(),
                        detail: "an empty mark — to clear a cell, delete it".to_string(),
                    });
                }
                marks.insert(at, mark.to_string());
                Ok(Representation::new(text_plain_utf8(), b"ok".to_vec()))
            }
            Verb::Delete => {
                marks.remove(&at);
                Ok(Representation::new(text_plain_utf8(), b"ok".to_vec()))
            }
            Verb::Exists => {
                store.reads.fetch_add(1, Ordering::SeqCst);
                let played = if marks.contains_key(&at) {
                    "true"
                } else {
                    "false"
                };
                Ok(Representation::new(text_plain_utf8(), played.as_bytes().to_vec()).cacheable())
            }
            _ => {
                store.reads.fetch_add(1, Ordering::SeqCst);
                match marks.get(&at) {
                    Some(mark) => Ok(Representation::new(
                        text_plain_utf8(),
                        mark.clone().into_bytes(),
                    )
                    .cacheable()),
                    None => Err(Error::NotFound(format!(
                        "nothing has been played at {},{}",
                        at.0, at.1
                    ))),
                }
            }
        }
    })
    .with_description(
        Description::new("ttt-stored")
            .title("Stored cell")
            .summary("What has been played at (x, y): read it, play a mark, or clear it.")
            .verb(Verb::Meta)
            // Three verbs, three contracts, so each gets its own action — and each names
            // the coordinates, because an explicit action does not inherit flat inputs.
            .action(
                ActionSpec::new(Verb::Source)
                    .summary("the mark played at (x, y); NotFound if none has been")
                    .input(coordinates()[0].clone())
                    .input(coordinates()[1].clone())
                    .output(TEXT_PLAIN_UTF8),
            )
            .action(
                ActionSpec::new(Verb::Sink)
                    .summary("play a mark at (x, y)")
                    .input(coordinates()[0].clone())
                    .input(coordinates()[1].clone())
                    .input(
                        ArgSpec::new("content")
                            .summary("the mark to play, e.g. X or O")
                            .class(XSD_STRING),
                    )
                    .output(TEXT_PLAIN_UTF8),
            )
            .action(
                ActionSpec::new(Verb::Exists)
                    .summary("true if a mark has been played at (x, y), false if none has")
                    .input(coordinates()[0].clone())
                    .input(coordinates()[1].clone())
                    .output(TEXT_PLAIN_UTF8),
            )
            .action(
                ActionSpec::new(Verb::Delete)
                    .summary("clear the cell at (x, y)")
                    .input(coordinates()[0].clone())
                    .input(coordinates()[1].clone())
                    .output(TEXT_PLAIN_UTF8),
            ),
    )
}

The state lives in a CellStore the host holds, the same shape as Part I’s title, and for the same reason: a test can hold the read counter and prove that an answer was served rather than recomputed.

Look at what the Source arm does not say. It is .cacheable() and names no golden thread. The kernel does both halves of the bookkeeping itself: since ikigai-core 0.1.73 every cacheable read hangs from the thread named after the resource it read, and every successful Sink or Delete cuts the thread named after its target. (Part I’s title still declares .depends_on(TITLE) by hand; it was written before 0.1.73, and the line is now redundant.)

This is where ikigai and the original part company. In the series, the store attaches a golden thread when it reads and cuts it when it writes, explicitly, in the store’s own code. It is an honest design, and it has an honest failure: the series itself records an in-memory store that forgot to cut its thread on DELETE, so a cleared cell went on being served with its old mark from the cache. Here there is no line to forget.

The platonic cell

/// `ttt-cell`: the cell at `(x, y)` as a reader sees it — the mark, or [`EMPTY`].
///
/// A composite: it *resolves* the stored cell through the kernel and turns a `NotFound`
/// into the empty cell. Any other failure (a coordinate in the wrong spelling, say) is
/// passed on untouched: only "nothing has been played" means empty.
///
/// The empty answer is `.cacheable()`, and it is still correct after a mark is played,
/// with no code here to make it so. A sub-request that failed is recorded as a
/// dependency like one that succeeded, so the cached empty cell hangs from the stored
/// cell's thread — and the `Sink` that plays the cell cuts it.
pub fn platonic_cell() -> AsyncFnEndpoint {
    AsyncFnEndpoint::new("ttt-cell", |inv: &Invocation<'_>| -> InvokeFuture<'_> {
        Box::pin(async move {
            let (x, y) = (coordinate(inv, "x")?, coordinate(inv, "y")?);
            let stored = Iri::parse(stored_name(x, y))
                .map_err(|e| Error::Endpoint(format!("the stored cell's name: {e}")))?;
            let mark = match inv.source(&stored).await {
                Ok(played) => played.bytes,
                Err(Error::NotFound(_)) => EMPTY.as_bytes().to_vec(),
                Err(other) => return Err(other),
            };
            Ok(Representation::new(text_plain_utf8(), mark).cacheable())
        })
    })
    .with_description(
        Description::new("ttt-cell")
            .title("Cell")
            .summary("The cell at (x, y): the mark played there, or - where none has been.")
            .verb(Verb::Source)
            .verb(Verb::Meta)
            .input(coordinates()[0].clone())
            .input(coordinates()[1].clone())
            .output(TEXT_PLAIN_UTF8),
    )
}

It resolves the stored cell through the kernel, with inv.source, exactly as a caller outside would, and turns one error into an answer: NotFound becomes -. Any other failure passes through untouched. A store that is down is not an empty cell, and answering it as one would be a lie that caches.

Why this is not a Fallback

The original gets its empty cell from a resolution fallback: a request that misses the store falls through to a default resource that answers every cell. ikigai has the same tool — Fallback tries spaces in order until one resolves the name — and it is the wrong tool here, for a reason worth being exact about.

Fallback acts on resolution: it moves on only when a space does not bind the name. The stored cell is bound by a template, stored:{x}:{y}, which binds every cell, played or not. Resolution always succeeds. The miss happens a step later, at invocation, when the endpoint looks in the store and finds nothing — and by then Fallback has already made its choice. So the honest ikigai form is a composite that catches the invocation’s NotFound, and says so in its code.

The failed read is a dependency

Here is the property that makes the composite more than a workaround. The empty answer is .cacheable(). The next read of the same empty cell is served without the endpoint running. And yet it is never stale:

/// The chapter's first lesson. The platonic cell's empty answer is cached; playing the
/// cell invalidates it; clearing the cell invalidates the mark. Nothing in either
/// endpoint names a thread.
#[test]
fn the_cached_empty_cell_is_cut_by_the_move_that_fills_it() -> Result<()> {
    let store = Arc::new(CellStore::default());
    let kernel = kernel_over(store.clone());
    let (cell, stored) = (cell_name(1, 1), stored_name(1, 1));

    // Empty, computed once and then served: the second read runs no code at all.
    assert_eq!(source(&kernel, &cell)?, EMPTY);
    assert_eq!(source(&kernel, &cell)?, EMPTY);
    assert_eq!(store.reads.load(Ordering::SeqCst), 1);
    assert!(cached(&kernel, &cell));

    // The stored cell's read FAILED — and the failure is still a dependency. So the
    // Sink to the stored cell cuts the thread the cached empty answer hangs from.
    play(&kernel, &stored, "X")?;
    assert!(!cached(&kernel, &cell));
    assert_eq!(source(&kernel, &cell)?, "X");

    // And the other way: the Delete cuts the thread the cached mark hangs from.
    clear(&kernel, &stored)?;
    assert!(!cached(&kernel, &cell));
    assert_eq!(source(&kernel, &cell)?, EMPTY);
    println!("played and cleared: the cell recomputed each time, and only then");
    Ok(())
}

The platonic cell’s inv.source of the stored cell failed. Since ikigai-core 0.1.73 a failed sub-request is recorded as a dependency exactly like a successful one: the cached empty answer hangs from the stored cell’s thread. When the stored cell is played, the Sink cuts that thread, and the empty answer goes with it. Clearing the cell works the same way in reverse. No code in either endpoint mentions the other’s thread, and nothing polls.

That is also why writes go to the stored cell rather than through the platonic one. If the platonic cell accepted a Sink and passed it on, the kernel would cut the platonic cell’s own thread after the write, and the lesson would be hidden: the cache would be invalidated by the name that was written, not by the dependency. Writing to the store directly leaves the dependency as the only thing that can reach the platonic cell’s cache — and it does.

Run it

These cells run against the book’s own kernel, in this page — the same space the tests above run against, under the same engine the ikigai CLI uses. One kernel serves every cell on the page and keeps its cache between runs, so run them in order; running one again answers differently the second time, and that is the point.

Read an empty cell, twice:

-
[computed]
-
[cached]

The first read ran the platonic cell, which asked the store and got NotFound. The second ran nothing. The store itself, asked directly, still has nothing to say:

error: not found: nothing has been played at 1,1

That is NotFound, not no endpoint resolved: the name is bound, and the thing it names is absent. The two errors are different on purpose, and the difference is the whole reason Fallback could not help.

Now play the center. The cached empty cell is still cached until the write lands, and then it is not:

cached
ok
[uncacheable]
not cached
X
[computed]

The Sink named only the stored cell. The platonic cell’s cached answer went because it had failed to read the stored cell, and that failure was recorded. Clear the cell and it comes back empty — computed once, then served:

ok
[uncacheable]
-
[computed]
-
[cached]

Both endpoints describe themselves, so the page can tell you what a cell takes. x and y are bindings — captured from the name by the template, not passed as arguments:

@prefix ik: <https://ikigai-rs.dev/ns#> .
 
<urn:ikigai:endpoint:ttt-cell> a ik:Endpoint ;
    ik:id "ttt-cell" ;
    ik:title "Cell" ;
    ik:summary "The cell at (x, y): the mark played there, or - where none has been." ;
    ik:verb "Source", "Meta" ;
    ik:output "text/plain;charset=utf-8" ;
    ik:input <urn:ikigai:endpoint:ttt-cell:input:x> ;
    ik:input <urn:ikigai:endpoint:ttt-cell:input:y> ;
    ik:action <urn:ikigai:contract:ttt-cell:source:b3:…> .
 
<urn:ikigai:endpoint:ttt-cell:input:x> ik:inputName "x" ;
    ik:source "binding" ;
    ik:required true ;
    ik:summary "the column — any integer" ;
    ik:class <http://www.w3.org/2001/XMLSchema#integer> .
 
<urn:ikigai:endpoint:ttt-cell:input:y> ik:inputName "y" ;
    ik:source "binding" ;
    ik:required true ;
    ik:summary "the row — any integer" ;
    ik:class <http://www.w3.org/2001/XMLSchema#integer> .
 
<urn:ikigai:contract:ttt-cell:source:b3:…:input:x> ik:inputName "x" ;
    ik:source "binding" ;
    ik:required true ;
    ik:summary "the column — any integer" ;
    ik:class <http://www.w3.org/2001/XMLSchema#integer> .
 
<urn:ikigai:contract:ttt-cell:source:b3:…:input:y> ik:inputName "y" ;
    ik:source "binding" ;
    ik:required true ;
    ik:summary "the row — any integer" ;
    ik:class <http://www.w3.org/2001/XMLSchema#integer> .
 
<urn:ikigai:contract:ttt-cell:source:b3:…> a ik:Action ;
    ik:verb "Source" ;
    ik:output "text/plain;charset=utf-8" ;
    ik:input <urn:ikigai:contract:ttt-cell:source:b3:…:input:x> ;
    ik:input <urn:ikigai:contract:ttt-cell:source:b3:…:input:y> .
[computed]

The b3:… in the contract’s name stands for a digest of the contract, which this book does not spell out; What resolution buys you says why.

Loose on purpose

{x} and {y} are any integers — -1, 3, a cell far off the board — and not 0 to 2. Writing the board’s edges into the grammar would make the model watertight for tic-tac-toe and useless for anything else (Connect Four is a board of cells too), so the atoms stay loose and the rules arrive at the edge of the application, in part III.

/// A coordinate, captured from the name by the template.
///
/// **Any** integer: `-3` and `40` are cells as surely as `1` is. The model is loose on
/// purpose; a board's edges are a rule of one game, and a rule arrives at the edge of the
/// application, not in the grammar of its atoms.
///
/// But exactly one **spelling** per integer. `01`, `+1` and `-0` are refused, because two
/// names for one cell would be two golden threads over one piece of state: a write
/// through one name would cut its own thread and leave a cached read under the other
/// serving the old mark.
fn coordinate(inv: &Invocation<'_>, name: &str) -> Result<i64> {
    let text = inv
        .bindings
        .get(name)
        .ok_or_else(|| Error::MissingArgument(name.to_string()))?;
    plain_integer(name, text)
}

/// `text` as an integer, if it is spelled the one way that integer is spelled.
fn plain_integer(name: &str, text: &str) -> Result<i64> {
    match text.parse::<i64>() {
        Ok(value) if value.to_string() == text => Ok(value),
        _ => Err(Error::InvalidArgument {
            name: name.to_string(),
            detail: format!("`{text}` is not an integer in its plain form (e.g. 0, 2, -1)"),
        }),
    }
}

Loose about which integers, strict about how each one is spelled. 01 and 1 are the same number, and two names for one cell would be two threads over one piece of state: playing through one name would cut its own thread and leave a cached read under the other serving the old mark. One spelling per cell keeps one thread per cell.

Next

Part II gathers cells into lines by name, makes the rows, columns and diagonals from those names with no code at all, and shows that one move recomputes the lines through it and the board — and nothing else.

Tic-tac-toe, II: lines, aliases and the board

Part I built the atom: a stored cell that holds a mark, and a platonic cell that answers - where nothing has been played. Everything else in the game is a gathering of cells, or a question about one, and this part builds the gatherings: a line of cells, the eight lines a board has, and the board. It follows the fourth part of the series Part I credits, which is where the original makes the same move.

Only one of the three new things is an endpoint worth reading. The eight lines are not code at all, and the board is a dozen lines that read three names. The lesson is what that arrangement does when a mark is played.

A line of cells has a name

A line of cells is named by its members, in order: urn:iki:tutorial:ttt:cells:0.0,1.0,2.0 is the cells at (0,0), (1,0) and (2,0), read in that order. Each member is x.y, and members are joined by commas.

/// A line of cells: the members, in order, each spelled `x.y`, joined by `,`.
pub const CELLS: &str = "urn:iki:tutorial:ttt:cells:{list}";

/// The board: every row, top to bottom.
pub const BOARD: &str = "urn:iki:tutorial:ttt:board";

It is a sequence rather than a set, because a row reads left to right: the same three cells named the other way round are another line, and answer the other way round. And the model stays as loose as the cell’s. Any coordinates, any length, the same cell twice if you like — nothing in the name knows a board is three by three.

/// The members of a line, read out of its name: `0.0,1.0,2.0` is `(0,0) (1,0) (2,0)`.
///
/// The same strictness as a single cell's coordinates, for the same reason. Every member
/// is two integers in their plain form, joined by one `.`; members are joined by one `,`;
/// nothing else — no spaces, no empty member, no trailing comma. A list has exactly one
/// spelling, so a line has exactly one name, and one name is one cache entry and one
/// golden thread. The model stays loose about *which* cells: any coordinates, any
/// length, repeats allowed.
fn members(inv: &Invocation<'_>) -> Result<Vec<(i64, i64)>> {
    let list = inv
        .bindings
        .get("list")
        .ok_or_else(|| Error::MissingArgument("list".to_string()))?;
    let refuse = |detail: String| Error::InvalidArgument {
        name: "list".to_string(),
        detail,
    };
    list.split(',')
        .map(|member| match member.split_once('.') {
            _ if member.is_empty() => Err(refuse(
                "an empty member — cells are x.y, joined by single commas".to_string(),
            )),
            Some((x, y)) => Ok((plain_integer("list", x)?, plain_integer("list", y)?)),
            None => Err(refuse(format!(
                "`{member}` is not a cell spelled x.y (e.g. 0.2)"
            ))),
        })
        .collect()
}

The one-spelling rule from Part I carries over, and for the same reason. Two spellings of one line would be two names over one answer, so two cache entries, and a move would have to find both. So a member is two integers in their plain form joined by one ., members are joined by one ,, and there is nothing else: no spaces, no empty member, no trailing comma. Both separators are safe everywhere the name travels. RFC 8141 allows , and . in a URN. The engine’s grammar treats only whitespace, quotes, |, (, ), ; and a .. standing alone as syntax. And a template’s last variable captures the rest of the name, commas included.

/// `ttt-cells`: a line of cells — the marks of its members, side by side, in order.
///
/// A composite over the platonic cell, and nothing more: it sources each member through
/// the kernel and concatenates the answers, so an empty cell reads `-` and a row reads
/// like `X-O`. It is `.cacheable()` and names no thread; its dependencies are its
/// sub-requests, so a move that changes one member cuts this line's cached answer and
/// leaves every line that does not pass through that cell alone.
pub fn line_of_cells() -> AsyncFnEndpoint {
    AsyncFnEndpoint::new("ttt-cells", |inv: &Invocation<'_>| -> InvokeFuture<'_> {
        Box::pin(async move {
            let mut marks = Vec::new();
            for (x, y) in members(inv)? {
                let cell = Iri::parse(cell_name(x, y))
                    .map_err(|e| Error::Endpoint(format!("a cell's name: {e}")))?;
                marks.extend(inv.source(&cell).await?.bytes);
            }
            Ok(Representation::new(text_plain_utf8(), marks).cacheable())
        })
    })
    .with_description(
        Description::new("ttt-cells")
            .title("Line of cells")
            .summary("The marks of the named cells, in order, side by side: X-O.")
            .verb(Verb::Source)
            .verb(Verb::Meta)
            .input(
                ArgSpec::new("list")
                    .summary("the cells, in order: x.y joined by commas, e.g. 0.0,1.0,2.0")
                    .class(XSD_STRING)
                    .binding(),
            )
            .output(TEXT_PLAIN_UTF8),
    )
}

The endpoint is a composite over the platonic cell and nothing more. It sources each member through the kernel, as any caller would, and puts the marks side by side. It is .cacheable() and names no golden thread. Its dependencies are its sub-requests, and Part I showed what the kernel does with those.

As in Part I, one kernel serves every cell on this page and keeps its cache between runs, so run them in order. A page starts on a fresh board. Here is a row of three empty cells, and a stranger line of two, one of them far off any board:

---
[computed]
--
[computed]

And the second spellings, refused. A trailing comma is an empty member, and 01 is 1 spelled another way:

error: invalid argument `list`: an empty member — cells are x.y, joined by single commas
       `list`: the cells, in order: x.y joined by commas, e.g. 0.0,1.0,2.0
error: invalid argument `list`: `01` is not an integer in its plain form (e.g. 0, 2, -1)
       `list`: the cells, in order: x.y joined by commas, e.g. 0.0,1.0,2.0

The first line of each is the endpoint’s refusal, word for word. The indented line under it is the shell’s, not the game’s: since ikigai-cli 0.1.63, a refusal of one named argument is followed by what that argument’s own description says it takes, read from the endpoint’s contract, so the fix sits next to the complaint. The game declared that description once, in its ArgSpec, and never wrote it into an error message.

Rows, columns and diagonals: eight names, no code

A row is a line of cells with a shorter name. So it is written as exactly that: a name for a name. Eight exact rules, as text:

/// The rows, columns and diagonals of a 3×3 board, as names for lines of cells.
///
/// This table is where "three by three" lives — not in the cell, which takes any
/// coordinates, and not in the line, which takes any cells. `y` runs down the board, so
/// row 0 is the top row; a line reads left to right, or top to bottom, and diagonal 1
/// runs from the top-right corner to the bottom-left.
pub const LINES: &str = "\
exact urn:iki:tutorial:ttt:row:0      urn:iki:tutorial:ttt:cells:0.0,1.0,2.0
exact urn:iki:tutorial:ttt:row:1      urn:iki:tutorial:ttt:cells:0.1,1.1,2.1
exact urn:iki:tutorial:ttt:row:2      urn:iki:tutorial:ttt:cells:0.2,1.2,2.2
exact urn:iki:tutorial:ttt:column:0   urn:iki:tutorial:ttt:cells:0.0,0.1,0.2
exact urn:iki:tutorial:ttt:column:1   urn:iki:tutorial:ttt:cells:1.0,1.1,1.2
exact urn:iki:tutorial:ttt:column:2   urn:iki:tutorial:ttt:cells:2.0,2.1,2.2
exact urn:iki:tutorial:ttt:diagonal:0 urn:iki:tutorial:ttt:cells:0.0,1.1,2.2
exact urn:iki:tutorial:ttt:diagonal:1 urn:iki:tutorial:ttt:cells:2.0,1.1,0.2
";
/// The game's space: the endpoints, wrapped in the table that names the lines.
pub fn space() -> Alias {
    space_over(Arc::default())
}

/// [`space`], with the stored cells in a [`CellStore`] the caller keeps — so a test can
/// read the counter.
pub fn space_over(store: Arc<CellStore>) -> Alias {
    space_with_store(Arc::new(stored_space(store)))
}

/// The game over ANY store: every composite, then `store` for the stored cells, all
/// wrapped in the table that names the lines.
///
/// Nothing here holds state. `store` is whatever binds [`STORED`] — the in-memory
/// [`stored_space`], or a peer in another process or language — and the composites reach
/// it by name, through the kernel, so they cannot tell which one answered.
pub fn space_with_store(store: Arc<dyn Space>) -> Alias {
    // One table, shared: the aliases rewrite through it, and the rules read it.
    let table = Arc::new(lines());
    let composites = EndpointSpace::new()
        .bind(template(CELL), platonic_cell())
        .bind(template(CELLS), line_of_cells())
        .bind(template(BOARD), board())
        .bind(template(CHECKSET), checkset(Arc::clone(&table)))
        .bind(template(WINNER), winner(Arc::clone(&table)))
        .bind(template(TURN), turn())
        .bind(template(MOVE), play_move())
        .bind(template(RESET), reset(Arc::clone(&table)))
        .bind(template(TEMPLATE), templates())
        .bind(template(VIEW_BOARD), view("board"))
        .bind(template(VIEW_SQUARE), view("square"))
        .bind(template(VIEW_STATUS), view("status"))
        .bind(template(VIEW_REPLY), view("reply"))
        .bind(template(VIEW_GAME), view("game"))
        .bind(template(VIEW_PLAY), view_play())
        .bind(template(VIEW_RESET), view_reset());
    Alias::new(
        table,
        Arc::new(Fallback::new(vec![Arc::new(composites), store])),
    )
}

/// A store: the stored cell over one [`CellStore`], and nothing else.
pub fn stored_space(store: Arc<CellStore>) -> EndpointSpace {
    EndpointSpace::new().bind(template(STORED), stored_cell(store))
}

The game’s space is its bound endpoints wrapped in an Alias that rewrites through that table. The first three are this part’s and the one before it; the next four are the rules, which Part III builds, reading the same table; the rest (a reset, the templates and the views) arrive in Part V. The stored cell sits in a space of its own after them, because Part IV swaps it per game. No endpoint implements a row. urn:iki:tutorial:ttt:row:0 resolves because the table rewrites it to urn:iki:tutorial:ttt:cells:0.0,1.0,2.0, and the line endpoint answers that. The table parses from text, so it could as easily have been read from a file. Nothing about it is compiled logic.

This table is also where “three by three” lives, and nowhere else does. The cell takes any coordinates and the line takes any cells, so the size of the board is the one thing the game has to say for itself, and it says it here, as data. Connect Four is a board of seven columns and six rows, won by four in a line, and it has sixty-nine such lines. It would be another table over the same two endpoints, not another crate. That is what keeping Part I’s model loose was for: the atoms do not change when the game does, only the names laid over them.

y runs down the board, so row 0 is the top row. A line reads left to right, or top to bottom, and diagonal 1 runs from the top-right corner to the bottom-left.

One name for the row, one cache entry

An alias is a second name, not a second resource. The Alias reports the name it rewrote to, and the kernel files the answer under that name before it computes the cache key. So row:1 and the line it names are one cache entry, and a read through either name serves the other:

/// An alias is a second NAME, not a second resource: the row and the line it names are
/// one cache entry. The second read runs nothing at all.
#[test]
fn a_row_and_its_line_are_one_cache_entry() -> Result<()> {
    let store = Arc::new(CellStore::default());
    let kernel = kernel_over(store.clone());

    assert_eq!(source(&kernel, &row_name(1))?, "---");
    let reads = store.reads.load(Ordering::SeqCst);
    assert!(cached(&kernel, ROW_1), "cached under the line's own name");

    assert_eq!(source(&kernel, ROW_1)?, "---");
    assert_eq!(
        store.reads.load(Ordering::SeqCst),
        reads,
        "served, not recomputed"
    );
    Ok(())
}

Read the row by its short name, then ask about the line by its own:

---
[computed]
cached
---
[cached]

The line was never read under its own name, and it is cached. That is what a zero-code alias over a set buys in ikigai: the eight short names add nothing to the cache and nothing to invalidate. Whatever reaches the line reaches the row, because they are one thing.

Two consequences, both of them core’s decisions rather than this game’s. Ask the row what it is (describe urn:iki:tutorial:ttt:row:0 text/turtle) and it describes ttt-cells, because a description reports the backing resource. And probe the line’s cache by the line’s name, as the cell above does. cache asks without resolving, and a rewrite made inside a space is known only by resolving through it, so cache urn:iki:tutorial:ttt:row:1 answers not cached even while the entry is there. The probe under-reports a hit; it never invents one. (A table installed on the kernel itself with Kernel::with_aliases is known to the probe. This one travels with the game’s space instead, so that the page and every other host get the rows with no wiring of their own.)

Nor does the catalog list the aliases. It lists what is bound — cells:{list} once, and board — and the table is data of its own: in urn:kernel:topology, the game’s space is an ik:Alias node carrying eight ik:RewriteRules, each with its logical and canonical name, above the spaces that bind the templates. Type source urn:kernel:topology into any cell on this page to see the whole arrangement as a graph.

The board

/// `ttt-board`: the three rows, top to bottom, one per line of text.
///
/// It reads the rows by their alias names, through the kernel, like any other caller —
/// it does not know that a row is a line of cells, or that a line is cells. Cacheable,
/// with no thread named: the move that changes one cell reaches the board through the
/// one row that holds the cell, and through nothing else.
pub fn board() -> AsyncFnEndpoint {
    AsyncFnEndpoint::new("ttt-board", |inv: &Invocation<'_>| -> InvokeFuture<'_> {
        Box::pin(async move {
            let mut rows = Vec::new();
            for y in 0..3 {
                let row = Iri::parse(row_name(y))
                    .map_err(|e| Error::Endpoint(format!("a row's name: {e}")))?;
                rows.push(String::from_utf8_lossy(&inv.source(&row).await?.bytes).into_owned());
            }
            Ok(Representation::new(text_plain_utf8(), rows.join("\n").into_bytes()).cacheable())
        })
    })
    .with_description(
        Description::new("ttt-board")
            .title("Board")
            .summary("The board: row 0 (the top row) to row 2, one row per line.")
            .verb(Verb::Source)
            .verb(Verb::Meta)
            .output(TEXT_PLAIN_UTF8),
    )
}

The board reads the three rows by their short names, through the kernel, like any other caller. It does not know that a row is a line, or that a line is cells, and it does not need to. It answers them top to bottom, one row per line of text:

---
---
---
[computed]

The board was computed, but not all of it was. Row 1 was already cached from the cell above, so the board’s read of it ran nothing.

One move, and what it recomputes

Here is what the arrangement is for. The board depends on three rows, each row on three cells, each cell on its stored mark, and the kernel recorded every one of those dependencies as it resolved them. So a move reaches exactly what was derived from the cell it changed:

/// One move recomputes the cell, the lines through it and the board — and nothing else.
#[test]
fn one_move_recomputes_only_what_passes_through_it() -> Result<()> {
    let store = Arc::new(CellStore::default());
    let kernel = kernel_over(store.clone());

    // Read the board and every line once: all of it computed, all of it cached.
    source(&kernel, BOARD)?;
    for line in [COLUMN_0, COLUMN_1, COLUMN_2, DIAGONAL_0, DIAGONAL_1] {
        source(&kernel, line)?;
    }

    // Play the top-left corner. It is on one row, one column and one diagonal.
    play(&kernel, 0, 0, "X")?;
    for through in [ROW_0, COLUMN_0, DIAGONAL_0, BOARD] {
        assert!(!cached(&kernel, through), "{through} passes through (0,0)");
    }
    for past in [ROW_1, ROW_2, COLUMN_1, COLUMN_2, DIAGONAL_1] {
        assert!(cached(&kernel, past), "{past} does not");
    }

    // Read the board again. Of the nine stored cells, exactly one is read again: the
    // corner. Row 0's other two cells, and rows 1 and 2 whole, are served from cache.
    let reads = store.reads.load(Ordering::SeqCst);
    assert_eq!(source(&kernel, BOARD)?, "X--\n---\n---");
    assert_eq!(store.reads.load(Ordering::SeqCst), reads + 1);
    Ok(())
}

Run it here. First read some lines the board does not: the column and the diagonal through the top-left corner, and one of each that miss it:

---
[computed]
---
[computed]
---
[computed]
---
[computed]

Now play the corner. The four probes are row 0, column 0, diagonal 0 and the board, the four things that pass through (0,0). None of them is cached any more:

ok
[uncacheable]
not cached
not cached
not cached
not cached

And the ones that miss it are row 1, row 2, column 1 and diagonal 1. All four are still cached:

cached
cached
cached
cached

The Sink named one stored cell. Nothing in any endpoint named a row, a column or the board, and nothing walked the board looking for what to throw away. The dependencies were recorded when the answers were computed, and the cut followed them.

Now read the board again, traced, to see what it recomputes:

trace  urn:iki:tutorial:ttt:board
  client      ikigai repl  ·  capability: root (full authority)
  transport   embedded · in-process
 
urn:iki:tutorial:ttt:board   ttt-board · computed · ThreadId(1) · —   → 11b  X--…
├─ urn:iki:tutorial:ttt:cells:0.0,1.0,2.0   ttt-cells · computed · ThreadId(1) · — · alias=urn:iki:tutorial:ttt:row:0 -> urn:iki:tutorial:ttt:cells:0.0,1.0,2.0
│  ├─ urn:iki:tutorial:ttt:cell:0:0   ttt-cell · computed · ThreadId(1) · —
│  │  └─ urn:iki:tutorial:ttt:stored:0:0   ttt-stored · computed · ThreadId(1) · —
│  ├─ urn:iki:tutorial:ttt:cell:1:0   ttt-cell · cached · ThreadId(1) · —
│  └─ urn:iki:tutorial:ttt:cell:2:0   ttt-cell · cached · ThreadId(1) · —
├─ urn:iki:tutorial:ttt:cells:0.1,1.1,2.1   ttt-cells · cached · ThreadId(1) · — · alias=urn:iki:tutorial:ttt:row:1 -> urn:iki:tutorial:ttt:cells:0.1,1.1,2.1
└─ urn:iki:tutorial:ttt:cells:0.2,1.2,2.2   ttt-cells · cached · ThreadId(1) · — · alias=urn:iki:tutorial:ttt:row:2 -> urn:iki:tutorial:ttt:cells:0.2,1.2,2.2

Read it from the top. The board was computed. Row 0 was computed, and inside it only the corner went back to the store: its two neighbors were served from cache. Rows 1 and 2 were served whole, and their six cells were never asked, so they are not in the tree at all. Each row carries a note, alias=… -> …, which is the name the board asked for beside the name the kernel filed it under. Of nine stored cells, one was read.

That is the normalized-recompute property, and it comes from the shape of the design rather than from any cleverness in the code. Each resource depends only on the resources it is made of, so a change travels up exactly the paths that lead to it. (The corner shows its stored cell as a child because it has been played. An unplayed cell that recomputed would show as one line: its failed read of the store is recorded as a dependency, but a trace in this release does not draw it.)

One more move, in the center, and the board again: computed once, then served.

ok
[uncacheable]
X--
-O-
---
[computed]
X--
-O-
---
[cached]

The center is on four lines (row 1, column 1 and both diagonals), a corner on three, and an edge on two. That count is about to matter.

Next

Part III asks questions of the board. Which lines a cell is on becomes a resource of its own: a representation that is a list of the names of other resources. Whose turn it is and who has won are questions about those lines, and the rules of tic-tac-toe arrive there, at the edge of the application, with the model underneath still loose.

Tic-tac-toe, III: the rules as resources

Part II built the gatherings: a line of cells, the eight lines a board has, and the board. None of it knows the rules of tic-tac-toe. This part adds them, and adds them the same way as everything else: as resources. Which lines a square is on, who has won and whose turn it is are each a name that answers. Then comes the one resource that changes the game, the move, and the rules are enforced there and nowhere else. It covers the ground of the sixth and seventh parts of the series Part I credits.

/// Which lines pass through `(x, y)`: a list of their names.
pub const CHECKSET: &str = "urn:iki:tutorial:ttt:checkset:{x}:{y}";

/// Who has won: `X`, `O`, [`EMPTY`] for nobody yet, or [`DRAW`].
pub const WINNER: &str = "urn:iki:tutorial:ttt:winner";

/// Whose move it is: `X` or `O`, or [`EMPTY`] once the game is over.
pub const TURN: &str = "urn:iki:tutorial:ttt:turn";

/// Play the side to move at `(x, y)` — `Sink` only.
pub const MOVE: &str = "urn:iki:tutorial:ttt:move:{x}:{y}";

/// What the winner answers when every square is played and no line is won.
pub const DRAW: &str = "draw";

Which lines a square is on

The center of the board is on four lines, a corner on three and an edge on two. That is a fact about the board, and it can be a resource of its own: the CheckSet, urn:iki:tutorial:ttt:checkset:{x}:{y}, which answers the names of the lines through a square, one per line of text.

/// The names of the lines `table` makes that pass through the cell spelled `member`
/// (`x.y`), in name order.
///
/// Read from the table, never listed by hand: a line passes through a cell when its
/// target's member list holds that cell. A string match is exact because a cell has one
/// spelling. The answer is the ALIAS names — `row:1`, not `cells:0.1,1.1,2.1` — because
/// they read as the game; each resolves to its line, and the line's name is the cache key.
/// Sorted, because a table keeps its rules most-specific-first, not in the order written.
fn lines_through<'t>(table: &'t AliasTable, member: &str) -> Vec<&'t str> {
    let line = CELLS.trim_end_matches("{list}");
    let mut names: Vec<&str> = table
        .rules()
        .iter()
        .filter(|rule| {
            rule.to()
                .strip_prefix(line)
                .is_some_and(|list| list.split(',').any(|m| m == member))
        })
        .map(|rule| rule.from())
        .collect();
    names.sort_unstable();
    names
}

/// `ttt-checkset`: the lines through `(x, y)`, one name per line of text.
///
/// A representation whose content is the names of other resources. It depends on its
/// name and a constant table and on nothing else — no sub-request, so no dependency any
/// `Sink` could cut — and `.cacheable()` is `Expiry::Never`: computed once in the life of
/// the kernel, and served from then on, whatever is played. A cell off the board is on no
/// line and answers the empty list; the cell itself is still a cell.
pub fn checkset(table: Arc<AliasTable>) -> FnEndpoint {
    FnEndpoint::new("ttt-checkset", move |inv: &Invocation<'_>| {
        let member = format!("{}.{}", coordinate(inv, "x")?, coordinate(inv, "y")?);
        let names = lines_through(&table, &member).join("\n");
        Ok(Representation::new(text_plain_utf8(), names.into_bytes()).cacheable())
    })
    .with_description(
        Description::new("ttt-checkset")
            .title("CheckSet")
            .summary(
                "The names of the lines through (x, y), one per line; none for a cell off \
                 the board.",
            )
            .verb(Verb::Source)
            .verb(Verb::Meta)
            .input(coordinates()[0].clone())
            .input(coordinates()[1].clone())
            .output(TEXT_PLAIN_UTF8),
    )
}

Look at what the answer is. It is not marks, and it is not a board. It is names of other resources, and each one can be resolved. A representation whose content is links is what makes data linked, and nothing about it needed a new mechanism: a list of names is text, and newline-separated text is how the ecosystem writes a list.

The list is not written anywhere by hand. The CheckSet reads Part II’s table of eight aliases and keeps each alias whose line holds the square. So “three by three” still lives in that table and nowhere else. A board of another size, or Connect Four’s sixty-nine lines, is another table, and the CheckSet would answer for it with no change of code.

It answers the alias names, row:1 rather than cells:0.1,1.1,2.1, because those read as the game. Each one resolves, through the alias, to its line, and the line’s own name is the one the cache files the answer under.

As before, one kernel serves every cell on this page, and the page starts on a fresh board. Ask the center, twice:

urn:iki:tutorial:ttt:column:1
urn:iki:tutorial:ttt:diagonal:0
urn:iki:tutorial:ttt:diagonal:1
urn:iki:tutorial:ttt:row:1
[computed]
urn:iki:tutorial:ttt:column:1
urn:iki:tutorial:ttt:diagonal:0
urn:iki:tutorial:ttt:diagonal:1
urn:iki:tutorial:ttt:row:1
[cached]

A corner, an edge, and a square off the board. The last answers the empty list. It is still a cell (Part I’s model takes any coordinates), but no line passes through it, and only the table knows where the board is:

urn:iki:tutorial:ttt:column:0
urn:iki:tutorial:ttt:diagonal:0
urn:iki:tutorial:ttt:row:0
[computed]
urn:iki:tutorial:ttt:column:1
urn:iki:tutorial:ttt:row:0
[computed]
[computed]

A platonic resource

The CheckSet depends on its own name and a constant table, and on nothing else. It issues no sub-request, so it records no dependency that a Sink could cut. Its .cacheable() is Expiry::Never, and here that is literally true: once computed, it is served for the life of the kernel, whatever is played. The series has a name for a resource like this, a platonic resource, computed once in the life of the system. The cell in Part I was platonic in a weaker sense, because it answers for every square before anyone plays one. The CheckSet is platonic in the strong one: no move can make it wrong, so none ever recomputes it.

/// The CheckSet names the lines through a cell by their alias names, and is computed once:
/// no move — not even one on that very square — un-caches it.
#[test]
fn the_checkset_is_a_list_of_names_computed_once() -> Result<()> {
    let kernel = kernel_over(Arc::default());
    let center = checkset_name(1, 1);

    assert_eq!(
        source(&kernel, &center)?,
        "urn:iki:tutorial:ttt:column:1\n\
         urn:iki:tutorial:ttt:diagonal:0\n\
         urn:iki:tutorial:ttt:diagonal:1\n\
         urn:iki:tutorial:ttt:row:1"
    );
    assert!(cached(&kernel, &center));

    play(&kernel, 1, 1)?;
    play(&kernel, 0, 0)?;
    sink(&kernel, &stored_name(1, 1), "O")?; // straight into the store, past the rules
    assert!(cached(&kernel, &center), "nothing it depends on can change");
    Ok(())
}

The end of this page checks the same thing in the page’s own kernel, after a whole game.

Who has won, and whose turn it is

/// The mark that fills `line` — every mark one kind, and not the empty cell — if one does.
fn filled_by(line: &str) -> Option<char> {
    let mut marks = line.chars();
    let first = marks.next()?;
    (first.to_string() != EMPTY && marks.all(|mark| mark == first)).then_some(first)
}

/// `ttt-winner`: `X` or `O` if a line is filled by one of them, [`DRAW`] if every square
/// is played and none is, and [`EMPTY`] while the game goes on.
///
/// A pure function of the lines, read by their alias names from the table, through the
/// kernel. It names no thread: its dependencies are the eight lines, so a move recomputes
/// the lines through its square and this, and serves the others from cache. A board no
/// game reaches — both marks with a full line, played straight into the store — answers
/// the mark of whichever full line the table lists first.
pub fn winner(table: Arc<AliasTable>) -> AsyncFnEndpoint {
    AsyncFnEndpoint::new(
        "ttt-winner",
        move |inv: &Invocation<'_>| -> InvokeFuture<'_> {
            let table = Arc::clone(&table);
            Box::pin(async move {
                let mut lines = Vec::new();
                for rule in table.rules() {
                    lines.push(text_of(inv, rule.from()).await?);
                }
                let answer = match lines.iter().find_map(|line| filled_by(line)) {
                    Some(mark) => mark.to_string(),
                    None if lines.iter().all(|line| !line.contains(EMPTY)) => DRAW.to_string(),
                    None => EMPTY.to_string(),
                };
                Ok(Representation::new(text_plain_utf8(), answer.into_bytes()).cacheable())
            })
        },
    )
    .with_description(
        Description::new("ttt-winner")
            .title("Winner")
            .summary("X or O once a line is theirs, draw on a full board, - until then.")
            .verb(Verb::Source)
            .verb(Verb::Meta)
            .output(TEXT_PLAIN_UTF8),
    )
}

The winner reads the eight lines by their alias names, from the same table, through the kernel. A line is won when all three of its marks are one mark and not the empty cell. If none is won and no line has an empty cell left, the board is full, and the answer is draw. Otherwise it is -, for nobody yet.

It is a pure function of the lines, so it is .cacheable(), and like the board in Part II it names no golden thread. Its dependencies are the eight lines it read. A move reaches it through the lines that pass through the move’s square, and through nothing else.

This is where the series puts the CheckSet to work. Checking for a win after a move, it looks only at the lines through the square just played, because the other lines cannot have changed. Here the kernel does that narrowing on its own. The winner asks for all eight lines, and the ones a move did not touch answer from cache without running any code. The CheckSet is still worth having, because the move below asks it where the board is. It is just not needed to make the winner cheap.

/// `ttt-turn`: whose move it is — `X` when the marks are level (so `X` opens), `O` when
/// `X` is ahead — and [`EMPTY`] once the winner says the game is over.
///
/// A pure function of two resources, the winner and the board, and cacheable for the same
/// reason they are.
pub fn turn() -> AsyncFnEndpoint {
    AsyncFnEndpoint::new("ttt-turn", |inv: &Invocation<'_>| -> InvokeFuture<'_> {
        Box::pin(async move {
            let side = if text_of(inv, WINNER).await? != EMPTY {
                EMPTY
            } else {
                let board = text_of(inv, BOARD).await?;
                let count = |mark: char| board.chars().filter(|&c| c == mark).count();
                if count('X') <= count('O') {
                    "X"
                } else {
                    "O"
                }
            };
            Ok(Representation::new(text_plain_utf8(), side.as_bytes().to_vec()).cacheable())
        })
    })
    .with_description(
        Description::new("ttt-turn")
            .title("Turn")
            .summary("Whose move it is: X (who opens) or O; - once the game is over.")
            .verb(Verb::Source)
            .verb(Verb::Meta)
            .output(TEXT_PLAIN_UTF8),
    )
}

Whose turn it is comes from the board. X opens, so X plays when the marks are level and O plays when X is one ahead. Once the winner says the game is over, whether won or drawn, nobody plays, and the turn answers -. The turn is a pure function of two resources, the winner and the board, and it is cacheable for the same reason they are.

A fresh board: nobody has won, and X is to play.

-
[computed]
X
[computed]

A draw takes nine moves to reach, so this page does not play one. The crate’s tests do: a full board with no line answers draw, the turn answers -, and a tenth move is refused.

The move: constraint at the edge

So far nothing in the game refuses anything. The stored cell takes any mark at any square, the line takes any cells, and the winner and the turn only describe what is there. That is deliberate. The rules arrive in one resource, the move, urn:iki:tutorial:ttt:move:{x}:{y}, which a player writes to and nobody reads.

/// `ttt-move`: play the side to move at `(x, y)` — the one place the rules are enforced.
///
/// Each check reads a resource the game already has, and the move itself is a `Sink` to
/// the stored cell issued through the invocation — the same request a player could make
/// by hand, and so the same cut of the stored cell's thread. Nothing about turns or boards
/// moves into the stored cell, which still takes any mark at any square.
///
/// Refused, with [`Error::InvalidArgument`] naming `x` or `y` (whichever is off the board):
/// a square no line passes through. Refused, with [`Error::Conflict`] — the arguments are
/// fine and the game's state does not permit the move: any square once the game is over, a
/// square already played, and a `content` mark that is not the side to play. `content` is
/// optional; left out, the move plays whoever's turn it is.
pub fn play_move() -> AsyncFnEndpoint {
    AsyncFnEndpoint::new("ttt-move", |inv: &Invocation<'_>| -> InvokeFuture<'_> {
        Box::pin(async move {
            if inv.request.verb != Verb::Sink {
                return Err(Error::Endpoint(
                    "a move is played, not read: `sink` it (read the board instead)".into(),
                ));
            }
            let (x, y) = (coordinate(inv, "x")?, coordinate(inv, "y")?);
            if text_of(inv, &checkset_name(x, y)).await?.is_empty() {
                // Either coordinate may be the one off the board; name the one that is.
                let name = if (0..3).contains(&x) { "y" } else { "x" };
                return Err(Error::InvalidArgument {
                    name: name.to_string(),
                    detail: format!("{x},{y} is off the board — no line passes through it"),
                });
            }
            match text_of(inv, WINNER).await?.as_str() {
                won if won == EMPTY => {}
                DRAW => return Err(Error::Conflict("the game is over — a draw".to_string())),
                mark => {
                    return Err(Error::Conflict(format!(
                        "the game is over — {mark} has won"
                    )))
                }
            }
            let there = text_of(inv, &cell_name(x, y)).await?;
            if there != EMPTY {
                return Err(Error::Conflict(format!(
                    "{x},{y} is taken — {there} played there"
                )));
            }
            let side = text_of(inv, TURN).await?;
            let offered = match inv.inline_str("content") {
                Ok(mark) => mark.trim(),
                Err(Error::MissingArgument(_)) => "",
                Err(other) => return Err(other),
            };
            if !offered.is_empty() && offered != side {
                return Err(Error::Conflict(format!(
                    "it is {side}'s turn, not {offered}'s"
                )));
            }
            let stored = Iri::parse(stored_name(x, y))
                .map_err(|e| Error::Endpoint(format!("the stored cell's name: {e}")))?;
            let play = Request::new(Verb::Sink, stored)
                .with_arg("content", ArgRef::Inline(side.clone().into_bytes()));
            inv.issue(play).await?;
            Ok(Representation::new(
                text_plain_utf8(),
                format!("{side} plays {x},{y}").into_bytes(),
            ))
        })
    })
    .with_description(
        Description::new("ttt-move")
            .title("Move")
            .summary(
                "Play the side to move at (x, y), if the rules allow it: on the board, the \
                 game not over, the square free, and the mark (if given) the side to play.",
            )
            .verb(Verb::Sink)
            .verb(Verb::Meta)
            .input(coordinates()[0].clone())
            .input(coordinates()[1].clone())
            .input(
                ArgSpec::new("content")
                    .summary(
                        "the mark to play — checked against the turn; left out, the turn plays",
                    )
                    .class(XSD_STRING)
                    .one_of(["X", "O"])
                    .optional(),
            )
            .output(TEXT_PLAIN_UTF8),
    )
}

The move enforces the rules by asking the resources the game already has. The CheckSet says whether the square is on the board, the winner whether the game is over, the cell whether the square is free, and the turn whose mark it is. A square off the board is an InvalidArgument, naming the coordinate that is off it: that argument is wrong. The other refusals (the game is over, the square is taken, the wrong mark) are a Conflict: the arguments are fine and the game’s state does not permit the move. Each carries a reason a player can act on. Only when all four agree does the move play, and it plays by issuing a Sink to the stored cell, the same request a player could make by hand.

content is optional and checked. Left out, the move plays whoever’s turn it is. Named, it must be the side to play, so a player who believes it is their turn and is wrong is told so rather than silently playing for the other side.

This is the series’ seventh part in one sentence: keep the model loose, and constrain it at the boundary of the application. The stored cell does not know about turns, and the move does not change it. The atoms are still the atoms Part I built. Another game on the same cells (Connect Four, or tic-tac-toe on a bigger board) would write its own move and its own table and reuse everything else. Put the rules into the stored cell, and the store would stop being reusable.

The move can play through the stored cell because a Sink issued from inside an endpoint is still a Sink to the kernel. It cuts the thread named after its target, exactly as a Sink a player issues does, and the recompute story in Part II depends on that:

/// The move's `Sink` to the stored cell is a sub-request, and the kernel cuts the stored
/// cell's thread on it exactly as it does on a `Sink` a player issues by hand. The whole
/// recompute story rests on this.
#[test]
fn a_sink_issued_inside_the_move_cuts_the_stored_cells_thread() -> Result<()> {
    let kernel = kernel_over(Arc::default());
    let (by_move, by_hand) = (cell_name(1, 1), cell_name(2, 2));
    assert_eq!(source(&kernel, &by_move)?, EMPTY);
    assert_eq!(source(&kernel, &by_hand)?, EMPTY);
    assert!(cached(&kernel, &by_move) && cached(&kernel, &by_hand));

    play(&kernel, 1, 1)?; // the Sink to stored:1:1 is issued by the move
    sink(&kernel, &stored_name(2, 2), "O")?; // issued by the caller

    assert!(!cached(&kernel, &by_move), "cut from inside the move");
    assert!(!cached(&kernel, &by_hand), "cut from outside, as in part I");
    assert_eq!(source(&kernel, &by_move)?, "X");
    assert_eq!(source(&kernel, &by_hand)?, "O");
    Ok(())
}

Play the center, with no mark named. The turn passes to O:

X plays 1,1
[uncacheable]
O
[computed]

Now each of the rules, once. Naming the wrong mark, then the right one:

error: conflict: it is O's turn, not X's
O plays 1,0
[uncacheable]

A square that is taken, and a square that is not on the board:

error: conflict: 1,1 is taken — X played there
error: invalid argument `x`: 3,1 is off the board — no line passes through it
       `x`: the column — any integer

The move describes itself like everything else, so a player (or an agent choosing a tool) can see what it takes before it tries. content is ik:required false, and one of the two marks:

@prefix ik: <https://ikigai-rs.dev/ns#> .
 
<urn:ikigai:endpoint:ttt-move> a ik:Endpoint ;
    ik:id "ttt-move" ;
    ik:title "Move" ;
    ik:summary "Play the side to move at (x, y), if the rules allow it: on the board, the game not over, the square free, and the mark (if given) the side to play." ;
    ik:verb "Sink", "Meta" ;
    ik:output "text/plain;charset=utf-8" ;
    ik:input <urn:ikigai:endpoint:ttt-move:input:x> ;
    ik:input <urn:ikigai:endpoint:ttt-move:input:y> ;
    ik:input <urn:ikigai:endpoint:ttt-move:input:content> ;
    ik:action <urn:ikigai:contract:ttt-move:sink:b3:…> .
 
<urn:ikigai:endpoint:ttt-move:input:x> ik:inputName "x" ;
    ik:source "binding" ;
    ik:required true ;
    ik:summary "the column — any integer" ;
    ik:class <http://www.w3.org/2001/XMLSchema#integer> .
 
<urn:ikigai:endpoint:ttt-move:input:y> ik:inputName "y" ;
    ik:source "binding" ;
    ik:required true ;
    ik:summary "the row — any integer" ;
    ik:class <http://www.w3.org/2001/XMLSchema#integer> .
 
<urn:ikigai:endpoint:ttt-move:input:content> ik:inputName "content" ;
    ik:source "argument" ;
    ik:required false ;
    ik:summary "the mark to play — checked against the turn; left out, the turn plays" ;
    ik:class <http://www.w3.org/2001/XMLSchema#string> ;
    ik:oneOf "X" ;
    ik:oneOf "O" .
 
<urn:ikigai:contract:ttt-move:sink:b3:…:input:x> ik:inputName "x" ;
    ik:source "binding" ;
    ik:required true ;
    ik:summary "the column — any integer" ;
    ik:class <http://www.w3.org/2001/XMLSchema#integer> .
 
<urn:ikigai:contract:ttt-move:sink:b3:…:input:y> ik:inputName "y" ;
    ik:source "binding" ;
    ik:required true ;
    ik:summary "the row — any integer" ;
    ik:class <http://www.w3.org/2001/XMLSchema#integer> .
 
<urn:ikigai:contract:ttt-move:sink:b3:…:input:content> ik:inputName "content" ;
    ik:source "argument" ;
    ik:required false ;
    ik:summary "the mark to play — checked against the turn; left out, the turn plays" ;
    ik:class <http://www.w3.org/2001/XMLSchema#string> ;
    ik:oneOf "X" ;
    ik:oneOf "O" .
 
<urn:ikigai:contract:ttt-move:sink:b3:…> a ik:Action ;
    ik:verb "Sink" ;
    ik:output "text/plain;charset=utf-8" ;
    ik:input <urn:ikigai:contract:ttt-move:sink:b3:…:input:x> ;
    ik:input <urn:ikigai:contract:ttt-move:sink:b3:…:input:y> ;
    ik:input <urn:ikigai:contract:ttt-move:sink:b3:…:input:content> .
[computed]

One move, and what it recomputes

Part II showed that one move reaches the lines through its square and the board, and nothing else. The rules sit one level above the lines, so the same holds for them:

/// One move recomputes the lines through its square, the winner and the turn — and reads
/// exactly one stored cell to do it.
#[test]
fn one_move_recomputes_only_the_lines_through_its_square() -> Result<()> {
    let store = Arc::new(CellStore::default());
    let kernel = kernel_over(store.clone());
    play(&kernel, 1, 1)?;
    play(&kernel, 1, 0)?;
    // Everything read once, so everything is cached.
    source(&kernel, WINNER)?;
    source(&kernel, TURN)?;

    play(&kernel, 0, 0)?; // a corner: one row, one column, one diagonal
    for through in [ROW_0, COLUMN_0, DIAGONAL_0, WINNER, TURN] {
        assert!(!cached(&kernel, through), "{through} passes through (0,0)");
    }
    for past in [ROW_1, ROW_2, COLUMN_1, COLUMN_2, DIAGONAL_1] {
        assert!(cached(&kernel, past), "{past} does not");
    }
    assert!(cached(&kernel, &checkset_name(0, 0)));

    let reads = store.reads.load(Ordering::SeqCst);
    assert_eq!(source(&kernel, WINNER)?, EMPTY);
    assert_eq!(source(&kernel, TURN)?, "O");
    assert_eq!(
        store.reads.load(Ordering::SeqCst),
        reads + 1,
        "of nine stored cells, the corner alone is read again"
    );
    Ok(())
}

The refusals above already asked the winner, so it is cached, and so is every line under it:

-
[cached]

X plays the top-left corner. The corner is on row 0, column 0 and diagonal 0, so those three lines are no longer cached, and neither are the winner and the turn built on them. The probes name each line by its own name, not its alias: cache does not resolve, so it cannot see a rewrite the game’s space makes, and a probe of row:0 would under-report. Row 1 misses the corner and is still cached. So is the corner’s CheckSet, which the move itself read:

X plays 0,0
[uncacheable]
not cached
not cached
not cached
not cached
not cached
cached
cached

Now ask for the winner again, traced:

trace  urn:iki:tutorial:ttt:winner
  client      ikigai repl  ·  capability: root (full authority)
  transport   embedded · in-process
 
urn:iki:tutorial:ttt:winner   ttt-winner · computed · ThreadId(1) · —   → 1b  -
├─ urn:iki:tutorial:ttt:cells:0.0,1.1,2.2   ttt-cells · computed · ThreadId(1) · — · alias=urn:iki:tutorial:ttt:diagonal:0 -> urn:iki:tutorial:ttt:cells:0.0,1.1,2.2
│  ├─ urn:iki:tutorial:ttt:cell:0:0   ttt-cell · computed · ThreadId(1) · —
│  │  └─ urn:iki:tutorial:ttt:stored:0:0   ttt-stored · computed · ThreadId(1) · —
│  ├─ urn:iki:tutorial:ttt:cell:1:1   ttt-cell · cached · ThreadId(1) · —
│  └─ urn:iki:tutorial:ttt:cell:2:2   ttt-cell · cached · ThreadId(1) · —
├─ urn:iki:tutorial:ttt:cells:2.0,1.1,0.2   ttt-cells · cached · ThreadId(1) · — · alias=urn:iki:tutorial:ttt:diagonal:1 -> urn:iki:tutorial:ttt:cells:2.0,1.1,0.2
├─ urn:iki:tutorial:ttt:cells:0.0,0.1,0.2   ttt-cells · computed · ThreadId(1) · — · alias=urn:iki:tutorial:ttt:column:0 -> urn:iki:tutorial:ttt:cells:0.0,0.1,0.2
│  ├─ urn:iki:tutorial:ttt:cell:0:0   ttt-cell · cached · ThreadId(1) · —
│  ├─ urn:iki:tutorial:ttt:cell:0:1   ttt-cell · cached · ThreadId(1) · —
│  └─ urn:iki:tutorial:ttt:cell:0:2   ttt-cell · cached · ThreadId(1) · —
├─ urn:iki:tutorial:ttt:cells:1.0,1.1,1.2   ttt-cells · cached · ThreadId(1) · — · alias=urn:iki:tutorial:ttt:column:1 -> urn:iki:tutorial:ttt:cells:1.0,1.1,1.2
├─ urn:iki:tutorial:ttt:cells:2.0,2.1,2.2   ttt-cells · cached · ThreadId(1) · — · alias=urn:iki:tutorial:ttt:column:2 -> urn:iki:tutorial:ttt:cells:2.0,2.1,2.2
├─ urn:iki:tutorial:ttt:cells:0.0,1.0,2.0   ttt-cells · computed · ThreadId(1) · — · alias=urn:iki:tutorial:ttt:row:0 -> urn:iki:tutorial:ttt:cells:0.0,1.0,2.0
│  ├─ urn:iki:tutorial:ttt:cell:0:0   ttt-cell · cached · ThreadId(1) · —
│  ├─ urn:iki:tutorial:ttt:cell:1:0   ttt-cell · cached · ThreadId(1) · —
│  └─ urn:iki:tutorial:ttt:cell:2:0   ttt-cell · cached · ThreadId(1) · —
├─ urn:iki:tutorial:ttt:cells:0.1,1.1,2.1   ttt-cells · cached · ThreadId(1) · — · alias=urn:iki:tutorial:ttt:row:1 -> urn:iki:tutorial:ttt:cells:0.1,1.1,2.1
└─ urn:iki:tutorial:ttt:cells:0.2,1.2,2.2   ttt-cells · cached · ThreadId(1) · — · alias=urn:iki:tutorial:ttt:row:2 -> urn:iki:tutorial:ttt:cells:0.2,1.2,2.2

Read it from the top. The winner was computed, and asked for all eight lines. Three were computed and five were served. The first line to need the corner, diagonal 0, recomputed the corner’s cell, which read the store. Column 0 and row 0 then found that cell already recomputed, and cached. Of nine stored cells, one was read. That is the whole cost of a move to the rules: the lines through the square, and the one square. (The lines appear in the table’s own order, longest alias first, which is the order the winner reads them.)

The game, played out

O plays the middle of the right-hand column, which does not stop anything. X takes the bottom-right corner, completing diagonal 0. The winner says so, and the turn says nobody is to play:

O plays 2,1
[uncacheable]
X plays 2,2
[uncacheable]
X
[computed]
-
[computed]

The last rule: the game is over, so any move is refused, whatever square it names.

error: conflict: the game is over — X has won
XO-
-XO
--X
[computed]

Finally, follow the links. The CheckSet answers names, and the engine’s map, .., runs a stage once for each line of its input. urn:iki:fn:compose sources the resource its src names and expands any transclusion markers in it. A line has none, so here it does exactly one thing: read the resource a name names. So this reads the four lines through the center, the winning diagonal among them:

OX-
XXX
-X-
-XO
[1 cached · 4 computed]
cached

The one cached answer in that tally is the CheckSet itself. It was computed in the first cell on this page, and after five moves, one of them on the center square itself, it is still cached, because nothing it depends on can change.

Next

Part IV plays more than one game at once, and asks where the game belongs: in every name, or around them as context.

Tic-tac-toe, IV: many games, and where the game belongs

Parts I to III play one game. There is one board per kernel, which is why each page starts on a fresh one. Two players in two games need two boards, and the question this part answers is where the game goes. It could go in every name, so that a cell is a cell of game 7. Or it could go around the names, as the context they are resolved in, so that a cell stays a cell and the game is where you are standing when you ask for it. The eighth part of the series Part I credits takes the second answer, with contextual configuration. This part takes it too, argues against the first, and changes no name and no endpoint above the atom to do it.

Only the store holds a game

Look back over the three parts for what holds state. The stored cell does. Everything above it is a pure function of names it sources: the platonic cell reads the stored cell, a line reads cells, the board reads rows, the winner reads lines, the turn reads the winner and the board, and the move reads four of those before it writes the stored cell. So a game is what urn:iki:tutorial:ttt:stored:{x}:{y} resolves to. Change that, for one request and everything it gives rise to, and the whole game changes with it. No code above the store has to know.

That makes two small changes, and they are the whole of this part’s code.

The store is a space

Part II showed the game’s space with the stored cell already in a space of its own, after the composites. Here is why: the space takes the store as an argument, and the store is any space at all:

/// The game's space: the endpoints, wrapped in the table that names the lines.
pub fn space() -> Alias {
    space_over(Arc::default())
}

/// [`space`], with the stored cells in a [`CellStore`] the caller keeps — so a test can
/// read the counter.
pub fn space_over(store: Arc<CellStore>) -> Alias {
    space_with_store(Arc::new(stored_space(store)))
}

/// The game over ANY store: every composite, then `store` for the stored cells, all
/// wrapped in the table that names the lines.
///
/// Nothing here holds state. `store` is whatever binds [`STORED`] — the in-memory
/// [`stored_space`], or a peer in another process or language — and the composites reach
/// it by name, through the kernel, so they cannot tell which one answered.
pub fn space_with_store(store: Arc<dyn Space>) -> Alias {
    // One table, shared: the aliases rewrite through it, and the rules read it.
    let table = Arc::new(lines());
    let composites = EndpointSpace::new()
        .bind(template(CELL), platonic_cell())
        .bind(template(CELLS), line_of_cells())
        .bind(template(BOARD), board())
        .bind(template(CHECKSET), checkset(Arc::clone(&table)))
        .bind(template(WINNER), winner(Arc::clone(&table)))
        .bind(template(TURN), turn())
        .bind(template(MOVE), play_move())
        .bind(template(RESET), reset(Arc::clone(&table)))
        .bind(template(TEMPLATE), templates())
        .bind(template(VIEW_BOARD), view("board"))
        .bind(template(VIEW_SQUARE), view("square"))
        .bind(template(VIEW_STATUS), view("status"))
        .bind(template(VIEW_REPLY), view("reply"))
        .bind(template(VIEW_GAME), view("game"))
        .bind(template(VIEW_PLAY), view_play())
        .bind(template(VIEW_RESET), view_reset());
    Alias::new(
        table,
        Arc::new(Fallback::new(vec![Arc::new(composites), store])),
    )
}

/// A store: the stored cell over one [`CellStore`], and nothing else.
pub fn stored_space(store: Arc<CellStore>) -> EndpointSpace {
    EndpointSpace::new().bind(template(STORED), stored_cell(store))
}

space_with_store binds every composite, then puts the store after them in a Fallback, and wraps the lot in Part II’s table. The composites reach the store by name, through the kernel, so they cannot tell which space answered. space() and space_over() are what they were in Parts I–III, spelled as the special case: the in-memory store, stored_space, over a CellStore.

A game is a corridor

The second change is a game: a resolution chain whose one corridor binds the stored cell to that game’s own store.

/// A game: the name of the corridor it is played in.
///
/// Not a resource. Nothing is bound at this name and nothing resolves it; it is the name a
/// resolution chain carries, and so the name the cache files a game's answers under.
pub const GAME: &str = "urn:iki:tutorial:ttt:game:{id}";

/// The corridor name of game `id`. An id is letters, digits and `-`, so a game is named by
/// one segment, in one spelling.
pub fn game_name(id: &str) -> Result<Iri> {
    if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
        return Err(Error::InvalidArgument {
            name: "game".to_string(),
            detail: format!("`{id}` is not a game id: letters, digits and - only (e.g. a, b, 7)"),
        });
    }
    Iri::parse(format!("urn:iki:tutorial:ttt:game:{id}"))
        .map_err(|e| Error::Endpoint(format!("a game's name: {e}")))
}

/// Game `id`, played over `store`: a resolution chain whose one corridor is the store.
///
/// Resolve anything in it — a cell, the board, the winner, a move — and that request, and
/// every sub-request it gives rise to, looks for `stored:{x}:{y}` in `store` ahead of the
/// root. Every other name falls through to the root, so the game above the atom is the same
/// names and the same code in every game. Only what the corridor binds differs.
///
/// The name is a claim: *any corridor named `game:{id}` holds these doors*. The cache keys on
/// it, so build a game's corridor once and keep it, and never give two stores one id.
/// Injecting a corridor is the host's authority — whoever may push one chooses the game.
pub fn game(id: &str, store: Arc<dyn Space>) -> Result<Scope> {
    let name = game_name(id)?;
    // `Scope::with_named` refuses (panics on) a space that names itself otherwise; a store
    // is anyone's space, so say it as an error instead.
    if let Some(own) = store.id() {
        if own.as_str() != name.as_str() {
            return Err(Error::InvalidArgument {
                name: "game".to_string(),
                detail: format!(
                    "this store names itself `{}`, so it cannot be game `{id}`",
                    own.as_str()
                ),
            });
        }
    }
    Ok(Scope::empty().with_named(name, store))
}

Scope and alias showed what a corridor does. It sits ahead of the root, and a name it binds shadows the root’s binding for the request and every sub-request it gives rise to. Here the corridor binds exactly one template, the stored cell’s. Ask for the board in game a, and the board reads rows, the rows read cells, and every cell asks for its stored cell, which game a’s corridor answers. Every other name in that chain of requests missed the corridor and was answered by the root, by the same endpoints that answered it in Part III.

Two things about the corridor are rules rather than conveniences.

Its name is a claim. urn:iki:tutorial:ttt:game:a is not a resource: nothing binds it and nothing resolves it. It is the name the chain carries, and the cache files every answer computed in game a under it. with_named asserts any corridor with this name holds these doors, so a game’s corridor is built once and kept, and two stores never share an id.

Injecting it is authority. Whoever may push a corridor ahead of the root chooses what the stored cell means, for every sub-request below, so whoever may push one chooses the game. That is why the choice belongs to the host and not to a line of the REPL.

Choosing a game in this page

The engine’s grammar has no way to name a chain, and that is right: a line that could name one would let whoever wrote the line decide what every name in it means. So on this page the game is markup. A cell the chapter wrote with data-game='a' says so beside its label, Command, in game a, and the page’s own host code runs each of its lines in game a’s chain. You can edit the command. You cannot edit the game.

The page does it with the engine it already had. The engine takes any resolver, so the page gives it one that issues every request in one chain, on the same kernel every other cell uses:

/// A [`Resolver`] that issues every request in ONE resolution chain — a game's, a person's
/// scenario — on a shared kernel. The engine takes any resolver, so an engine over `InChain`
/// is the whole REPL grammar (`source`, `sink`, `cache`, `trace`, pipes, maps) in one chain:
/// every stage, every contract fetch and every cache probe carries it, and the kernel hands it
/// on to every sub-request.
///
/// A line may bring a chain of its own: `as-of=<instant>` resolves the line in a temporal
/// corridor. The engine hands that chain to [`issue_as_async_in`](Resolver::issue_as_async_in),
/// and `InChain` stacks it INSIDE its own (`Scope::stack`, core 0.1.82), so the line's corridor
/// is the innermost, and the one that answers first.
pub struct InChain {
    kernel: Arc<Kernel>,
    scope: Scope,
}

impl InChain {
    /// Resolve in `scope` on `kernel`.
    pub fn new(kernel: Arc<Kernel>, scope: Scope) -> Self {
        Self { kernel, scope }
    }

    /// This chain with the line's inside it: the line's corridor innermost, answering first.
    fn with_line(&self, line: &Scope) -> Scope {
        self.scope.clone().stack(line)
    }

    /// How the cache served a resolution, from a probe taken before it.
    fn status(was_cached: bool, representation: &Representation) -> CacheStatus {
        match (representation.expiry == Expiry::Always, was_cached) {
            (true, _) => CacheStatus::Uncacheable,
            (false, true) => CacheStatus::Hit,
            (false, false) => CacheStatus::Miss,
        }
    }
}

#[async_trait::async_trait]
impl Resolver for InChain {
    fn issue(&self, request: Request) -> Result<(Representation, CacheStatus), Error> {
        self.issue_as(request, &Capability::root())
    }

    fn issue_as(
        &self,
        request: Request,
        capability: &Capability,
    ) -> Result<(Representation, CacheStatus), Error> {
        futures::executor::block_on(self.issue_as_async(request, capability))
    }

    async fn issue_as_async(
        &self,
        request: Request,
        capability: &Capability,
    ) -> Result<(Representation, CacheStatus), Error> {
        self.issue_as_async_in(request, capability, None, Scope::empty())
            .await
    }

    async fn issue_as_async_with_incoming(
        &self,
        request: Request,
        capability: &Capability,
        incoming: Provenance,
    ) -> Result<(Representation, CacheStatus), Error> {
        self.issue_as_async_in(request, capability, Some(incoming), Scope::empty())
            .await
    }

    async fn issue_as_async_in(
        &self,
        request: Request,
        capability: &Capability,
        incoming: Option<Provenance>,
        line: Scope,
    ) -> Result<(Representation, CacheStatus), Error> {
        let scope = self.with_line(&line);
        let was_cached = self.kernel.is_cached_in(&request, capability, &scope);
        let representation = match incoming {
            Some(incoming) => {
                self.kernel
                    .issue_with_incoming_in(request, capability, incoming, scope)
                    .await?
            }
            None => self.kernel.issue_in(request, capability, scope).await?,
        };
        let status = Self::status(was_cached, &representation);
        Ok((representation, status))
    }

    fn is_cached(&self, request: &Request, capability: &Capability) -> bool {
        self.kernel.is_cached_in(request, capability, &self.scope)
    }

    fn is_cached_in(&self, request: &Request, capability: &Capability, line: &Scope) -> bool {
        self.kernel
            .is_cached_in(request, capability, &self.with_line(line))
    }

    fn set_tracer(&self, tracer: Arc<dyn Tracer>) {
        self.kernel.set_tracer(tracer);
    }

    fn clear_tracer(&self) {
        self.kernel.clear_tracer();
    }

    fn entries(&self) -> Option<Vec<SpaceEntry>> {
        self.kernel.entries()
    }

    /// What `trace` prints as the transport — so a traced line says which chain it ran in.
    fn transport(&self) -> String {
        format!("embedded · in-process · chain {}", self.scope)
    }
}

One engine per game, each built the first time a cell names that game and kept for the life of the page, each with its own empty in-memory store. One kernel under all of them, so one cache and one set of golden threads, partitioned by the chain each request carries. A cell with no data-game plays the root’s game, the one every earlier part played.

Two games, side by side

X opens in game a, in the center:

X plays 1,1
[uncacheable]
---
-X-
---
[computed]

Game b has its own board. It is empty, and its first move is X’s, because its turn is read from its own board:

---
---
---
[computed]
X plays 0,0
[uncacheable]
X--
---
---
[computed]

And the root’s game, which no cell has named yet on this page, is untouched by either:

---
---
---
[computed]

The same names, the same endpoints, three boards. The crate says the same:

/// Two games on one kernel: the same names, the same code, two boards. A move in one
/// appears in that game and in no other — including the root's, the game a request plays
/// when it names none.
#[test]
fn two_games_are_two_boards_under_the_same_names() -> Result<()> {
    let kernel = kernel_over(Arc::default());
    let (a, _) = new_game("a");
    let (b, _) = new_game("b");

    assert_eq!(play_in(&kernel, &a, 1, 1)?, "X plays 1,1");
    assert_eq!(play_in(&kernel, &b, 0, 0)?, "X plays 0,0");
    assert_eq!(play_in(&kernel, &b, 2, 2)?, "O plays 2,2");

    assert_eq!(source_in(&kernel, &a, BOARD)?, "---\n-X-\n---");
    assert_eq!(source_in(&kernel, &b, BOARD)?, "X--\n---\n--O");
    assert_eq!(source_in(&kernel, &Scope::empty(), BOARD)?, FRESH);
    Ok(())
}

A move stays in its game

The move is the resource that could get this wrong. It reads four resources before it writes (the CheckSet, the winner, the cell and the turn) and then issues a Sink. Every one of those five requests has to resolve in the move’s own game. If any of them escaped to another game, a player would be refused because someone else’s game was over, or told a square was taken because someone else had taken it.

They cannot escape, because the move does not choose where they go. It issues them through its invocation, and the invocation hands its chain to every sub-request. So finish game a. X takes the top-left corner, which in game b is already X’s, and in game a is free:

O plays 1,0
[uncacheable]
X plays 0,0
[uncacheable]
O plays 2,0
[uncacheable]
X plays 2,2
[uncacheable]
X
[computed]

Game a is over, so a move in game a is refused:

error: conflict: the game is over — X has won

The same move in game b plays, and game b goes on:

O plays 0,2
[uncacheable]
-
[computed]
X
[computed]

The crate pins it both ways round, and counts reads on each game’s store to show that nothing game b asked touched game a’s:

/// A move reads four resources before it writes — the CheckSet, the winner, the cell and
/// the turn — and every one of them, and the write, is in the move's own game. Game A is
/// over; that refuses a move in A, and nowhere else. Reading B's board touches B's store
/// and never A's.
#[test]
fn a_move_stays_in_its_game() -> Result<()> {
    let kernel = kernel_over(Arc::default());
    let (a, a_store) = new_game("a");
    let (b, b_store) = new_game("b");

    x_wins(&kernel, &a)?;
    assert_eq!(source_in(&kernel, &a, WINNER)?, "X");
    match play_in(&kernel, &a, 0, 2) {
        Err(Error::Conflict(detail)) => assert_eq!(detail, "the game is over — X has won"),
        other => panic!("a move in a finished game must be refused, got {other:?}"),
    }

    let a_reads = a_store.reads.load(Ordering::SeqCst);
    assert_eq!(play_in(&kernel, &b, 0, 2)?, "X plays 0,2");
    assert_eq!(source_in(&kernel, &b, WINNER)?, EMPTY);
    assert_eq!(source_in(&kernel, &b, BOARD)?, "---\n---\nX--");
    assert_eq!(
        a_store.reads.load(Ordering::SeqCst),
        a_reads,
        "B read A's store"
    );
    assert!(b_store.reads.load(Ordering::SeqCst) > 0);

    // And the root's game, which nobody named, is untouched by either.
    assert_eq!(source_in(&kernel, &Scope::empty(), BOARD)?, FRESH);
    assert_eq!(play_in(&kernel, &Scope::empty(), 1, 1)?, "X plays 1,1");
    Ok(())
}

Two games, two caches

The cache key carries the chain’s fingerprint, so an answer computed in one game is never served in another. For the board that is exactly right. For the CheckSet it is a cost.

Ask game a which lines pass through the middle of the right-hand column, twice:

urn:iki:tutorial:ttt:column:2
urn:iki:tutorial:ttt:row:1
[computed]
urn:iki:tutorial:ttt:column:2
urn:iki:tutorial:ttt:row:1
[cached]

Game b has not got it, and computes the same two names again:

not cached
urn:iki:tutorial:ttt:column:2
urn:iki:tutorial:ttt:row:1
[computed]

Nor has the root:

not cached

Part III called the CheckSet platonic in the strong sense: it depends on its name and a constant table, nothing a move can change, so it is computed once in the life of the kernel. Under games that is no longer true. It is computed once in the life of each game. The crate measures it: every CheckSet of the board, read in the root and in three games, is nine distinct answers filed thirty-six times.

/// Two games are two cache partitions: the key carries the chain's fingerprint, so an
/// answer computed in one game is never served in another. Right for the board. For the
/// CheckSet — which depends on nothing a game changes — it means one copy per game.
#[test]
fn every_game_computes_its_own_checkset() -> Result<()> {
    let kernel = kernel_over(Arc::default());
    let games: Vec<Scope> = ["a", "b", "c"].iter().map(|id| new_game(id).0).collect();
    let center = checkset_name(1, 1);

    source_in(&kernel, &games[0], &center)?;
    assert!(cached_in(&kernel, &games[0], &center));
    assert!(!cached_in(&kernel, &games[1], &center));
    assert!(!cached_in(&kernel, &Scope::empty(), &center));

    // Every CheckSet of the board, in the root and in each of three games: nine answers,
    // the same nine every time, filed four times over.
    let before = kernel.cache_len();
    let mut answers = BTreeMap::new();
    for scope in std::iter::once(&Scope::empty()).chain(&games) {
        for x in 0..3 {
            for y in 0..3 {
                let name = checkset_name(x, y);
                let answer = source_in(&kernel, scope, &name)?;
                assert_eq!(*answers.entry(name).or_insert(answer.clone()), answer);
            }
        }
    }
    assert_eq!(answers.len(), 9);
    assert_eq!(kernel.cache_len() - before, 4 * 9 - 1); // the center in `a` was already there
    Ok(())
}

Nothing is wrong. Keying on the whole chain is sound and over-partitioned: two chains that differ only in a corridor a request never touched do not share. The CheckSet never touches the game’s corridor. It is answered by the root, and the chain it was asked in made no difference to the answer. The fix is to key an entry on the part of the chain that could have answered it, which since core 0.1.78 the kernel can see: a resolution reports the space that answered it. Keying the cache on that is a decision taken and not yet built (the resolution context, ledger item 563), so for now each game computes its own copy of something that is the same in every game.

A golden thread is a name

There is a second cost, the other way round. The key is partitioned by game, but a golden thread is not. A thread is a name, and a move cuts the thread named after the square it played, in every game at once.

It has already happened on this page. Game b read its top row with its board, several cells back, and nothing in game b has touched that row since. Yet read it now and it is computed, not served:

X--
[computed]
cached

Game a played three moves on the top row in between, and each one cut the thread of the square it played, in game b as well. Now the row is cached again, so watch it go on purpose.

The root’s game plays two moves, the second of them on the top-left corner:

X plays 1,1
[uncacheable]
O plays 0,0
[uncacheable]

Nothing in game b changed, and game b’s top row is no longer cached. Traced, game b’s corner shows why it was recomputed, and whose store answered it:

not cached
trace  urn:iki:tutorial:ttt:cell:0:0
  client      ikigai repl  ·  capability: root (full authority)
  transport   embedded · in-process · chain urn:iki:tutorial:ttt:game:b root
 
urn:iki:tutorial:ttt:cell:0:0   ttt-cell · computed · ThreadId(1) · — · scope=urn:iki:tutorial:ttt:game:b root   → 1b  X
└─ urn:iki:tutorial:ttt:stored:0:0   ttt-stored · computed · ThreadId(1) · — · answered-by=urn:iki:tutorial:ttt:game:b scope=urn:iki:tutorial:ttt:game:b root

Read the two nodes. Both ran in game b’s chain (scope=). The platonic cell was answered by the root, like every name but one. The stored cell was answered by game b’s corridor (answered-by=), and it still says X, which is game b’s mark, not the root’s O. The root’s move cut stored:0:0, and the cut reached every entry hanging from that thread, whatever game computed it. So game b recomputed a corner that had not changed, and got the same answer.

That is sound, because a cut can only make the kernel compute something again, never serve something stale. And it is a cost that grows with the number of games: a move in any game recomputes the lines through that square in all of them. A thread named by the square in its game would stop it. That is the same missing piece as the CheckSet’s, seen from the other side, and it waits on the same decision.

/// A golden thread is a NAME, not a name in a game. A move in A cuts `stored:0:0`, and every
/// entry that hangs from that thread goes, in every game: B's corner cell is recomputed,
/// although nothing in B changed. Sound — a cut can only over-invalidate — and a cost.
#[test]
fn a_move_in_one_game_uncaches_the_same_square_in_every_game() -> Result<()> {
    let kernel = kernel_over(Arc::default());
    let (a, _) = new_game("a");
    let (b, b_store) = new_game("b");
    let corner = CELL.replace("{x}", "0").replace("{y}", "0");

    source_in(&kernel, &a, &corner)?;
    source_in(&kernel, &b, &corner)?;
    source_in(&kernel, &b, ROW_0)?;
    assert!(cached_in(&kernel, &b, &corner) && cached_in(&kernel, &b, ROW_0));

    play_in(&kernel, &a, 0, 0)?;
    assert!(!cached_in(&kernel, &b, &corner));
    assert!(!cached_in(&kernel, &b, ROW_0));

    // Recomputed, and still right: B's corner is still empty.
    let reads = b_store.reads.load(Ordering::SeqCst);
    assert_eq!(source_in(&kernel, &b, &corner)?, EMPTY);
    assert_eq!(b_store.reads.load(Ordering::SeqCst), reads + 1);
    Ok(())
}

The game in the name

The other answer puts the game in every name. The stored cell becomes urn:iki:tutorial:ttt:game:{g}:stored:{x}:{y}, and no corridor is needed.

It does not stop at the stored cell. Every composite that names a cell now has to name the game too, so every template above the store gains {g}: the cell, the line, the board, the CheckSet, the winner, the turn and the move. Each endpoint has to read g out of its own name and spell it into every name it builds. Part II’s aliases are worse off. They are exact rules, eight of them, one per line of one board, and an exact rule does not parameterize. A prefix rule rewrites a prefix and not the middle of a name, so either the table grows eight rules per game, or the game moves to the end of every name, or the composites stop reading rows by their aliases and build line names themselves. Every one of those makes the code above the store know about games, which is the thing Parts I–III were built not to need.

Keeping the game as context keeps every name a game-free name, and keeps the one thing that differs between games in the one place it lives: the store.

A store you can swap

A game’s store is any space that binds the stored cell and keeps its contract. The contract is short, and the crate states it as a check that any store can be run against:

/// Check `store` against the **store contract** — what any space must do to be a game's
/// store, whatever language it is written in:
///
/// 1. It binds [`STORED`], `urn:iki:tutorial:ttt:stored:{x}:{y}`, for any integer `x`, `y`
///    in its one plain spelling; `01`, `+1` and `-0` are `InvalidArgument` naming `x` or `y`.
/// 2. `Source` answers the mark as `text/plain`, its bytes exactly the mark (no trailing
///    newline), cacheable. A cell nothing was played at is `NotFound` — not an empty
///    answer, and not `Unresolved`.
/// 3. `Sink` takes the mark as `content`, trimmed, and keeps any non-empty mark; an empty
///    one is `InvalidArgument` naming `content`.
/// 4. `Exists` answers `true` where a mark is played and `false` where none is — never
///    `NotFound` — as `text/plain`, cacheable.
/// 5. `Delete` clears the cell, and succeeds whether or not anything was there.
///
/// It WRITES: it plays and clears `(7, -3)`, a square off the board, and leaves it clear.
/// Point it at a scratch store, not a game in progress.
pub async fn check_store(store: Arc<dyn Space>) -> std::result::Result<(), String> {
    let kernel = Kernel::new(store);
    let root = Capability::root();
    let at = |name: &str| Iri::parse(name).expect("a constant name");
    let here = at(&stored_name(7, -3));
    let issue = |request: Request| kernel.issue(request, &root);
    let with = |verb: Verb, content: &str| {
        Request::new(verb, here.clone())
            .with_arg("content", ArgRef::Inline(content.as_bytes().to_vec()))
    };
    let read = || issue(Request::new(Verb::Source, here.clone()));

    for round in ["first", "second"] {
        issue(Request::new(Verb::Delete, here.clone()))
            .await
            .map_err(|e| format!("Delete must succeed on any cell ({round} delete): {e}"))?;
    }
    match read().await {
        Err(Error::NotFound(_)) => {}
        other => return Err(format!("an unplayed cell must be NotFound, got {other:?}")),
    }
    exists(&issue, &here, "false").await?;
    issue(with(Verb::Sink, " X\n"))
        .await
        .map_err(|e| format!("Sink of `content` must succeed: {e}"))?;
    let played = read()
        .await
        .map_err(|e| format!("a played cell must answer: {e}"))?;
    if played.bytes != b"X" {
        return Err(format!(
            "a played cell answers exactly its mark, trimmed: expected `X`, got {:?}",
            String::from_utf8_lossy(&played.bytes)
        ));
    }
    if !played.repr_type.to_string().starts_with("text/plain") {
        return Err(format!("a mark is text/plain, not {}", played.repr_type));
    }
    if played.expiry == Expiry::Always {
        return Err(
            "a mark must be cacheable, or nothing above the store can be cached".to_string(),
        );
    }
    exists(&issue, &here, "true").await?;
    match issue(with(Verb::Sink, "  ")).await {
        Err(Error::InvalidArgument { name, .. }) if name == "content" => {}
        other => return Err(format!("an empty mark must be refused, got {other:?}")),
    }
    for spelling in [
        "stored:07:-3",
        "stored:+7:-3",
        "stored:7:-03",
        "stored:0:-0",
    ] {
        let name = format!("urn:iki:tutorial:ttt:{spelling}");
        for verb in [Verb::Source, Verb::Exists] {
            match issue(Request::new(verb, at(&name))).await {
                Err(Error::InvalidArgument { name: arg, .. }) if arg == "x" || arg == "y" => {}
                other => {
                    return Err(format!(
                        "{name} must be refused by {verb:?} (one spelling), got {other:?}"
                    ))
                }
            }
        }
    }
    issue(Request::new(Verb::Delete, here.clone()))
        .await
        .map_err(|e| format!("Delete must clear a played cell: {e}"))?;
    match read().await {
        Err(Error::NotFound(_)) => {}
        other => {
            return Err(format!(
                "a cleared cell must be NotFound again, got {other:?}"
            ))
        }
    }
    exists(&issue, &here, "false").await
}

/// Clause 4 of the contract: `Exists` at `here` answers exactly `expected`, cacheably.
async fn exists<F, Fut>(issue: &F, here: &Iri, expected: &str) -> std::result::Result<(), String>
where
    F: Fn(Request) -> Fut,
    Fut: std::future::Future<Output = Result<Representation>>,
{
    match issue(Request::new(Verb::Exists, here.clone())).await {
        Ok(answer) if answer.bytes == expected.as_bytes() && answer.expiry != Expiry::Always => {
            Ok(())
        }
        Ok(answer) if answer.bytes == expected.as_bytes() => Err(format!(
            "Exists must be cacheable like the mark (it answered `{expected}`, uncacheable)"
        )),
        Ok(answer) => Err(format!(
            "Exists must answer `{expected}` here, got {:?}",
            String::from_utf8_lossy(&answer.bytes)
        )),
        Err(e) => Err(format!(
            "Exists must answer true or false, never fail (expected `{expected}`): {e}"
        )),
    }
}

Each clause is there because something above the store depends on it. NotFound for an unplayed square is what the platonic cell turns into -; an empty answer instead would make an empty square read as nothing, and a line of X and two empty cells would read X rather than X--. The mark’s exact bytes are what the line concatenates, so a trailing newline would break every row. A mark that is not cacheable would leave nothing above the store cacheable, because a composite is only as cacheable as its least cacheable part. And one spelling per coordinate is Part I’s rule: two names for one square would be two golden threads over one piece of state.

A store written a different way, marks kept in a map by the cell’s name, keeps the same contract and plays the same game:

/// The in-memory store keeps the contract — and so does a store written differently, which
/// is then a game's store like any other. The game cannot tell them apart.
#[test]
fn any_space_that_keeps_the_contract_is_a_store() -> Result<()> {
    block_on(check_store(Arc::new(stored_space(Arc::default())))).expect("in-memory store");

    let other = Arc::new(by_name_store());
    block_on(check_store(other.clone())).expect("a store written differently");

    let kernel = kernel_over(Arc::default());
    let elsewhere = game("elsewhere", other)?;
    assert_eq!(play_in(&kernel, &elsewhere, 1, 1)?, "X plays 1,1");
    assert_eq!(source_in(&kernel, &elsewhere, BOARD)?, "---\n-X-\n---");
    Ok(())
}

That is the point of making the seam a Space. A game backed by the in-memory store and a game backed by a store in another process differ only in what their corridor binds. Stores written in Python and TypeScript, served over the wire by those languages’ ikigai faces, are next: the same contract, and the same game above them.

Three boards, one kernel

Everything played on this page, in each game:

XOO
-X-
--X
[computed]
X--
---
O--
[computed]
O--
-X-
---
[computed]

Next

Part V puts a board in this page that you can play by clicking, over the same kernel and the same resources, and draws Parts I to V together.

Tic-tac-toe, V: a board you can play

Parts I to IV built tic-tac-toe out of names: a stored cell, a platonic cell, lines, rows by alias, a board, the rules, and games as corridors around them. Nobody could play it except by typing sink at a prompt. This part gives it a face, and the face is resources too: HTML that the game serves under names of its own, the same bytes whoever puts it on a screen. It covers the ground of the fifth part of the series Part I credits, which starts from the edge a player touches, and of its ninth, which draws the design together, and it ends by drawing the first five parts together.

Here are two games, a and b. Play them with the mouse or the keyboard: Tab to a square and press Enter or Space.

Game a's board appears here when the in-page kernel has loaded.

Game b's board appears here when the in-page kernel has loaded.

Nothing on this page is a server. Each click is a request to the same kernel the runnable cells use, in the game the board belongs to, and the cells below run in game a, the left board. So you can play a move with the mouse and then ask the kernel what it did with it. The expected output under each cell assumes you have not clicked yet. Once you have, the answers are about your game instead, which is the point: press New game to get back to the chapter’s.

The markup is a resource

The game’s HTML lives in templates, and each template is a resource, urn:iki:tutorial:ttt:template:{name}. There are thirteen, all small. Here is the one for a square that has been played:

<button type="button" class="ttt-square" id="ttt-square-$h{{x}}-$h{{y}}" aria-disabled="true" aria-label="$h{urn:iki:tutorial:ttt:cell:{x}:{y}} at $h{{x}},$h{{y}}">$h{urn:iki:tutorial:ttt:cell:{x}:{y}}</button>

It is HTML with markers in it, and each marker is a request. $h{urn:iki:tutorial:ttt:cell:{x}:{y}} asks the kernel for the cell at (x, y) and puts the answer into the page. No Rust fills this template. It is written in the template language of ikigai-fn, the function library Part I’s host mounts, and that library fills it through the kernel, as it fills any composition.

<button type="button" class="ttt-square" id="ttt-square-$h{{x}}-$h{{y}}" aria-disabled="true" aria-label="$h{urn:iki:tutorial:ttt:cell:{x}:{y}} at $h{{x}},$h{{y}}">$h{urn:iki:tutorial:ttt:cell:{x}:{y}}</button>
[computed]
$h{urn:iki:tutorial:ttt:turn} to play.
[computed]

A template is served as it is written, markers and all. It is filled only when a view asks for it, and the next section is the whole language a view speaks.

The template language

A marker is $, a letter, and a resource name in braces. The letter says how the answer goes in:

markerputs inmarkers in what it puts in
$h{name}the answer as text, escaped for HTMLleft alone
$r{name}the answer as it isleft alone
$a{name}the answer as it isexpanded, as part of this template

$$ is a literal $, so $$h{…} is the text $h{…}. Everything that is not a marker is copied as it is.

Why $h never expands. The stored cell keeps any mark at all, and a mark written straight into the store is text somebody else chose. $h escapes it: &, <, >, " and ' become &amp;, &lt;, &gt;, &quot; and &#39;, so the mark cannot become markup. And $h does not look for markers in it, so the mark cannot become a request either. If it did, a player could play a mark that is itself a marker, and the page would resolve whatever name they wrote in it, with the view’s authority. That is template injection, the same hole as SQL injection in another language. So a value from an atom goes in with $h. $r is for markup that is trusted and finished, such as another view, which goes in as it is and is not read again. Only $a expands what it splices, and a template uses it only over templates of its own.

Arguments. {x} inside a marker is an argument: a variable the view’s name captured, or an argument of the request. In a resource name its value is percent-encoded, so an argument can never add a segment or a query to the name it sits in. A marker that is only an argument, $h{{x}}, splices the value and asks the kernel for nothing. The open square has no resource in it but its own coordinates:

<button type="button" class="ttt-square" id="ttt-square-$h{{x}}-$h{{y}}" hx-post="iki/tutorial/ttt/view/play/$h{{x}}/$h{{y}}" hx-target="previous .ttt-status" aria-label="$h{{x}},$h{{y}}: empty, play here"></button>

Fallbacks. $h{a || b} tries a, and if a fails, b, and $h{{title} || b} stands in for a missing argument. The game needs none, because a cell already answers - for an empty square. (The spreadsheet’s evaluator falls back on a NotFound in code, not in a template, and its part V shows what the cache makes of a fallback.)

Shorthand: $j and each. A marker is a resource name and a way to splice it, so a shorter spelling of a name is a shorter marker, and nothing else is new. ikigai-fn 0.6 has two. The game needs neither, but they are the same idea made convenient:

  • $j{<src> <program>} is $h{urn:json:query?src=<src>&q=<program>&as=text/plain}: a jq selection of src, spliced as escaped text. urn:json:query is Transreption’s, so src may be CSV as well as JSON. The program runs to the marker’s closing brace, as written, with no percent-encoding, and no template argument ever reaches it.
  • urn:iki:fn:each is the collection case: it fills one template once per element of a JSON array, an object’s fields as the template’s {arguments}. Element data reaches the output only through $h or $j, so a field is data and never a request, which is the rule above.

The two templates in the listing below fill to the same text, because they are the same request:

#![allow(unused)]
fn main() {
extern crate shop;
extern crate ikigai_core;
extern crate futures;
use std::sync::Arc;
use futures::executor::block_on;
use ikigai_core::{
    ArgRef, Capability, EndpointSpace, Exact, Fallback, FnEndpoint, Iri, Kernel, ReprType,
    Representation, Request, Space, Verb,
};

let text = |body: &'static str| {
    FnEndpoint::new("fixed", move |_| {
        Ok(Representation::new(ReprType::new("text/plain"), body.as_bytes().to_vec()))
    })
};
let templates = EndpointSpace::new()
    .bind(Exact::new("urn:example:sugar"), text("$j{urn:iki:tutorial:shop:stock length}"))
    .bind(
        Exact::new("urn:example:spelled-out"),
        text("$h{urn:json:query?src=urn:iki:tutorial:shop:stock&q=length&as=text/plain}"),
    );
let kernel = Kernel::new(Arc::new(Fallback::new(vec![
    Arc::new(templates) as Arc<dyn Space>,
    Arc::new(shop::root(Arc::default(), Arc::default())),
])));
let fill = |template: &str| {
    let request = Request::new(Verb::Source, Iri::parse("urn:iki:fn:compose").unwrap())
        .with_arg("src", ArgRef::Inline(template.as_bytes().to_vec()));
    let answer = block_on(kernel.issue(request, &Capability::root())).unwrap();
    String::from_utf8(answer.bytes).unwrap()
};
// The stock has four records; both spellings ask the same question.
assert_eq!(fill("urn:example:sugar"), "4");
assert_eq!(fill("urn:example:spelled-out"), "4");
}

Short names in a space, later in the book, composes a one-line summary of that stock with $j and lists it with each.

A template bound at a name. A view is a template bound at a name of its own. ikigai_fn::compose_over(src) is the composer with its template fixed, and the variables the name captures are the template’s arguments:

/// A view: the template `name`, filled by `ikigai-fn`'s template language and bound at a
/// name of its own. The variables the name captures (`{x}`, `{y}`, `{game}`) and the
/// request's arguments (`message`) are the template's arguments.
///
/// There is no view code. The template names what it shows — a cell, the winner, another
/// view — and `urn:iki:fn:conditional` inside it chooses between templates. Every one of
/// those is sourced through the kernel, so a view is a composite like the rest: cacheable,
/// recomputed by the move that touches what it read, and per game through the corridor.
pub fn view(name: &str) -> ComposeOver {
    ikigai_fn::compose_over(Iri::parse(template_name(name)).expect("a template's name parses"))
}
        .bind(template(VIEW_BOARD), view("board"))
        .bind(template(VIEW_SQUARE), view("square"))
        .bind(template(VIEW_STATUS), view("status"))
        .bind(template(VIEW_REPLY), view("reply"))
        .bind(template(VIEW_GAME), view("game"))

The branch. urn:iki:fn:conditional sources its if resource and returns only then or only else. With equals=, the test is whether if’s text is that value. The side it does not take is never asked for, so neither its work nor its golden threads enter the answer. A square is two branches:

$a{urn:iki:fn:conditional?if=urn:iki:tutorial:ttt:cell:{x}:{y}&equals=-&then=urn:iki:tutorial:ttt:template:square-empty&else=urn:iki:tutorial:ttt:template:square-taken}
$a{urn:iki:fn:conditional?if=urn:iki:tutorial:ttt:winner&equals=-&then=urn:iki:tutorial:ttt:template:square-open&else=urn:iki:tutorial:ttt:template:square-closed}

A square asks its cell whether it is -. If not, the square is square-taken. If it is, square-empty asks the winner whether it is -: the square is square-open while the game goes on, and square-closed once it is over. Each branch is a template rather than HTML, and the square splices it with $a, so its markers are expanded as part of the square: the {x} and {y} in square-open are the square view’s own. This is the one place the game uses $a, and what it expands is always one of its own templates, never a value.

What stays code. Every marker is a Source. A template can read and cannot write, so the two views that write, a play and New game, are Rust, and a later section shows why.

The board as a view

The board is a resource, urn:iki:tutorial:ttt:view:board: the board template bound at that name. The template is the grid, with a square’s view at each place:

<div class="ttt-grid" role="group" aria-label="The board: row 0 is the top row, column 0 the left">
<div class="ttt-row">$r{urn:iki:tutorial:ttt:view:square:0:0}$r{urn:iki:tutorial:ttt:view:square:1:0}$r{urn:iki:tutorial:ttt:view:square:2:0}</div>
<div class="ttt-row">$r{urn:iki:tutorial:ttt:view:square:0:1}$r{urn:iki:tutorial:ttt:view:square:1:1}$r{urn:iki:tutorial:ttt:view:square:2:1}</div>
<div class="ttt-row">$r{urn:iki:tutorial:ttt:view:square:0:2}$r{urn:iki:tutorial:ttt:view:square:1:2}$r{urn:iki:tutorial:ttt:view:square:2:2}</div>
</div>

Each square’s view is a resource too, urn:iki:tutorial:ttt:view:square:{x}:{y}, the square template bound at a name with two variables. So view:square:2:0 is the square template with x = 2 and y = 0:

<button type="button" class="ttt-square" id="ttt-square-2-0" hx-post="iki/tutorial/ttt/view/play/2/0" hx-target="previous .ttt-status" aria-label="2,0: empty, play here"></button>
[cached]

It was already cached: the board above drew it when the page loaded.

The board is a composite like every other resource above the store. Every marker is a request through the kernel, so the board is cacheable, it recomputes only when a move touches something it read, and it shows whichever game it is asked in, because the corridor carries into every one of those reads. And it has no code. The template decides which squares there are, and the conditional in square decides which of three kinds each one is.

The status line is the same kind of thing, urn:iki:tutorial:ttt:view:status. The winner chooses between status-turn and status-over, a second choice between status-draw and status-won:

$a{urn:iki:fn:conditional?if=urn:iki:tutorial:ttt:winner&equals=-&then=urn:iki:tutorial:ttt:template:status-turn&else=urn:iki:tutorial:ttt:template:status-over}
$a{urn:iki:fn:conditional?if=urn:iki:tutorial:ttt:winner&equals=draw&then=urn:iki:tutorial:ttt:template:status-draw&else=urn:iki:tutorial:ttt:template:status-won}
$h{urn:iki:tutorial:ttt:turn} to play.

When this page loaded, the boards above asked for both views, in their own games. So in game a they are already cached:

cached
X to play.
[cached]

Is the HTML a transreption of the board or a projection of it? Measured against the bytes, it looks like neither: the HTML is well over a kilobyte and the board is eleven characters. Measured against the resource, which is the definition this book uses, it is a transreption, because every mark on the board can be read back out of it, so nothing about the board is lost, even though the HTML text carries far more than the board does.

Playing through the view

A square is a button, and an open square’s button carries its move as hx-post. That is htmx, a small library that turns attributes into requests: pressing the button sends a POST to the path in hx-post and puts the answer where hx-target says. The path is iki/tutorial/ttt/view/play/1/1, and the resource it names is the view’s one write:

/// What a view's `Sink` answers: `view:reply`, the `reply` template filled with `message`
/// — what the write said — and the status.
///
/// `message` is whatever the write said, as text, and a refusal reaches the player in the
/// kernel's own words. The one refusal the view does not answer is [`Error::Conflict`]: the
/// move did not happen, so the view lets it through to be a 409 at the HTTP edge (and in
/// the in-page shim), which the page swaps into the status region without touching the
/// board. Any other refusal is answered. It is an ARGUMENT of the reply, and the template
/// splices it with `$h`, so it is escaped and never expanded, whatever the error says.
async fn reply(inv: &Invocation<'_>, outcome: Result<Representation>) -> Result<Representation> {
    let message = match outcome {
        Ok(said) => String::from_utf8_lossy(&said.bytes).into_owned(),
        Err(conflict @ Error::Conflict(_)) => return Err(conflict),
        Err(refused) => refused.to_string(),
    };
    let reply = Request::new(Verb::Source, name_iri(VIEW_REPLY)?)
        .with_arg("message", ArgRef::Inline(message.into_bytes()));
    let answer = inv.issue(reply).await?;
    // A write's answer is not cacheable: it says what THIS write did.
    Ok(Representation::new(answer.repr_type, answer.bytes))
}

/// `ttt-view-play`: play the side to move at `(x, y)` — a `Sink` to the move, through the
/// kernel — and answer the reply: `X plays 1,1. O to play.` A move the rules refuse as a
/// [`Error::Conflict`] (a taken square, a finished game) fails with it, and so is a 409
/// over HTTP; a bad argument is answered. This is the view's one write, and it
/// changes nothing about the move: the rules are still the move's.
///
/// It stays code because it is a SEQUENCE — write, then read the reply — and composition
/// only reads: a template's markers are all `Source` requests.
pub fn view_play() -> AsyncFnEndpoint {
    AsyncFnEndpoint::new(
        "ttt-view-play",
        |inv: &Invocation<'_>| -> InvokeFuture<'_> {
            Box::pin(async move {
                sink_only(
                    inv,
                    "a play is made, not read: `sink` it (read the board view)",
                )?;
                let (x, y) = (coordinate(inv, "x")?, coordinate(inv, "y")?);
                let play = Request::new(Verb::Sink, name_iri(&move_name(x, y))?);
                let outcome = inv.issue(play).await;
                reply(inv, outcome).await
            })
        },
    )
    .with_description(
        Description::new("ttt-view-play")
            .title("Play, as a view")
            .summary(
                "Play the side to move at (x, y) and answer, as HTML text, what happened and \
                 whose turn it is — a refused move included.",
            )
            .verb(Verb::Sink)
            .verb(Verb::Meta)
            .input(coordinates()[0].clone())
            .input(coordinates()[1].clone())
            .output(TEXT_HTML_UTF8),
    )
}

urn:iki:tutorial:ttt:view:play:{x}:{y} is a Sink and nothing else. It sinks the move, the same resource Part III built, through the kernel, so the rules are still the move’s and only the move’s. Then it answers with urn:iki:tutorial:ttt:view:reply, a view like the others, whose template puts what the write said, as the argument message, in front of the status:

$h{{message}}. $r{urn:iki:tutorial:ttt:view:status}

That answer is what the button’s hx-target receives: the status line, which is a live region, so a screen reader says what the move did. The board re-renders when the status has settled, because its own markup asks it to (hx-trigger on the board, in the game template).

This view is code, and it has to be. A template’s markers are all reads, and a play is a write and then a read, in that order: the reply must not be read until the move has been made. Composition has no way to say “first”, so a sequence stays a few lines of Rust. Everything it shows is still a template.

X plays 1,1. O to play.
[uncacheable]
not cached
cached

The move cut the stored cell’s thread, and both views hung from it, so both were cut. Then the reply read the status, to answer with it, which cached the new one. The board waits until something asks for it. On this page something did: after a cell runs in a board’s game, the page asks that board to refresh, which is the next section’s subject. So the board view is cached again, with X in the center:

<div class="ttt-grid" role="group" aria-label="The board: row 0 is the top row, column 0 the left">
<div class="ttt-row"><button type="button" class="ttt-square" id="ttt-square-0-0" hx-post="iki/tutorial/ttt/view/play/0/0" hx-target="previous .ttt-status" aria-label="0,0: empty, play here"></button><button type="button" class="ttt-square" id="ttt-square-1-0" hx-post="iki/tutorial/ttt/view/play/1/0" hx-target="previous .ttt-status" aria-label="1,0: empty, play here"></button><button type="button" class="ttt-square" id="ttt-square-2-0" hx-post="iki/tutorial/ttt/view/play/2/0" hx-target="previous .ttt-status" aria-label="2,0: empty, play here"></button></div>
<div class="ttt-row"><button type="button" class="ttt-square" id="ttt-square-0-1" hx-post="iki/tutorial/ttt/view/play/0/1" hx-target="previous .ttt-status" aria-label="0,1: empty, play here"></button><button type="button" class="ttt-square" id="ttt-square-1-1" aria-disabled="true" aria-label="X at 1,1">X</button><button type="button" class="ttt-square" id="ttt-square-2-1" hx-post="iki/tutorial/ttt/view/play/2/1" hx-target="previous .ttt-status" aria-label="2,1: empty, play here"></button></div>
<div class="ttt-row"><button type="button" class="ttt-square" id="ttt-square-0-2" hx-post="iki/tutorial/ttt/view/play/0/2" hx-target="previous .ttt-status" aria-label="0,2: empty, play here"></button><button type="button" class="ttt-square" id="ttt-square-1-2" hx-post="iki/tutorial/ttt/view/play/1/2" hx-target="previous .ttt-status" aria-label="1,2: empty, play here"></button><button type="button" class="ttt-square" id="ttt-square-2-2" hx-post="iki/tutorial/ttt/view/play/2/2" hx-target="previous .ttt-status" aria-label="2,2: empty, play here"></button></div>
</div>
[cached]

A move the rules refuse because of the game’s state, a taken square or a finished game or the wrong mark, is a Conflict, and the view lets it through rather than answering it. Over HTTP that is a 409 whose body is the message, as plain text; here the REPL prints the same words. The move did not happen, so there is no new status to show. A bad argument, a square off the board, is still answered in the kernel’s words, followed by whose turn it still is.

error: conflict: 1,1 is taken — X played there

The page treats that 409 as an answer, not a failure. htmx would, by default, drop a 4xx and report an error, so the board’s script puts a 409’s text into the status region (which is role="status", so a screen reader announces it) as text, never as HTML, and leaves the board as it was. The next move that is made restores the status.

The boards never offer that move: a square that is taken is not an open button. It happens when two people play one game from two screens, and the board one of them is looking at is out of date.

A move made without the view recomputes the view the same way, because the view is only a reader of what the move changed. Traced, the status shows the template language at work: the status template, served from cache; the conditional, recomputed because the winner it asked was; the status-turn template it chose, from cache; and the turn that template splices. Under the winner and the turn, exactly the three lines through the corner and the board were recomputed, and the other five lines were served from cache.

O plays 0,0
[uncacheable]
trace  urn:iki:tutorial:ttt:view:status
  client      ikigai repl  ·  capability: root (full authority)
  transport   embedded · in-process · chain urn:iki:tutorial:ttt:game:a root
 
urn:iki:tutorial:ttt:view:status   composeOver · computed · ThreadId(1) · — · scope=urn:iki:tutorial:ttt:game:a root   → 10b  X to play.
├─ urn:iki:tutorial:ttt:template:status   ttt-template · cached · ThreadId(1) · — · scope=urn:iki:tutorial:ttt:game:a root
├─ urn:iki:fn:conditional   conditional · computed · ThreadId(1) · — · scope=urn:iki:tutorial:ttt:game:a root
│  ├─ urn:iki:tutorial:ttt:winner   ttt-winner · computed · ThreadId(1) · — · scope=urn:iki:tutorial:ttt:game:a root
│  │  ├─ urn:iki:tutorial:ttt:cells:0.0,1.1,2.2   ttt-cells · computed · ThreadId(1) · — · alias=urn:iki:tutorial:ttt:diagonal:0 -> urn:iki:tutorial:ttt:cells:0.0,1.1,2.2 scope=urn:iki:tutorial:ttt:game:a root
│  │  │  ├─ urn:iki:tutorial:ttt:cell:0:0   ttt-cell · computed · ThreadId(1) · — · scope=urn:iki:tutorial:ttt:game:a root
│  │  │  │  └─ urn:iki:tutorial:ttt:stored:0:0   ttt-stored · computed · ThreadId(1) · — · answered-by=urn:iki:tutorial:ttt:game:a scope=urn:iki:tutorial:ttt:game:a root
│  │  │  ├─ urn:iki:tutorial:ttt:cell:1:1   ttt-cell · cached · ThreadId(1) · — · scope=urn:iki:tutorial:ttt:game:a root
│  │  │  └─ urn:iki:tutorial:ttt:cell:2:2   ttt-cell · cached · ThreadId(1) · — · scope=urn:iki:tutorial:ttt:game:a root
│  │  ├─ urn:iki:tutorial:ttt:cells:2.0,1.1,0.2   ttt-cells · cached · ThreadId(1) · — · alias=urn:iki:tutorial:ttt:diagonal:1 -> urn:iki:tutorial:ttt:cells:2.0,1.1,0.2 scope=urn:iki:tutorial:ttt:game:a root
│  │  ├─ urn:iki:tutorial:ttt:cells:0.0,0.1,0.2   ttt-cells · computed · ThreadId(1) · — · alias=urn:iki:tutorial:ttt:column:0 -> urn:iki:tutorial:ttt:cells:0.0,0.1,0.2 scope=urn:iki:tutorial:ttt:game:a root
│  │  │  ├─ urn:iki:tutorial:ttt:cell:0:0   ttt-cell · cached · ThreadId(1) · — · scope=urn:iki:tutorial:ttt:game:a root
│  │  │  ├─ urn:iki:tutorial:ttt:cell:0:1   ttt-cell · cached · ThreadId(1) · — · scope=urn:iki:tutorial:ttt:game:a root
│  │  │  └─ urn:iki:tutorial:ttt:cell:0:2   ttt-cell · cached · ThreadId(1) · — · scope=urn:iki:tutorial:ttt:game:a root
│  │  ├─ urn:iki:tutorial:ttt:cells:1.0,1.1,1.2   ttt-cells · cached · ThreadId(1) · — · alias=urn:iki:tutorial:ttt:column:1 -> urn:iki:tutorial:ttt:cells:1.0,1.1,1.2 scope=urn:iki:tutorial:ttt:game:a root
│  │  ├─ urn:iki:tutorial:ttt:cells:2.0,2.1,2.2   ttt-cells · cached · ThreadId(1) · — · alias=urn:iki:tutorial:ttt:column:2 -> urn:iki:tutorial:ttt:cells:2.0,2.1,2.2 scope=urn:iki:tutorial:ttt:game:a root
│  │  ├─ urn:iki:tutorial:ttt:cells:0.0,1.0,2.0   ttt-cells · computed · ThreadId(1) · — · alias=urn:iki:tutorial:ttt:row:0 -> urn:iki:tutorial:ttt:cells:0.0,1.0,2.0 scope=urn:iki:tutorial:ttt:game:a root
│  │  │  ├─ urn:iki:tutorial:ttt:cell:0:0   ttt-cell · cached · ThreadId(1) · — · scope=urn:iki:tutorial:ttt:game:a root
│  │  │  ├─ urn:iki:tutorial:ttt:cell:1:0   ttt-cell · cached · ThreadId(1) · — · scope=urn:iki:tutorial:ttt:game:a root
│  │  │  └─ urn:iki:tutorial:ttt:cell:2:0   ttt-cell · cached · ThreadId(1) · — · scope=urn:iki:tutorial:ttt:game:a root
│  │  ├─ urn:iki:tutorial:ttt:cells:0.1,1.1,2.1   ttt-cells · cached · ThreadId(1) · — · alias=urn:iki:tutorial:ttt:row:1 -> urn:iki:tutorial:ttt:cells:0.1,1.1,2.1 scope=urn:iki:tutorial:ttt:game:a root
│  │  └─ urn:iki:tutorial:ttt:cells:0.2,1.2,2.2   ttt-cells · cached · ThreadId(1) · — · alias=urn:iki:tutorial:ttt:row:2 -> urn:iki:tutorial:ttt:cells:0.2,1.2,2.2 scope=urn:iki:tutorial:ttt:game:a root
│  └─ urn:iki:tutorial:ttt:template:status-turn   ttt-template · cached · ThreadId(1) · — · scope=urn:iki:tutorial:ttt:game:a root
└─ urn:iki:tutorial:ttt:turn   ttt-turn · computed · ThreadId(1) · — · scope=urn:iki:tutorial:ttt:game:a root
   ├─ urn:iki:tutorial:ttt:winner   ttt-winner · cached · ThreadId(1) · — · scope=urn:iki:tutorial:ttt:game:a root
   └─ urn:iki:tutorial:ttt:board   ttt-board · computed · ThreadId(1) · — · scope=urn:iki:tutorial:ttt:game:a root
      ├─ urn:iki:tutorial:ttt:cells:0.0,1.0,2.0   ttt-cells · cached · ThreadId(1) · — · alias=urn:iki:tutorial:ttt:row:0 -> urn:iki:tutorial:ttt:cells:0.0,1.0,2.0 scope=urn:iki:tutorial:ttt:game:a root
      ├─ urn:iki:tutorial:ttt:cells:0.1,1.1,2.1   ttt-cells · cached · ThreadId(1) · — · alias=urn:iki:tutorial:ttt:row:1 -> urn:iki:tutorial:ttt:cells:0.1,1.1,2.1 scope=urn:iki:tutorial:ttt:game:a root
      └─ urn:iki:tutorial:ttt:cells:0.2,1.2,2.2   ttt-cells · cached · ThreadId(1) · — · alias=urn:iki:tutorial:ttt:row:2 -> urn:iki:tutorial:ttt:cells:0.2,1.2,2.2 scope=urn:iki:tutorial:ttt:game:a root

Game b has not been touched. Its status still says X is to play, but it was computed again rather than served:

X to play.
[computed]

That is Part IV’s thread that is a name, now visible in a view: O’s move on the top-left corner of game a cut stored:0:0 in every game, and game b’s status depended on game b’s corner.

New game is the last write, urn:iki:tutorial:ttt:view:reset. It sinks urn:iki:tutorial:ttt:reset, which deletes every square a line of the table passes through, each one through the kernel, and answers like a play does:

The board is clear. X to play.
[uncacheable]

One markup, three transports

Look at the paths in the markup: iki/tutorial/ttt/view/board, iki/tutorial/ttt/view/play/1/1. They are relative, and they name no game and no host. Every host that serves this markup maps a path to a name by one rule:

A request for the relative path a/b/c is a request for urn:a:b:c, in the game of the page it was made from. GET is Source, POST is Sink, DELETE is Delete.

That is the mechanical default of ikigai-web, the HTTP transport in the CLI’s workspace, which maps /account/id/alice to urn:account:id:alice, with one change: the path is relative, so the part of the URL in front of it is the host’s. A host that serves game a at /game/a/ and game b at /game/b/ sends the same markup to both, and a click on game a’s page arrives as /game/a/iki/tutorial/ttt/view/play/1/1. The host takes /game/a/ off the front and turns it into game a’s corridor. The game is where the page is, which is Part IV’s argument again: context, not a name.

This markup has three hosts in this arc, and it is the same markup in all three.

This page. There is no server. js/ttt.js listens for htmx’s htmx:beforeRequest, which fires before htmx sends anything, and for a request from inside a board it cancels the request, maps the path by the rule above, and asks the page’s kernel. The game comes from the board’s markup, data-game='a' on the element that holds it, exactly as a runnable cell’s game does, and never from the request. Then it hands the answer to htmx.swap, the function htmx’s own response handling calls, so everything htmx does after a response happens here too. The kernel side is one call, beside the engine the cells use:

    /// One request, straight to the kernel — not a line of the REPL — in chain `chain`, or
    /// the root's when it names none. What the page's htmx shims send for a click: `GET` a
    /// view is a `Source`, `POST` a play or an edit is a `Sink`, in the chain the markup
    /// names, with a form's fields (`args`) as named arguments, the way a server reads a form
    /// body. Resolves to the representation, or the kernel's refusal as its message.
    pub fn issue_with_args(
        &self,
        chain: Option<&str>,
        verb: Verb,
        target: &str,
        args: Vec<(String, String)>,
    ) -> impl std::future::Future<Output = Result<Representation, String>> + 'static {
        let kernel = Arc::clone(&self.kernel);
        let scope = self.scope_of(chain);
        let target = Iri::parse(target).map_err(|e| format!("not a resource name: {e}"));
        async move {
            let request = args
                .into_iter()
                .fold(Request::new(verb, target?), |request, (name, value)| {
                    request.with_arg(name, ArgRef::Inline(value.into_bytes()))
                });
            kernel
                .issue_in(request, &Capability::root(), scope?)
                .await
                .map_err(|e| e.to_string())
        }
    }

The page’s copy of the markup has one problem a server’s does not. The kernel cuts every cached answer a move depended on, but a board on the screen is HTML the kernel handed out earlier, and nothing cuts that. A click keeps its own board current, because the markup asks for the status and the board after every move. A runnable cell does not, so after a cell runs in a board’s game, the page asks that board to fetch its status again, and the board follows. Across processes this is the same gap Part IV’s store contract names: a write the kernel did not see leaves a copy stale, and there is no golden thread over the wire yet to tell it.

A host process. ttt-host serves the same game over HTTP and over the kernel’s IPC socket, and can take a game’s store from another process. The next section is about it.

Other languages. The Python and TypeScript faces of ikigai can serve the stored cell already, under the contract Part IV wrote. They can also serve this markup: the templates are resources they can read, the part of the template language these templates use is small enough to write again in any language, and the path rule is one line. That is the next part of the arc.

The same board from a server

ttt-host is a crate in this repository and a program you can run. It holds the game in one kernel, space_with_store as the root game, with one corridor per game named on its command line, and it serves that kernel two ways.

Over HTTP, through ikigai-web, it serves a page per game, /game/a/ for game a. The page is a document around the game’s own page shell, urn:iki:tutorial:ttt:view:game:a, the same bytes this page shows, with a <base href="/game/a/"> so that the markup’s relative paths arrive under the game. The host does the rest: it takes /game/a/ off the front of a request and turns it into game a’s corridor, and maps what is left by the rule above.

/// `urn:game:{id}:iki:tutorial:ttt:{rest}` → `urn:iki:tutorial:ttt:{rest}`, issued in game
/// `id`'s corridor on the game's kernel. The host's act, not the request's: the request
/// names a game the host already has, and the host decides what that game's corridor holds.
///
/// `urn:game:root:…` is the root game, issued with no corridor: the same answers as its
/// plain names, so a client can address every game with one prefix.
struct Gateway {
    kernel: Arc<Kernel>,
    games: BTreeMap<String, Scope>,
    store_open: bool,
}

impl Space for Gateway {
    fn resolve(&self, request: &Request, _scope: &Scope) -> Resolution {
        let Some(rest) = request.target.as_str().strip_prefix(EDGE_GAMES) else {
            return Resolution::Miss;
        };
        let Some((id, name)) = rest.split_once(':') else {
            return Resolution::Miss;
        };
        let scope = match id {
            ROOT_GAME => Some(Scope::empty()),
            id => self.games.get(id).cloned(),
        };
        let (Some(scope), Some(target)) = (scope, game_name(name, self.store_open)) else {
            return Resolution::Miss;
        };
        forwarded(&self.kernel, target, scope)
    }

    /// Every game's names but the root's. `urn:game:root:…` is a second spelling of names
    /// the catalog already lists plainly, and it is left out the way an alias is (core's
    /// `Alias::entries` is transparent): one entry per resource. It also keeps the catalog's
    /// gateway names a list of exactly the games `--game` named, which is how the Python
    /// and Deno apps build their navigation.
    fn entries(&self) -> Option<Vec<SpaceEntry>> {
        let names = shown(&self.kernel, self.store_open);
        Some(
            self.games
                .keys()
                .flat_map(|id| {
                    names.iter().map(move |entry| {
                        let local = entry.pattern.trim_start_matches("urn:");
                        SpaceEntry::new(format!("{EDGE_GAMES}{id}:{local}"), &entry.endpoint)
                    })
                })
                .collect(),
        )
    }
}

That is a space of its own because of who may inject a corridor. ikigai-web issues every request with Kernel::issue, in the root’s chain, and putting a corridor ahead of the root is Kernel::issue_in, which only the holder of the kernel can call. The host holds it, so the host’s own small space in front of the game does the injecting. Over the IPC socket the same space serves the same names, spelled as the HTTP paths are: game a’s board is urn:game:a:iki:tutorial:ttt:board there. The pages, the files they load and the routes are in the crate; the tests post a move to game a’s path and read game a’s board back, with game b and the root untouched:

/// A move posted to game `a`'s path re-renders game `a`'s board, and only game `a`'s: the
/// root and game `b` are other corridors on the same kernel.
#[tokio::test(flavor = "multi_thread")]
async fn a_move_posted_over_http_re_renders_that_games_board() {
    let host = Arc::new(Host::build(&options(&[])).expect("a host"));
    let addr = serve_http(host).await;

    let (status, reply) = http(addr, "POST", "/game/a/iki/tutorial/ttt/view/play/1/1").await;
    assert_eq!((status, reply.as_str()), (200, "X plays 1,1. O to play."));

    // A taken square is refused as a conflict: 409, the message as text, the board unchanged.
    let (status, head, refused) =
        http_raw(addr, "POST", "/game/a/iki/tutorial/ttt/view/play/1/1").await;
    assert_eq!(
        (status, refused.as_str()),
        (409, "conflict: 1,1 is taken — X played there")
    );
    assert!(
        head.to_lowercase().contains("content-type: text/plain"),
        "{head}"
    );
    // Off the board is a bad argument: still answered.
    let (status, _) = http(addr, "POST", "/game/a/iki/tutorial/ttt/view/play/3/1").await;
    assert_eq!(status, 200);
    // The page carries the script that swaps a 409 into the status region.
    let (status, page) = http(addr, "GET", "/game/a/").await;
    assert_eq!(status, 200);
    assert!(page.contains("/static/conflict.js"));
    let (status, js) = http(addr, "GET", "/static/conflict.js").await;
    assert_eq!(status, 200);
    assert!(js.contains("textContent"));

    let (status, board) = http(addr, "GET", "/game/a/iki/tutorial/ttt/view/board").await;
    assert_eq!(status, 200);
    assert!(
        board.contains("aria-label=\"X at 1,1\">X</button>"),
        "{board}"
    );
    for other in [
        "/game/b/iki/tutorial/ttt/view/board",
        "/iki/tutorial/ttt/view/board",
    ] {
        let (_, board) = http(addr, "GET", other).await;
        assert!(!board.contains("X at 1,1"), "{other}: {board}");
    }
}

A store in another process

A game’s store can be another process: --game py=/tmp/ttt-py.sock makes game py’s store whatever answers the stored cell’s names on that socket. The host mounts it the way ikigai --override urn:iki:tutorial:ttt:stored:=/tmp/ttt-py.sock would, names forwarded unchanged, and before it accepts it, it runs Part IV’s store contract against it:

/// A peer store on `socket`, mounted over the stored cell's names and checked against the
/// store contract before it is accepted.
///
/// The mount is an OVERRIDE (`MountedRemote::overriding`): names are forwarded unchanged,
/// so the peer answers `urn:iki:tutorial:ttt:stored:1:1` as that name — what the Python and
/// Deno stores bind. The check WRITES a square off the board (`7,-3`) and clears it again.
///
/// The check reads the PEER's answers, not the mount's: a mount holds every answer as
/// uncacheable here, whatever the peer said (`ikigai-resolve`, since 0.1.64), because a cut on
/// the peer never crosses back. The peer's own cache still holds its marks, so whether a mark
/// is cacheable is a question about the peer, and the contract asks the peer.
pub fn peer_store(socket: &Path) -> std::result::Result<Arc<dyn Space>, String> {
    let resolver: Arc<dyn ikigai_resolve::Resolver> = Arc::new(
        ikigai_ipc::connect(socket)
            .map_err(|e| format!("the store at {}: {e}", socket.display()))?,
    );
    let mount: Arc<dyn Space> = Arc::new(MountedRemote::overriding(
        Arc::clone(&resolver),
        STORED_PREFIX,
        format!("ipc:{}", socket.display()),
    ));
    futures::executor::block_on(tic_tac_toe::check_store(peer_answers(resolver))).map_err(
        |clause| {
            format!(
                "the store at {} breaks the store contract: {clause}",
                socket.display()
            )
        },
    )?;
    Ok(mount)
}

A store that breaks the contract is refused before anything listens, with the clause it broke. The contract gained a clause in this part: Exists answers true or false, never NotFound, and is cacheable like the mark. The Python and TypeScript stores already answered that way; the Rust one used to answer Exists as if it were Source, and now does not.

Both of those stores are in their own repositories, as examples/tictactoe_store.py and examples/tictactoe_store.ts. To play against one, start it on a socket, then start the host with a game over that socket. From a clone of ikigai-python:

PYTHONPATH=src python3 -m examples.tictactoe_store /tmp/ttt-py.sock

or from a clone of ikigai-deno:

deno run -A examples/tictactoe_store.ts /tmp/ttt-ts.sock

and then, from this repository:

cargo run -p ttt-host -- --game a --game py=/tmp/ttt-py.sock --game ts=/tmp/ttt-ts.sock

and open http://127.0.0.1:8070/game/py/. The board is this page’s board, the markup is the same markup, and every mark on it lives in a Python dictionary. Stop the host and start it again, and the Python store still has the game.

Two things to know. The mount is an override and not an alias: an alias mount at urn:iki:tutorial:ttt:stored: would strip that prefix and forward names the Python and TypeScript servers do not bind. And the host caches nothing over the mount. A cut on the Python process never crosses the wire, so a mark written there by some other client would leave a cached board stale; the host holds every answer the mount gives as uncacheable instead, and so the cells, lines and board above it too. Each read asks the store again, a mark written by another client is on the next board the host draws, and the store’s own cache is still where its marks are held. That is why the contract asks the store, not the mount, whether a mark is cacheable.

What the board does for a reader who cannot see it

The board is buttons, so it works with a keyboard and a screen reader with no extra code. Every square has a name that says where it is and what is on it: 2,0: empty, play here, X at 1,1. A square that cannot be played, taken or closed by the end of the game, is aria-disabled rather than disabled: it stays in the tab order and keeps its name, so a reader can still find out what is on it, and it does nothing when pressed. The status line is a live region (role="status"), which a screen reader announces when it changes, and it is the target of every play, so what a move did is said once, politely, after the move. And the squares carry ids, because htmx puts focus back on the element that had it, by id, after a swap: press a square and focus stays on that square while the board is rebuilt under it. On this page, which has two boards, the shim prefixes each id with its game so that no id appears twice.

What was built

Five parts, in the order the series builds its own version, and the order is the lesson.

The resource model first. Part I named every set in the game before writing any code, and found that only one of them holds anything: the stored cell. Everything else was a set of cells, or a question about sets of cells.

Composition over code. Part II built lines, rows and the board out of names. A row is an alias with no code behind it; a line is a list of cells; the board is three rows. The kernel did the bookkeeping that code usually does: a move recomputes exactly what it touched and serves the rest from cache, and no line of the game says so.

Constraint at the edge. Part III made the rules resources, and kept the model loose. The stored cell still takes any mark anywhere. One resource, the move, refuses what the rules forbid, and it refuses by reading resources the game already had.

Context, not state. Part IV played many games without changing a name, by making the game a corridor around the names rather than a part of them, and it turned the store into a seam any process in any language can fill.

And this part put a face on it without moving any of that. The views are more resources of the same kind: composites, cached, per game, recomputed by the moves that touch them, and written as templates with no view code at all. The markup is resources too, so a page, a server and a program in another language show the same game from the same bytes.

Next, the arc crosses languages: the stored cell served from Python and from TypeScript, a host that mounts it, and apps in both languages that serve this same board.

Tic-tac-toe, VI: the same game in Python and TypeScript

Five parts built one game in Rust, and nothing in the model said it had to be Rust. This part moves it into two other languages, in two different ways.

The atom in another language. Only the stored cell holds a game. Part IV turned it into a seam, the store contract, which any space in any language can fill. Here a Python process and a TypeScript process each fill it. Everything above the atom stays in a Rust kernel and does not change: the platonic cell, the lines, the rules, the cache and the golden threads. The commands you ran in the earlier parts answer the same, word for word, over a game whose marks live in a Python dictionary.

A whole app in another language. A Python app and a TypeScript app each serve Part V’s board to a browser. They do not compute the game. They fill Part V’s templates from the host’s resources and send every write through the host. The HTMX board on this book’s pages, on ttt-host, and in each app is one markup.

The polyglot faces are the ones the Python and TypeScript tracks, later in the book, teach. Each track’s section on families covers the templated, multi-verb doors this part uses; what this part needs of them is said as it goes.

Nothing below runs in this page. Both shapes need processes and sockets. The transcripts were captured from real runs against ikigai-python and ikigai-deno at the commits cited beside them, and scripts/ttt-polyglot-demo.sh in this repository runs the whole part again. It starts both stores, the host and both apps, reruns every command shown here, and compares the output with this page. It also checks that each code excerpt from those repositories still appears in them verbatim. CI does not run it, because it needs Python, Deno and two other checkouts. The two runnable cells are the exception: they ask this page’s own kernel the same questions, so you can see that the answer does not depend on the language holding the store.

Shape 1: the contract is the whole interface

Part IV wrote down what a store must do, and check_store checks it:

  1. It binds urn:iki:tutorial:ttt:stored:{x}:{y} for any integer in its one plain spelling, and refuses 01, +1 and -0.
  2. Source answers the mark, as text/plain, cacheable. An unplayed square is NotFound, not an empty answer.
  3. Sink keeps any non-empty mark from content, trimmed.
  4. Exists answers true or false, cacheable.
  5. Delete clears the square, whether or not anything was there.

That is the entire interface between the game and whatever holds it. The game never asks a store anything else, so a store never needs to know anything else. It does not know the rules, which lines exist, or that anything is cached.

Both faces serve the stored cell with a family: one templated door answering four verbs, which the language tracks cover in full later. In Python, family(pattern) gives a door, and each verb is a decorated function whose parameters include the template’s variables.

    cell = family(
        STORED,
        id="ttt-stored",
        title="Stored cell",
        summary="What has been played at (x, y): read it, play a mark, or clear it.",
    )

    @cell.source(cacheable=True, summary="the mark played at (x, y); NotFound if none has been")
    def read(x: Column, y: Row) -> str:
        at = _square(x, y)
        with store.lock:
            store.reads += 1
            mark = store.marks.get(at)
        if mark is None:
            raise NotFoundError(f"nothing has been played at {x},{y}")
        return mark

In TypeScript, family(pattern, options) is a builder, with one chained call per verb:

  return family(STORED, {
    id: "ttt-stored",
    title: "Stored cell",
    summary:
      "What has been played at (x, y): read it, play a mark, or clear it.",
    bindings: coordinates,
  })
    .source({
      summary: "the mark played at (x, y); NotFound if none has been",
      output: TEXT_PLAIN_UTF8,
      cacheable: true,
    }, (args) => {
      const square = at(args);
      store.reads += 1;
      const mark = store.marks.get(square);
      if (mark === undefined) {
        throw new NotFoundError(`nothing has been played at ${square}`);
      }
      return mark;
    })

The Sink and the Delete follow in the same style in both files. Neither file declares an Exists: both faces default it to “would Source succeed”, which is clause 4. Neither file has a line about caching beyond cacheable, or about invalidation at all, because the host kernel does both.

Start two stores

From a checkout of this repository, with ikigai-python and ikigai-deno checked out beside it and ttt-host installed from here (cargo install --path crates/ttt-host --locked):

mkdir -p /tmp/ttt6demo
(cd ../ikigai-python && PYTHONPATH=src python3 -m examples.tictactoe_store /tmp/ttt6demo/py.sock) &
(cd ../ikigai-deno && deno run -A examples/tictactoe_store.ts /tmp/ttt6demo/ts.sock) &

The sockets live in /tmp/ttt6demo because macOS allows a socket path 104 bytes, and a deep temporary directory does not leave room for the file name.

The same commands, a Python-held game

ttt-host --store <socket> makes the root game’s store the peer on that socket. Before it accepts the peer, it runs check_store against it and refuses to start if a clause fails, naming the clause. -c runs one REPL line against the host’s kernel, in this process, and prints what the line answers, the way the book’s cells print it. These are Part I’s, Part II’s and Part III’s commands, and Part V’s reset:

$ ttt-host --store /tmp/ttt6demo/py.sock \
    -c 'source urn:iki:tutorial:ttt:cell:1:1' \
    -c 'sink urn:iki:tutorial:ttt:move:1:1' \
    -c 'trace urn:iki:tutorial:ttt:cell:1:1' \
    -c 'source urn:iki:tutorial:ttt:board' \
    -c 'source urn:iki:tutorial:ttt:board' \
    -c 'sink urn:iki:tutorial:ttt:move:1:1' \
    -c 'sink urn:iki:tutorial:ttt:reset'
-
[computed]
X plays 1,1
[uncacheable]
trace  urn:iki:tutorial:ttt:cell:1:1
  client      ikigai repl  ·  capability: root (full authority)
  transport   embedded · in-process

urn:iki:tutorial:ttt:cell:1:1   ttt-cell · computed · main · —   → 1b  X
└─ urn:iki:tutorial:ttt:stored:1:1   ttt-stored · computed · main · —
   └─ urn:iki:tutorial:ttt:stored:1:1   ttt-stored · computed · Thread-2 (_handle) · 0ms
---
-X-
---
[computed]
---
-X-
---
[cached]
error: conflict: 1,1 is taken — X played there
The board is clear
[uncacheable]

Every verdict is the Rust kernel’s. The empty cell was NotFound in Python and - in Rust, because the platonic cell turns exactly that refusal into the empty cell. The move checked the rules in Rust and wrote one mark into Python. The board was computed once and then served from cache, and the refusal came from rules that never saw a line of Python.

The trace is the part to look at. The cell’s child is the stored cell, twice: once as the host’s kernel resolved it, on its main thread, and once more as a span from the other process, on a thread called Thread-2 (_handle), which is Python’s name for the thread serving that connection. The span came back over the wire and was stitched into the host’s tree. A resolution that crosses a language boundary can be traced across it.

Here are the same lines in this page’s kernel, where the store is Rust:

-
[computed]
X plays 1,1
[uncacheable]
trace  urn:iki:tutorial:ttt:cell:1:1
  client      ikigai repl  ·  capability: root (full authority)
  transport   embedded · in-process
 
urn:iki:tutorial:ttt:cell:1:1   ttt-cell · computed · ThreadId(1) · —   → 1b  X
└─ urn:iki:tutorial:ttt:stored:1:1   ttt-stored · computed · ThreadId(1) · —
---
-X-
---
[computed]
---
-X-
---
[cached]
error: conflict: 1,1 is taken — X played there
The board is clear
[uncacheable]

The answers are the same. The trace is one node shorter, because the stored cell here is an endpoint in the same kernel and has no process of its own to report a span from.

The same commands, a TypeScript-held game

$ ttt-host --store /tmp/ttt6demo/ts.sock \
    -c 'source urn:iki:tutorial:ttt:cell:1:1' \
    -c 'sink urn:iki:tutorial:ttt:move:1:1' \
    -c 'trace urn:iki:tutorial:ttt:cell:1:1' \
    -c 'source urn:iki:tutorial:ttt:board' \
    -c 'source urn:iki:tutorial:ttt:board' \
    -c 'sink urn:iki:tutorial:ttt:move:1:1' \
    -c 'sink urn:iki:tutorial:ttt:reset'
…
   └─ urn:iki:tutorial:ttt:stored:1:1   ttt-stored · computed · deno-main · 0ms
…

The output is the Python run’s, byte for byte, except for that one line: Deno serves the connection on its main thread and says so. The demo script checks exactly that: the two outputs are compared with the peer’s thread name set aside, and must be identical.

So the store contract is the whole interface. Nothing above the atom changed, and nothing above the atom could tell.

Shape 2: the app in another language

The second shape keeps the whole game in the Rust host and moves the face instead. Part V made the markup resources, urn:iki:tutorial:ttt:template:{name}, and wrote down what a host of that markup implements: the template language the views are written in, and the path rule that turns iki/tutorial/ttt/view/board into a name. Each app implements both in its own language, as a client of the host.

Start the host with a game over each store, then the two apps. Each app is a client of the host’s IPC socket and a web server of its own. If the ttt-host from Part V is still running, stop it first: this one serves the same port, 8070. Part V’s stores can stay; these use new sockets under /tmp/ttt6demo.

ttt-host --game py=/tmp/ttt6demo/py.sock --game ts=/tmp/ttt6demo/ts.sock --socket /tmp/ttt6demo/host.sock &
(cd ../ikigai-deno && deno run -A examples/tictactoe_app.ts --socket /tmp/ttt6demo/host.sock) &
(cd ../ikigai-python && PYTHONPATH=src python3 -m examples.tictactoe_app --socket /tmp/ttt6demo/host.sock) &
ttt-host: http://127.0.0.1:8070/ (the root game); games at /game/<id>/: ["py", "ts"]
ttt-host: IPC on /tmp/ttt6demo/host.sock

The host is on port 8070, the Deno app on 8071 and the Python app on 8072, so all three run at once. Open http://127.0.0.1:8072/game/py/ and you are playing game py, whose marks are in the Python store, on a board the Python app rendered from resources the Rust host computed. Open the same path on 8071 and it is the same game again.

Filling the templates

In the Rust host a view is a template bound at a name, and ikigai-fn fills it. An app could ask the host for the finished view, but then it would only pass the host’s HTML along, so each app composes the views itself. It reads the templates, template:{name}, and the game’s raw resources, cell:{x}:{y}, winner and turn, from the host, and fills them with a small template engine of its own. The engine implements the part of the template language these templates use, as the tic-tac-toe crate’s README states it: the three markers, the {x} arguments, the escape table and conditional.

It starts with a scan. In Python:

def scan(template: str) -> list[str | tuple[str, str]]:
    """``template`` as literal text and markers, each its letter and its trimmed body. `$$` is
    a literal `$`; a marker that never closes is literal text, `$` included."""
    out: list[str | tuple[str, str]] = []
    text, i = [], 0
    while i < len(template):
        if template.startswith("$$", i):
            text.append("$")
            i += 2
            continue
        if template[i] == "$" and template[i + 1 : i + 2] in ("a", "r", "h"):
            end = unquoted(template, i + 3) if template[i + 2 : i + 3] == "{" else -1
            if end >= 0:
                out += ["".join(text), (template[i + 1], template[i + 3 : end].strip(WHITESPACE))]
                text, i = [], end + 1
                continue
        text.append(template[i])
        i += 1
    return [*out, "".join(text)]

A template becomes literal text and markers, each marker its letter and its trimmed body. $$ is a $, and a marker that never closes stays text. unquoted finds the } that closes the marker, counting the braces of any {x} inside it and skipping quoted values.

Each marker is then spliced by its letter:

def splice(mode: str, body: str, args: dict[str, str], source: Source, depth: int) -> str:
    """One marker: `$h` escapes what it names, `$r` splices it as it is, `$a` fills it."""
    if len(split(body, "||")) > 1:
        raise refused(f"marker `{body}`: this filler has no `||` fallbacks")
    name = body[1:-1] if body[:1] == "{" and body[-1:] == "}" else ""
    if ARG.fullmatch(name):
        if mode == "a":
            raise refused(f"marker `{body}`: an argument is a value and never a template")
        value = argument(args, name)
    else:
        iri, _, query = body.partition("?")
        given = {}
        for pair in filter(None, (pair.strip(WHITESPACE) for pair in split(query, "&"))):
            key, eq, word = pair.partition("=")
            if not eq:
                raise refused(f"marker argument `{pair}` is not key=value")
            given[key.strip(WHITESPACE)] = value_of(word.strip(WHITESPACE), args)
        request = substitute(iri.strip(WHITESPACE), args, encode=True)
        value = conditional(given, source) if request == CONDITIONAL else source(request, given)
        if mode == "a":
            return fill(value, args, source, depth + 1)
    return value.translate(ESCAPE) if mode == "h" else value

A body that is only an argument, {x}, is spliced as a value and resolves nothing. Anything else is a request, with its arguments filled in, and every request is a read of the host but one. $a fills what came back, $r splices it as it is, and $h escapes it through a five-entry table: &, <, >, " and ', the last one as &#39;, which is what ikigai-fn writes and not what Python’s html.escape writes. That one character is the difference between the same bytes and nearly the same bytes.

The one request the app answers itself is conditional, which the templates use to choose: a square asks whether its cell is -, the status whether the winner is. The host’s gateway forwards only the game’s names, so urn:iki:fn:conditional does not reach ikigai-fn from an app, and the app has its own:

#: ``conditional``'s reading of ``if`` when there is no ``equals``.
BOOLEAN = {"true": True, "1": True, "yes": True, "on": True}
BOOLEAN |= {"false": False, "0": False, "no": False, "off": False, "": False}


def conditional(given: dict[str, str], source: Source) -> str:
    """``urn:iki:fn:conditional``: source ``if``; if its trimmed text is ``equals`` (or, with
    no ``equals``, a true boolean), source and answer ``then``, else ``else`` (or nothing).
    Only the chosen side is sourced."""
    test, then = argument(given, "if"), argument(given, "then")
    verdict = source(test, {}).strip(WHITESPACE)
    if "equals" in given:
        taken = verdict == given["equals"]
    elif (taken := BOOLEAN.get(verdict.lower())) is None:
        raise refused(f"conditional: `{test}` returned {verdict!r}, not a boolean")
    chosen = then if taken else given.get("else")
    return "" if chosen is None else source(chosen, {})

Only the chosen side is read, as in ikigai-fn, and what comes back is a template, which the $a around the conditional then fills.

The TypeScript engine is the same design. Here is its fill, which parses every marker of a template before it resolves any, so a malformed marker refuses the template whatever the resources say, and then resolves the markers together:

export async function compose(
  template: string,
  args: Args,
  resolve: Resolve,
  depth = 0,
): Promise<string> {
  if (depth >= MAX_DEPTH) {
    throw new EndpointError(`compose: recursion limit (${MAX_DEPTH}) exceeded`);
  }
  const segments = scan(template);
  // Every marker is parsed before any resolves: a malformed one fails the template.
  const markers = segments.map((s) =>
    typeof s === "string" ? null : parseMarker(s.mode, s.body)
  );
  const filled = await Promise.all(segments.map(async (s, n) => {
    const marker = markers[n];
    if (typeof s === "string" || marker === null) return s as string;
    let answer: string;
    if ("arg" in marker) {
      answer = argument(args, marker.arg);
    } else {
      const iri = fillArguments(marker.iri, args, true);
      const query: Args = {};
      for (const [key, { text, quoted }] of marker.args) {
        query[key] = quoted ? text : fillArguments(text, args, false);
      }
      answer = iri === CONDITIONAL
        ? await conditional(query, resolve)
        : await resolve(iri, query);
      if (s.mode === "a") {
        return compose(answer, args, resolve, depth + 1);
      }
    }
    return s.mode === "h" ? escapeHtml(answer) : answer;
  }));
  return filled.join("");
}

A view the board names, such as view:square:1:0, is composed the same way: the app matches the name against the README’s table of views and fills that view’s template with what the name captured. Any other name goes to the host, in the right game:

  resolve: Resolve = (iri, args) => {
    const name = iri.startsWith(GAME) ? iri.slice(GAME.length) : null;
    for (const [pattern, template, vars] of VIEWS) {
      const m = name === null ? null : pattern.exec(name);
      if (m === null) continue;
      const captured = Object.fromEntries(vars.map((v, n) => [v, m[n + 1]]));
      return this.view(template, { ...args, ...captured });
    }
    const target = name === null ? iri : this.iri(name);
    return this.client.source(target, args).then((r) => r.text);
  };

Which template a view shows is not in either app. It is in the templates, as conditional markers over cell, winner and turn, so the apps hold no rule of the game at all.

This costs something, and it is worth saying how much. An earlier version of these apps, written before Part V’s views became templates, filled simple {{slot}} placeholders, and its filler was about 70 lines. Now the TypeScript engine is about 225 lines and the Python one about 175, most of it parsing: finding where a marker ends, splitting a request’s arguments outside quotes, checking that every { opens an argument. Each whole app grew less, by a fifth to a quarter, because the view code that chose templates went away. And conditional now exists three times: in Rust, in ikigai-fn, where the host uses it, and again in Python and in TypeScript, because the gateway forwards only the game’s names. The three copies are not even the same. The Python one also reads a boolean, as ikigai-fn’s does, and the TypeScript one implements only the equals form the templates use.

The README states the language as a list of cases, and a Rust test runs every case through ikigai-fn, so the list cannot drift from the language. Each app copies the list into its own tests. Where the apps found the language ambiguous, and they found seven places, the README now says which way it goes, most of them as new cases.

Two things the apps do not do. They never compute the game: the winner is winner, read from the host, not a function of the cells written again in Python. And they never write to a store: a play is a Sink of the host’s move:{x}:{y}, and a new game is a Sink of reset. The next section is about why both matter.

One markup, three backends

A move made through one app is a move in the host’s game, so every face shows it:

$ curl -s -X POST http://127.0.0.1:8072/game/py/iki/tutorial/ttt/view/play/0/0
X plays 0,0. O to play.
$ curl -s -X POST http://127.0.0.1:8071/game/py/iki/tutorial/ttt/view/play/1/1
O plays 1,1. X to play.
$ curl -s http://127.0.0.1:8070/game/py/iki/tutorial/ttt/view/status
X to play.
$ for port in 8070 8071 8072; do curl -s http://127.0.0.1:$port/game/py/iki/tutorial/ttt/view/board | shasum -a 256 | cut -c1-16; done
80a8c36f3845618f
80a8c36f3845618f
80a8c36f3845618f

X played through the Python app, O through the TypeScript app, and the Rust host reports the result. The last command hashes the board from each of the three HTTP faces: one rendered in Rust, one in TypeScript, one in Python. The three digests are equal because the three boards are the same bytes.

“The same bytes” is a claim each app’s tests check, not a description. Both apps run their parity tests against a real ttt-host. The tests play a won game, a refusal and a draw in one game through the app and in another game through the host’s own Rust views, and compare the board, the status and every reply at each step. They also compare the pages and the three static files, byte for byte, and the answer to every edge case the host’s HTTP face has: a wrong verb, a coordinate spelled 01, a game the host does not have. An app that drifted from the host by one character would fail its own tests.

What does not cross, yet

Three limits, each of which the model predicts.

A write the host does not see

The host’s cache is correct because the host sees every write. When its kernel issues a Sink or Delete to stored:{x}:{y}, it cuts that name’s golden thread, and every cached answer that read the square is recomputed on the next request. A write that goes to the store directly, from some other client of the Python process, is not seen:

$ ikigai --plain --override urn:iki:tutorial:ttt:stored:=/tmp/ttt6demo/py.sock -c 'sink urn:iki:tutorial:ttt:stored:2:2 O'
ok
[uncacheable]
$ ikigai --plain --override urn:iki:tutorial:ttt:stored:=/tmp/ttt6demo/py.sock -c 'source urn:iki:tutorial:ttt:stored:2:2'
O
[computed]
$ ikigai --plain --connect /tmp/ttt6demo/host.sock -c 'source urn:game:py:iki:tutorial:ttt:board'
X--
-O-
---
[uncacheable]

The Python store holds an O at 2,2. The host still says the square is empty, and it will go on saying so, because its cached cell at 2,2 hangs from a thread that nothing cut. Worse, the host’s rules would let X play there, and the Sink would overwrite the O, because the move checks the square through that same cached cell. The fix is the rule the apps follow: every write goes through the host. Here, a reset through the host deletes every square through its kernel, which cuts every square’s thread, and the host and the store agree again:

$ curl -s -X POST http://127.0.0.1:8070/game/py/iki/tutorial/ttt/view/reset
The board is clear. X to play.
$ ikigai --plain --override urn:iki:tutorial:ttt:stored:=/tmp/ttt6demo/py.sock -c 'source urn:iki:tutorial:ttt:stored:2:2'
error: not found: nothing has been played at 2,2

The general fix would be a golden thread over the wire: a way for a peer to tell the host that a name it serves has changed. There is none today, so a peer that can be written behind the host’s back is a correctness hazard, and the contract says so.

The [uncacheable] on the board is a second, smaller thing to notice. The IPC face of ttt-host is a small kernel in front of the game’s kernel, which injects the game’s corridor, and it marks every answer it forwards as uncacheable, because it never saw the reads that answer depends on and could never cut a copy. The board’s bytes still came from the game kernel’s cache. That is why the transcripts in shape 1 use -c, which runs in the game’s own kernel, where the verdicts are real.

Composition stays in the kernel

A peer serves the atom and nothing else, and that is not an accident of this example. The host knows what every cached answer was computed from because it resolved every piece of it: the board read the rows, the rows read the cells, and each read was the kernel’s. A peer that did more, for example a Python winner that asked the host for the cells and computed the answer itself, would make a request the host resolves as a new top-level request from outside. The host would answer each cell correctly and learn nothing about what the Python answer depended on. The Python winner would then be a traccessor: something that accesses resources from across a boundary, in a fresh request, where dependency tracking cannot follow. Its answer could not hang from any golden thread, so it could never be cached safely or cut when a move changed it.

So the peers hold state, and the kernel composes. The apps follow the same rule from the other side: they read winner rather than computing it, and render only what they read.

A refusal is typed

A move the rules refuse is a Conflict, and the wire carries it: version 8 of the wire protocol gives it a tag in Rust, Python and TypeScript together, and HTTP 409 at the web edges. A taken square is not an invalid argument. The argument is well formed, and the refusal is about the state of the game. The host answers it with a 409 and the message as plain text:

$ curl -s -X POST http://127.0.0.1:8070/game/py/iki/tutorial/ttt/view/play/0/0
X plays 0,0. O to play.
$ curl -s -w '\n%{http_code}\n' -X POST http://127.0.0.1:8070/game/py/iki/tutorial/ttt/view/play/0/0
conflict: 0,0 is taken — X played there
409

A client that wants to treat “that square is taken” differently from “that is not a coordinate” tells them apart by type: the second, a square off the board, is still an InvalidArgument and a 400.

What was built

Across six parts: a game drawn as resources, then built as resources, played in many games at once, given a face, and now held and served from three languages without its model changing.

The last part shows the model’s seam holding. The store contract was written in Part IV for a store in some process. Two stores in other languages kept it on the first try, and check_store said so before the host accepted them. The markup was written in Part V for some host. Two apps in other languages serve it byte for byte, and their tests check that. What stayed in one place was everything that needed to know what depended on what: the composition, the cache and the rules, in the kernel.

One thing in all six parts is still Rust that need not be: the arrangement, which doors exist and in what order. Tic-tac-toe, VII: the game’s space is a file moves it into a file.

Tic-tac-toe, VII: the game’s space is a file

Six parts built tic-tac-toe as resources. The atom is code, because it holds the marks, and the rules and views are small functions and templates. But one thing about the game was still Rust in every part: its arrangement. Which doors exist, in what order they are tried, which names are only other names (the eight lines of Part II), and what a host puts beside the game (conditional, for the views) were all calls in space_with_store and kernel_over (the crate’s function that builds the coded game’s kernel and puts conditional behind it).

A kernel already reports its arrangement. urn:kernel:topology answers it as Turtle, and the catalog and the tracing chapter read it. This part runs that the other way: the arrangement is written down as data, one file, and the kernel builds the game from it. The endpoints stay in Rust. Everything around them moves into the file.

The file

Here is the game’s whole arrangement, crates/tic-tac-toe/tic-tac-toe.arrangement:

; Tic-tac-toe's space: which doors exist, in what order, and which names are only other
; names. Every endpoint named here is Rust, registered by the host under that name. This
; file arranges them and cannot make one.
(fallback
  (alias
    ; The board's eight lines, each an exact alias onto the line of cells it holds.
    (exact "urn:iki:tutorial:ttt:row:0"      "urn:iki:tutorial:ttt:cells:0.0,1.0,2.0")
    (exact "urn:iki:tutorial:ttt:row:1"      "urn:iki:tutorial:ttt:cells:0.1,1.1,2.1")
    (exact "urn:iki:tutorial:ttt:row:2"      "urn:iki:tutorial:ttt:cells:0.2,1.2,2.2")
    (exact "urn:iki:tutorial:ttt:column:0"   "urn:iki:tutorial:ttt:cells:0.0,0.1,0.2")
    (exact "urn:iki:tutorial:ttt:column:1"   "urn:iki:tutorial:ttt:cells:1.0,1.1,1.2")
    (exact "urn:iki:tutorial:ttt:column:2"   "urn:iki:tutorial:ttt:cells:2.0,2.1,2.2")
    (exact "urn:iki:tutorial:ttt:diagonal:0" "urn:iki:tutorial:ttt:cells:0.0,1.1,2.2")
    (exact "urn:iki:tutorial:ttt:diagonal:1" "urn:iki:tutorial:ttt:cells:2.0,1.1,0.2")
    (fallback
      ; Everything above the atom: the cell, the lines, the rules, the templates and views.
      (endpoints
        (door "urn:iki:tutorial:ttt:cell:{x}:{y}"        ttt-cell)
        (door "urn:iki:tutorial:ttt:cells:{list}"        ttt-cells)
        (door "urn:iki:tutorial:ttt:board"               ttt-board :match template)
        (door "urn:iki:tutorial:ttt:checkset:{x}:{y}"    ttt-checkset)
        (door "urn:iki:tutorial:ttt:winner"              ttt-winner :match template)
        (door "urn:iki:tutorial:ttt:turn"                ttt-turn :match template)
        (door "urn:iki:tutorial:ttt:move:{x}:{y}"        ttt-move)
        (door "urn:iki:tutorial:ttt:reset"               ttt-reset :match template)
        (door "urn:iki:tutorial:ttt:template:{name}"     ttt-template)
        (door "urn:iki:tutorial:ttt:view:board"          ttt-view-board :match template)
        (door "urn:iki:tutorial:ttt:view:square:{x}:{y}" ttt-view-square)
        (door "urn:iki:tutorial:ttt:view:status"         ttt-view-status :match template)
        (door "urn:iki:tutorial:ttt:view:reply"          ttt-view-reply :match template)
        (door "urn:iki:tutorial:ttt:view:game:{game}"    ttt-view-game)
        (door "urn:iki:tutorial:ttt:view:play:{x}:{y}"   ttt-view-play)
        (door "urn:iki:tutorial:ttt:view:reset"          ttt-view-reset :match template))
      ; The one atom: the stored cell, the only state a game has.
      (endpoints
        (door "urn:iki:tutorial:ttt:stored:{x}:{y}" ttt-stored))))
  ; The one resource the views use that the game does not bind.
  (endpoints
    (door "urn:iki:fn:conditional" conditional)))

It is an s-expression, read by ikigai-sexpr, which converts it losslessly to and from the Turtle that core builds a space from. Read it from the outside in:

  • (fallback A B) is an ordered list of spaces. A name is tried in the first, then the second, and the first one that answers wins. The game is first. The second layer holds the one thing the views use that the game does not bind, ikigai-fn’s conditional, which kernel_over added in code.
  • (alias rules… space) is Part II’s table. Each exact rule renames one name to another before the space inside sees it, so urn:iki:tutorial:ttt:row:0 reaches the line of cells it holds. The rules are the whole of Part II’s LINES, and no code is behind them.
  • The inner fallback is Part IV’s seam: every composite first, then the store, in a space of its own. That is what lets a game corridor, or a store in another process, answer for the stored cell alone.
  • (endpoints (door pattern endpoint) …) is an endpoint space. Each door binds a pattern to an endpoint, by the endpoint’s name, and the doors are tried in the order written.

Eight doors say :match template. A door’s pattern is text, and text alone cannot say how it matches: urn:iki:tutorial:ttt:board could be one exact name, or a URI template that happens to have no variables. Both match the same single name. The reader takes a pattern with no braces as exact, but the game’s Rust binds every door through UriTemplate::parse, so those eight are templates, and the file says so to declare the arrangement the code builds. Left out, the game would answer the same, but it would be a different arrangement. This part checks that it is the same one, so the file says it.

What stays Rust: the endpoints, in a registry

A declaration binds a door to an endpoint by name, and never makes one. So the host’s half is a registry: every endpoint the game has, each under the name it gives itself.

/// Every endpoint the game has, registered under the name each one gives itself — the host's
/// half of a declaration. The file says which door binds which name; this says what each
/// name IS. Nothing else in the game is Rust any more.
///
/// The five views are one kind of endpoint, `composeOver`, over five templates, so each is
/// [`named`](ikigai_fn::ComposeOver::named) here: a registry holds one endpoint per name.
/// `lines` is the table the rules read (the CheckSet, the winner and the reset), taken from
/// the declaration by [`lines_of`] so the board's geometry is written once.
pub fn registry(
    store: Arc<CellStore>,
    lines: Arc<ikigai_core::AliasTable>,
) -> std::result::Result<Registry, DeclarationError> {
    let endpoints: [Arc<dyn Endpoint>; 18] = [
        Arc::new(stored_cell(store)),
        Arc::new(platonic_cell()),
        Arc::new(line_of_cells()),
        Arc::new(board()),
        Arc::new(checkset(Arc::clone(&lines))),
        Arc::new(winner(Arc::clone(&lines))),
        Arc::new(turn()),
        Arc::new(play_move()),
        Arc::new(reset(lines)),
        Arc::new(templates()),
        Arc::new(view("board").named("ttt-view-board")),
        Arc::new(view("square").named("ttt-view-square")),
        Arc::new(view("status").named("ttt-view-status")),
        Arc::new(view("reply").named("ttt-view-reply")),
        Arc::new(view("game").named("ttt-view-game")),
        Arc::new(view_play()),
        Arc::new(view_reset()),
        Arc::new(ikigai_fn::conditional()),
    ];
    let mut registry = Registry::new();
    for endpoint in endpoints {
        registry.register(endpoint)?;
    }
    Ok(registry)
}

Eighteen endpoints, each the same function earlier parts wrote: the atom, the cell, the line, the board, the rules, the templates, the views, the two view writes, and conditional. None of them changed to be declared.

The five views need one thing here. Each is composeOver bound to a different template, and an unnamed composeOver answers the name composeOver. A registry holds one endpoint per name, because a door that said “composeOver” could otherwise mean any of the five:

/// A declaration arranges registered endpoints and never mints one: a door naming an endpoint
/// nobody registered is refused at build, naming the door and the name.
#[test]
fn a_declaration_cannot_mint_an_endpoint() {
    let undo = ARRANGEMENTS
        .iter()
        .find(|(name, _)| *name == "undo")
        .map(|(_, text)| *text)
        .expect("the undo arrangement");
    let tree = arrangement_to_topology(undo).expect("it reads");
    let refused = build(
        &tree,
        &registry(Arc::default(), Arc::new(lines())).expect("registers"),
    )
    .err()
    .expect("refused");
    assert!(
        matches!(&refused, DeclarationError::UnknownEndpoint { id, .. } if id == "ttt-undo"),
        "{refused}"
    );
}

/// A registry holds one endpoint per name, so two views left unnamed — both `composeOver` —
/// cannot both be registered. That is why each view is named.
#[test]
fn two_unnamed_views_are_one_name_too_many() {
    let mut registry = Registry::new();
    registry
        .register(Arc::new(view("board")))
        .expect("the first");
    let refused = registry
        .register(Arc::new(view("square")))
        .expect_err("refused");
    assert_eq!(
        refused,
        DeclarationError::DuplicateEndpoint {
            id: "composeOver".to_string()
        }
    );
}

So the registry names each view (ttt-view-board, and so on) where it registers it. Part V’s view is unchanged, and so is the coded game, whose views are still unnamed.

Three endpoints are configured with a value rather than just registered. The CheckSet, the winner and the reset read the line table, to know which lines pass through a cell and which squares a reset clears. In the coded game that table is LINES. In the declared game it comes from the file, so the board’s geometry is written once:

/// The game `tree` declares, over `store`: its line table read from the declaration, every
/// endpoint registered, and core's [`build`](ikigai_core::build). Refused, naming where, if
/// the declaration binds a name the registry does not hold, or anything else core refuses.
pub fn build_game(
    tree: &Topology,
    store: Arc<CellStore>,
) -> std::result::Result<Arc<dyn Space>, DeclarationError> {
    let lines = Arc::new(lines_of(tree)?);
    ikigai_core::build(tree, &registry(store, lines)?)
}

lines_of finds the file’s one alias and turns its rules into a table. Everything else is core’s build: it walks the declaration, rebuilds each space with its public constructor, binds each door to the registered endpoint of that name, and hands back a live space. A host for the declared game is then one line:

/// A host for the declared game alone: a kernel over the space the file declares, with the
/// Meta renderer [`kernel`](crate::kernel) uses. The coded kernel's twin, with no arrangement
/// in Rust.
pub fn kernel_over(store: Arc<CellStore>) -> std::result::Result<Kernel, DeclarationError> {
    Ok(Kernel::with_meta_renderer(
        space_over(store)?,
        Arc::new(TurtleRenderer),
    ))
}

The round trip

A declaration is also a resource. The file is bound at urn:iki:tutorial:ttt:arrangement:tic-tac-toe, and urn:iki:tutorial:ttt:build reads the declaration it is given through the kernel, builds it against the game’s registry, and answers what it built, printed back as an arrangement:

/// `ttt-build`: read the declaration `of` names through the kernel — as Turtle, by whatever
/// transreptor the kernel has for its type (ikigai-sexpr's, for an `.arrangement`) — build
/// it against the game's registry over a store of its own, and answer the space that was
/// built, printed as an arrangement.
///
/// The answer is the built space describing itself, not the file: comments gone, the alias
/// rules in the order the table tries them. A declaration the build refuses is refused here,
/// in core's words, as an invalid `of`. Cacheable: a pure function of what `of` answered,
/// so it hangs from `of`'s thread, and an edit to the file rebuilds it.
pub fn build_endpoint() -> AsyncFnEndpoint {
    AsyncFnEndpoint::new("ttt-build", |inv: &Invocation<'_>| -> InvokeFuture<'_> {
        Box::pin(async move {
            let raw = inv.inline_str("of")?.trim();
            let of = Iri::parse(raw).map_err(|e| Error::InvalidArgument {
                name: "of".to_string(),
                detail: format!("not a resource name ({e}): `of` names the declaration"),
            })?;
            let turtle = turtle_of(inv, &of).await?;
            let refused = |detail: String| Error::InvalidArgument {
                name: "of".to_string(),
                detail,
            };
            let tree = Topology::from_turtle(&turtle).map_err(|e| refused(e.to_string()))?;
            let built = build_game(&tree, Arc::default()).map_err(|e| refused(e.to_string()))?;
            let text = topology_to_arrangement(&built.topology())
                .map_err(|e| Error::Endpoint(format!("printing the built space: {e}")))?;
            Ok(Representation::new(arrangement_type(), text.into_bytes()).cacheable())
        })
    })
    .with_description(
        Description::new("ttt-build")
            .title("Build a declaration")
            .summary(
                "Read the declaration `of` names, build it against the game's endpoints, and \
                 answer the space that was built as an arrangement; refused, naming where, if \
                 it binds an endpoint the game does not have.",
            )
            .verb(Verb::Source)
            .verb(Verb::Meta)
            .input(
                ArgSpec::new("of")
                    .summary(
                        "the name of a declaration, e.g. \
                         urn:iki:tutorial:ttt:arrangement:tic-tac-toe; the one input, so a \
                         positional value lands here",
                    )
                    .class(XSD_ANY_URI),
            )
            .output(MEDIA_ARRANGEMENT),
    )
}

/// Source `of` and bring it to Turtle through the kernel's own transreptors: a plan from the
/// type it answered as, each step a request, so every step is a sub-request like any other.
async fn turtle_of(inv: &Invocation<'_>, of: &Iri) -> Result<String> {
    let answered = inv.source(of).await?;
    let media = answered.repr_type.media_type.clone();
    let mut bytes = answered.bytes;
    if media != TURTLE {
        let plan =
            inv.select_transreptor(&media, TURTLE)
                .ok_or_else(|| Error::InvalidArgument {
                    name: "of".to_string(),
                    detail: format!(
                        "{} answers {media}, and nothing here reads that as Turtle",
                        of.as_str()
                    ),
                })?;
        for step in plan {
            let transreptor = Iri::parse(&step.endpoint)
                .map_err(|e| Error::Endpoint(format!("a transreptor's name: {e}")))?;
            let request = Request::new(Verb::Source, transreptor)
                .with_arg("content", ArgRef::Inline(bytes))
                .with_arg("as", ArgRef::Inline(step.to.into_bytes()));
            bytes = inv.issue(request).await?.bytes;
        }
    }
    String::from_utf8(bytes).map_err(|_| Error::InvalidArgument {
        name: "of".to_string(),
        detail: format!("{} is not UTF-8 text", of.as_str()),
    })
}

The file is served as text/x-ikigai-arrangement, and nothing in the build endpoint parses that. It asks the kernel for a transreption to text/turtle, and the kernel finds ikigai-sexpr’s. Run it:

(fallback
  (alias
    (exact "urn:iki:tutorial:ttt:diagonal:0" "urn:iki:tutorial:ttt:cells:0.0,1.1,2.2")
    (exact "urn:iki:tutorial:ttt:diagonal:1" "urn:iki:tutorial:ttt:cells:2.0,1.1,0.2")
    (exact "urn:iki:tutorial:ttt:column:0" "urn:iki:tutorial:ttt:cells:0.0,0.1,0.2")
    (exact "urn:iki:tutorial:ttt:column:1" "urn:iki:tutorial:ttt:cells:1.0,1.1,1.2")
    (exact "urn:iki:tutorial:ttt:column:2" "urn:iki:tutorial:ttt:cells:2.0,2.1,2.2")
    (exact "urn:iki:tutorial:ttt:row:0" "urn:iki:tutorial:ttt:cells:0.0,1.0,2.0")
    (exact "urn:iki:tutorial:ttt:row:1" "urn:iki:tutorial:ttt:cells:0.1,1.1,2.1")
    (exact "urn:iki:tutorial:ttt:row:2" "urn:iki:tutorial:ttt:cells:0.2,1.2,2.2")
    (fallback
      (endpoints
        (door "urn:iki:tutorial:ttt:cell:{x}:{y}" ttt-cell)
        (door "urn:iki:tutorial:ttt:cells:{list}" ttt-cells)
        (door "urn:iki:tutorial:ttt:board" ttt-board :match template)
        (door "urn:iki:tutorial:ttt:checkset:{x}:{y}" ttt-checkset)
        (door "urn:iki:tutorial:ttt:winner" ttt-winner :match template)
        (door "urn:iki:tutorial:ttt:turn" ttt-turn :match template)
        (door "urn:iki:tutorial:ttt:move:{x}:{y}" ttt-move)
        (door "urn:iki:tutorial:ttt:reset" ttt-reset :match template)
        (door "urn:iki:tutorial:ttt:template:{name}" ttt-template)
        (door "urn:iki:tutorial:ttt:view:board" ttt-view-board :match template)
        (door "urn:iki:tutorial:ttt:view:square:{x}:{y}" ttt-view-square)
        (door "urn:iki:tutorial:ttt:view:status" ttt-view-status :match template)
        (door "urn:iki:tutorial:ttt:view:reply" ttt-view-reply :match template)
        (door "urn:iki:tutorial:ttt:view:game:{game}" ttt-view-game)
        (door "urn:iki:tutorial:ttt:view:play:{x}:{y}" ttt-view-play)
        (door "urn:iki:tutorial:ttt:view:reset" ttt-view-reset :match template))
      (endpoints
        (door "urn:iki:tutorial:ttt:stored:{x}:{y}" ttt-stored))))
  (endpoints
    (door "urn:iki:fn:conditional" conditional)))
[computed]
(fallback
  (alias
    (exact "urn:iki:tutorial:ttt:diagonal:0" "urn:iki:tutorial:ttt:cells:0.0,1.1,2.2")
    (exact "urn:iki:tutorial:ttt:diagonal:1" "urn:iki:tutorial:ttt:cells:2.0,1.1,0.2")
    (exact "urn:iki:tutorial:ttt:column:0" "urn:iki:tutorial:ttt:cells:0.0,0.1,0.2")
    (exact "urn:iki:tutorial:ttt:column:1" "urn:iki:tutorial:ttt:cells:1.0,1.1,1.2")
    (exact "urn:iki:tutorial:ttt:column:2" "urn:iki:tutorial:ttt:cells:2.0,2.1,2.2")
    (exact "urn:iki:tutorial:ttt:row:0" "urn:iki:tutorial:ttt:cells:0.0,1.0,2.0")
    (exact "urn:iki:tutorial:ttt:row:1" "urn:iki:tutorial:ttt:cells:0.1,1.1,2.1")
    (exact "urn:iki:tutorial:ttt:row:2" "urn:iki:tutorial:ttt:cells:0.2,1.2,2.2")
    (fallback
      (endpoints
        (door "urn:iki:tutorial:ttt:cell:{x}:{y}" ttt-cell)
        (door "urn:iki:tutorial:ttt:cells:{list}" ttt-cells)
        (door "urn:iki:tutorial:ttt:board" ttt-board :match template)
        (door "urn:iki:tutorial:ttt:checkset:{x}:{y}" ttt-checkset)
        (door "urn:iki:tutorial:ttt:winner" ttt-winner :match template)
        (door "urn:iki:tutorial:ttt:turn" ttt-turn :match template)
        (door "urn:iki:tutorial:ttt:move:{x}:{y}" ttt-move)
        (door "urn:iki:tutorial:ttt:reset" ttt-reset :match template)
        (door "urn:iki:tutorial:ttt:template:{name}" ttt-template)
        (door "urn:iki:tutorial:ttt:view:board" ttt-view-board :match template)
        (door "urn:iki:tutorial:ttt:view:square:{x}:{y}" ttt-view-square)
        (door "urn:iki:tutorial:ttt:view:status" ttt-view-status :match template)
        (door "urn:iki:tutorial:ttt:view:reply" ttt-view-reply :match template)
        (door "urn:iki:tutorial:ttt:view:game:{game}" ttt-view-game)
        (door "urn:iki:tutorial:ttt:view:play:{x}:{y}" ttt-view-play)
        (door "urn:iki:tutorial:ttt:view:reset" ttt-view-reset :match template))
      (endpoints
        (door "urn:iki:tutorial:ttt:stored:{x}:{y}" ttt-stored))))
  (endpoints
    (door "urn:iki:fn:conditional" conditional)))
[cached]

That answer is the built space describing itself, not the file echoed back. The comments are gone, because a space has none to report. The eight rules come out diagonals first, in the order the alias table tries them (longest name first), not the order the file lists them in. Everything else is the file. Building it and asking the result for its arrangement gives the arrangement you started with: the round trip is a fixpoint, and a test holds it to that.

The second line was served from the cache. The build is a pure function of what it read, so it hangs from the file’s golden thread, and a change to the file would rebuild it.

The same game, twice

The page’s kernel now holds both games: the coded one, which every earlier part played, and one built from the file. A cell that says data-game='a' plays the coded game a, as in Part IV. A cell marked as a declared game a runs in a corridor that holds the whole game built from the file, ahead of the root’s coded one, with a store of its own.

Play the same moves in each. X takes the center, O a corner, and then X tries the center again. First in the coded game:

X plays 1,1
[uncacheable]
O plays 0,0
[uncacheable]
error: conflict: 1,1 is taken — X played there
O--
-X-
---
[computed]
X
[computed]

Then in the game built from the file:

X plays 1,1
[uncacheable]
O plays 0,0
[uncacheable]
error: conflict: 1,1 is taken — X played there
O--
-X-
---
[computed]
X
[computed]

The same answers, the same refusal in the same words, and the same verdicts from the cache. The refusal is a Conflict, as Part VI explained, and both games say conflict: together, because a refusal belongs to the endpoint, and the file only says where the endpoint is bound.

A trace shows which space answered. One more move in the declared game, then a trace of the square it took:

X plays 2,2
[uncacheable]
trace  urn:iki:tutorial:ttt:cell:2:2
  client      ikigai repl  ·  capability: root (full authority)
  transport   embedded · in-process · chain urn:iki:tutorial:ttt:declared:a root
 
urn:iki:tutorial:ttt:cell:2:2   ttt-cell · computed · ThreadId(1) · — · answered-by=urn:iki:tutorial:ttt:declared:a scope=urn:iki:tutorial:ttt:declared:a root   → 1b  X
└─ urn:iki:tutorial:ttt:stored:2:2   ttt-stored · computed · ThreadId(1) · — · answered-by=urn:iki:tutorial:ttt:declared:a scope=urn:iki:tutorial:ttt:declared:a root

Both nodes were answered by the corridor urn:iki:tutorial:ttt:declared:a: the cell, and the stored cell it read, which is the declared game’s own store. Nothing fell through to the coded game behind it.

Two cells are one sample. The crate plays a whole game both ways, reads nineteen names after every step (the board, the rules, the lines, the views and the store), and requires the same bytes, the same cacheability and the same refusals from both, and the same number of reads of the atom:

/// The game built from the file answers every step of a whole game exactly as the coded game
/// does — every write, every refusal, and every read after each — and reads its atom exactly
/// as often, so it caches and recomputes the same.
#[test]
fn the_declared_game_answers_exactly_as_the_coded_one() {
    let (coded_store, declared_store) = (
        Arc::new(CellStore::default()),
        Arc::new(CellStore::default()),
    );
    let coded = kernel_over(Arc::clone(&coded_store));
    let declared = declared::kernel_over(Arc::clone(&declared_store)).expect("the file builds");

    let mut steps = 0;
    let mut compare = |verb: Verb, name: &str, content: Option<&str>| {
        let name = format!("{T}{name}");
        let (a, b) = (
            answer(&coded, verb, &name, content),
            answer(&declared, verb, &name, content),
        );
        assert_eq!(b, a, "{verb:?} {name}");
        steps += 1;
    };
    for name in READS {
        compare(Verb::Source, name, None);
    }
    for (verb, name, content) in SCRIPT {
        compare(verb, name, content);
        for name in READS {
            compare(Verb::Source, name, None);
        }
    }
    assert_eq!(steps, READS.len() * (SCRIPT.len() + 1) + SCRIPT.len());
    assert_eq!(
        declared_store.reads.load(Ordering::SeqCst),
        coded_store.reads.load(Ordering::SeqCst),
        "the same reads of the atom: the same cache, the same golden threads"
    );
}

The last assertion is the strongest one. Both kernels read their store the same number of times over the whole game, so the declared game caches, and recomputes on a move, exactly as the coded one does. Nothing about golden threads was declared. They come from the endpoints, which are the same.

That test compares behavior, not arrangements, and on purpose. The two arrangements are not identical: the coded game’s five views are all named composeOver, and the declared ones are named. Rename those five back and they are the same tree, which another test in the same file checks.

What a declaration may and may not do

A declaration arranges endpoints the host registered. It can order doors, stack spaces, alias names, mount, limit and seal. It can never make an endpoint. Here is a declaration that tries to add an undo by naming one:

; A declaration that tries to add a feature by naming it: an undo that nobody wrote.
(endpoints
  (door "urn:iki:tutorial:ttt:board" ttt-board :match template)
  (door "urn:iki:tutorial:ttt:undo"  ttt-undo))
error: invalid argument `of`: <urn:ikigai:space:_:1:door:2> binds `ttt-undo`, which the host did not register: a declaration arranges registered endpoints and never mints one
       `of`: the name of a declaration, e.g. urn:iki:tutorial:ttt:arrangement:tic-tac-toe; the one input, so a positional value lands here

The refusal names where: the second door of the first anonymous space, by the name that door has in the declaration’s Turtle. An undo would be a new endpoint, written in Rust and registered, and then a file could bind it.

Core refuses everything it cannot rebuild, and never skips it, because a kernel that answered less than its declaration says would look fine until someone asked for the missing part. The crate checks each case against the game’s own registry:

refused at buildwhy
an endpoint name the registry does not holda declaration arranges what the host registered, and mints nothing
two different endpoints under one namerefused when the second is registered: a door binds by name, so the name must say which
an opaque spacea remote peer or a hand-written resolver has nothing to rebuild from
a closure rewriteits rule is code, not a table. The table-driven form is an alias
a seal on a name the host sealedseals are checked when the space is built, against the host’s own

Each refusal names the node it found, as the declaration’s Turtle names it.

The picture

An arrangement is a tree, so it can be drawn. ikigai-diagram renders one as nested boxes, and this is the file’s:

The tic-tac-toe arrangement as nested boxes. The outermost box is a fallback of two layers. Layer 1 is an alias of eight exact rules, the diagonals, columns and rows each renamed to its line of cells, around a fallback of two endpoint spaces: sixteen doors for everything above the atom, from the cell to the view that resets the game, and then one door for the stored cell. Layer 2 is one endpoint space with one door, urn:iki:fn:conditional.
The game's arrangement, drawn from tic-tac-toe.arrangement by ikigai_diagram::render. Each door row names its pattern, the endpoint that answers it, and how the pattern matches. Select the picture to open it at full size.

The picture is committed beside this chapter, and a test holds it to the file:

/// The picture the chapter shows is `ikigai_diagram::render` of the file's arrangement, byte
/// for byte. On a mismatch the picture the code draws is written beside the build and the
/// failure names the command that commits it; nothing rewrites it from inside a test.
#[test]
fn the_committed_picture_is_the_files_arrangement() {
    let committed = concat!(
        env!("CARGO_MANIFEST_DIR"),
        "/../../books/ikigai/src/applied/tic-tac-toe-arrangement.svg"
    );
    let drawn = ikigai_diagram::render(&declared::topology().expect("the file reads"));
    if std::fs::read_to_string(committed).ok().as_deref() == Some(drawn.as_str()) {
        return;
    }
    let out = format!(
        "{}/tic-tac-toe-arrangement.svg",
        env!("CARGO_TARGET_TMPDIR")
    );
    std::fs::write(&out, &drawn).expect("write the picture the code draws");
    panic!(
        "the committed picture no longer matches tic-tac-toe.arrangement.\n\
         If the change is intended:  cp {out} {committed}"
    );
}

Edit the file, and the test fails until the picture is redrawn.

What changed, measured

The devtools’ resource-ratio report counts, from a kernel’s topology, the names bound to code (doors) and the names composed by a rule over another name (aliases). Before this part, for the coded game under urn:iki:tutorial:: 17 coded, 8 composed. For the declared game: 17 coded, 8 composed. The same, and it should be. Declaring does not change how many doors are code. The same eighteen endpoints (seventeen under the game’s names, and conditional) answer the same doors.

What moved is the arrangement:

whatcoded gamedeclared game
where the doors, their order and the aliases areRust: space_with_store, stored_space, LINES, and the fallback in kernel_overtic-tac-toe.arrangement: 32 lines, not counting comments
Rust that arranges anythingall of the abovenone
Rust the host addsnoneregistry, one line per endpoint, and lines_of to read the line table from the file
endpoints18the same 18, five of them named

The registry is new code, and it is a list: the endpoints the coded game held implicitly, in its binds. That is the line this part draws. An endpoint is behavior, and behavior is code. Where things go, and under which names, is data, and now it is a file that any tool can read, draw, diff or check.

Where this goes

A host can read an arrangement too. Since ikigai-cli 0.1.33 the ikigai host prints the root it is running as a declaration, draws it, and starts its local root from one:

ikigai --plain -c 'source urn:iki:host:arrangement as=text/x-ikigai-arrangement' > host.arrangement
ikigai --plain -c 'source urn:diagram:kernel' > host.svg
ikigai --arrangement mine.arrangement

The first line is the s-expression face; without as= the same root comes back as Turtle. The second is an SVG, and needs urn:cap:kernel:inspect, which the local REPL holds. The third reads a declaration (here mine.arrangement, your edited copy of host.arrangement), Turtle or *.arrangement, relative to the working directory, and arrangement = "<path>" in the host’s config does the same. A file that does not build stops the start and names the node that failed. One thing to know before you try it: a host arranges only the endpoints it registered. This chapter’s tic-tac-toe.arrangement names ttt-cell and the rest, which only the game registers, so it is not a host arrangement and --arrangement refuses it. Start from the host’s own printed root instead, and edit that. As printed, it does not yet start unchanged, because a few of the host’s endpoints share a name (ledger item 639); its header says which.

This part declared one space, the game’s root. A game is still a corridor built in Rust (Part IV’s game), and so are the temporal and personal corridors the spreadsheet arc builds later (Spreadsheet, VI). Declaring corridors as templates, filled in per request from a game id, an instant or a person, is the next step (ledger item 632).

What was built

The game’s space is one file. The kernel reads it, as Turtle, through a transreptor, and core builds the game from it against a registry of the endpoints the earlier parts wrote. The declared game plays exactly as the coded one, move for move, refusal for refusal and cache hit for cache hit, and a test holds it to that. Its picture is drawn from the same file, and a test holds that too. What stays Rust is the part that has to be: the endpoints, which hold the state and compute the answers. Where they are bound is now data.

A space that changes while it runs

Tic-tac-toe, VII moved the game’s arrangement into a file. The endpoints stayed in Rust, in a Registry, and core’s build read the file once, when the kernel was made, and handed back a space. It also drew that space with ikigai_diagram::render. What it could not do is change the space afterwards: to give the game a door it did not have, you edited the file and built a new kernel.

This chapter keeps the same two halves, a registry of endpoints and a declaration that only arranges them, and lets the declaration change while the kernel runs. The tool is ikigai-core’s DynamicSpace (0.1.109, ledger #2006): a space inside the host’s root that rebuilds itself from a declaration resource whenever that resource is written. The root never changes. Only the doors inside the dynamic space do.

It leans on one rule from Golden threads in practice: a cut takes what hung from it. Every answer a dynamic space serves hangs from one thread that the space owns, and every change to the space cuts that thread. That is why a removed door takes its cached answers with it.

Nothing here needs a server or a REPL. If a ttt-host or an ikigai serve from an earlier chapter is still running, it can stay running: this chapter uses neither. Every listing below runs on every build of the book, against the crate crates/live-space.

The same host also runs in this page. The cells below that say “in the live host” run their lines in it: the root crates/live-space builds, with an empty dynamic space called workshop, started the first time you press Run in one of them. It is a kernel of its own, beside the one the book’s other cells share, because that one already binds Part I’s names, and an empty space would not look empty in front of them. Its rebuild loop is the chapter’s drive, running on the page’s own event loop. Run the cells in order: each one changes the host the next one reads. The four parts of the chapter that change the space are numbered stages, one tab each, with the space drawn as it was after each one (below).

The host

The registry holds four endpoints the book already wrote: Part I’s camel-case, title and camel-title (from Hello, resource), and ikigai-fn’s toUpper. Each is registered under the name it gives itself, and none of them is bound to any name yet:

/// Every endpoint the dynamic space may bind, by the name each gives itself. A
/// declaration that names anything else is refused, and binds nothing.
pub fn registry(title: Arc<TitleState>) -> Registry {
    let mut registry = Registry::new();
    for endpoint in [
        Arc::new(hello_camel::camel_case()) as Arc<dyn ikigai_core::Endpoint>,
        Arc::new(hello_camel::title(title)),
        Arc::new(hello_camel::camel_title()),
        Arc::new(ikigai_fn::to_upper()),
    ] {
        registry.register(endpoint).expect("four distinct names");
    }
    registry
}

They are three kinds of endpoint: a pure function of its argument (camel-case, toUpper), an endpoint over state the host holds (title), and a composite that reads another resource through the kernel (camel-title reads title). The registry does not care which is which. A declaration binds a door to an endpoint by its name, and never makes one.

The declaration lives at a name of its own:

/// Where the dynamic space's declaration lives. A write to it is a change to the space.
pub const DECLARATION: &str = "urn:iki:tutorial:live:declaration";

/// Where the host says its dynamic space in words ([`words`]): what the page's stages viewer
/// compares from one stage to the next.
pub const WORDS: &str = "urn:iki:tutorial:live:arrangement";

/// `view.arrangement`: a declaration holding one view, `urn:iki:tutorial:live:picture`, onto
/// `urn:diagram:kernel`, as the s-expression the chapter writes. `:resolve-at root` is what makes
/// the rule a view (ikigai-sexpr 0.2.4 reads and prints it).
pub const VIEW: &str = include_str!("../view.arrangement");

/// `view.ttl`: the same declaration as [`VIEW`], as the `ik:` Turtle core reads, where the view
/// is `ik:resolveAt "root"`.
pub const VIEW_TURTLE: &str = include_str!("../view.ttl");

/// The first declaration the chapter writes: all four endpoints, bound.
pub const FOUR_DOORS: &str = r#"(endpoints
  (door "urn:iki:tutorial:camel-case" camel-case)
  (door "urn:iki:tutorial:title" title)
  (door "urn:iki:tutorial:camel-title" camel-title)
  (door "urn:iki:tutorial:upper" toUpper))"#;

Sink writes an arrangement there, in the s-expression form Tic-tac-toe VII’s file is written in; Source answers it as the ik: Turtle core builds from. Beside it is the dynamic space in words, urn:iki:tutorial:live:arrangement: a line for the space, and one for each space and door it holds, read from the kernel’s topology, so it says what is in force rather than what was last written. The host’s root is three layers: those two, then ikigai-diagram‘s space (urn:diagram:kernel, the picture Tic-tac-toe VII drew from a file, here drawn from the running kernel, beside the urn:diagram:state pair that draws governors’ state over an arrangement, which this chapter does not use), then the dynamic space:

/// The root of a host for `dynamic`: the declaration and the dynamic space in words, then the
/// picture of the host's own arrangement, then the dynamic space. The layers are consulted in order, so a door the declaration
/// binds can never shadow the declaration itself.
pub fn root(
    declaration_state: Arc<DeclarationState>,
    dynamic: Arc<DynamicSpace>,
) -> Arc<dyn Space> {
    let fixed = EndpointSpace::new()
        .bind(Exact::new(DECLARATION), declaration(declaration_state))
        .bind(Exact::new(WORDS), words());
    Arc::new(Fallback::new(vec![
        Arc::new(fixed),
        Arc::new(ikigai_diagram::space()),
        dynamic,
    ]))
}

/// The kernel over [`root`], with the Meta renderer Part I's host uses.
pub fn kernel(root: Arc<dyn Space>) -> Kernel {
    Kernel::with_meta_renderer(root, Arc::new(TurtleRenderer))
}

The order matters: no door the declaration binds can shadow the declaration, or the picture.

Starting the host is three steps: make the space, put it in the kernel, and start the loop that rebuilds it:

    /// Make an empty dynamic space called `name`, put it in a host, and start the loop
    /// that rebuilds it from [`DECLARATION`].
    pub fn start(name: &str) -> Host {
        let title = Arc::new(TitleState::default());
        let space = Arc::new(DynamicSpace::new(
            name,
            Iri::parse(DECLARATION).expect("a constant IRI"),
            registry(Arc::clone(&title)),
        ));
        let root = root(Arc::default(), Arc::clone(&space));
        let kernel = Arc::new(kernel(Arc::clone(&root)));
        // The host's loop: read the declaration, bind it, and wait for its thread to be
        // cut, forever. Core has no runtime, so the host spawns it on one.
        let pool = LocalPool::new();
        let (looping, on) = (Arc::clone(&space), Arc::clone(&kernel));
        pool.spawner()
            .spawn_local(async move {
                let stopped = looping.drive(&on, &Capability::root()).await;
                panic!("the rebuild loop stopped: {stopped}");
            })
            .expect("spawn the rebuild loop");
        let mut host = Host {
            kernel,
            root,
            space,
            title,
            pool,
        };
        host.settle();
        host
    }

DynamicSpace::new is where the space gets its name. "workshop" becomes urn:iki:space:dynamic:workshop, which is the space’s name, the name of the thread every answer it serves hangs from, and the prefix of its status door.

drive is the host’s loop. It listens for cuts of the declaration’s thread, reads the declaration, binds it if it is good, and waits again. A space’s resolve is synchronous and cannot read a resource, so the rebuild happens here, outside resolution, when the declaration changes. Core has no runtime, so the host runs the loop on one. Here that is a LocalPool, and Host::write lets it catch up after each write, so each listing reads in order:

    /// Write `declaration` to [`DECLARATION`], as any client with the authority to write it
    /// could, then let the loop run until it has nothing left to do.
    pub fn write(&mut self, declaration: &str) {
        let write = Request::new(Verb::Sink, Iri::parse(DECLARATION).expect("a constant IRI"))
            .with_arg("content", ArgRef::Inline(declaration.as_bytes().to_vec()));
        block_on(self.kernel.issue(write, &Capability::root())).expect("the write");
        self.settle();
    }

The write is an ordinary Sink. The kernel cuts the declaration’s thread after it, as it does after every write, and the loop hears that cut. Nothing else tells the space to change.

The space, in stages

The kernel’s state first: the whole host, as urn:diagram:kernel draws it. Before anything is declared it looks like this, and once the page’s host is running, every step you take below redraws it here, so this picture is always the kernel as it is now:

The host's arrangement as nested boxes: a chain holding the root, a fallback of the three layers the paragraphs above name. The first layer holds the declaration and its words, the second the diagram's four doors, and the third, the dynamic space urn:iki:space:dynamic:workshop, holds an endpoint space with no doors.
The host before anything is declared: urn:diagram:kernel, as the host in crates/live-space answers it, held to it byte for byte by a test. Select the picture to open it at full size.

The third layer is the dynamic space: its name, the declaration it is built from, and inside it, before anything is declared, an endpoint space with no doors. The status door is not drawn. The space answers it itself, ahead of anything a declaration binds, so no declaration can shadow it.

Only the third layer changes, so the stages below draw only it: focus= on urn:diagram:kernel (ikigai-diagram 0.2.4) draws one space and what it encloses, which is also what keeps the picture legible on a narrow screen.

How to read the stages. A stage is the space after a step that changed it, and each one is a numbered tab. Below the tabs, two pictures sit side by side (stacked on a phone): After is the open tab’s stage, and Before is the stage you compare it with, the one just before unless you pick another from the “Compare with” list above them. Under them is what changed from Before to After, in words (the space’s words, urn:iki:tutorial:live:arrangement, compared line by line: the words that differ are marked, and what they replaced is said beside them). Below that is the tab’s step: a sentence or two and a cell, whose Run button runs the step in this page’s host.

Each picture says whose it is. Until you run a tab’s step, its picture is the chapter’s own, in a dashed frame; run it, and After becomes the picture your host drew, in a solid frame. Run the tabs in order: each step starts where the one before left the space. A cell that changes nothing the space holds makes no stage. The line above the tabs keeps two facts current, each read from the space’s status door after every cell: how many declarations have bound, and the last refusal. How each step works, as a Rust listing that runs on every build of the book, is after the stages.

Every picture is drawn once. Both pictures are resources like any other: urn:diagram:kernel is a pure function of the kernel’s topology, so the kernel caches it, and the cached picture hangs from urn:kernel:bindings, the thread every successful bind cuts. After every cell you run, the page asks for both, the whole host above and the space below, as your own lines would. A cell that bound a new declaration cut the thread, so the picture is drawn again, once; a cell that cut nothing gets the cached picture, and the page says so. Switching tabs, or what you compare with, asks for nothing at all: each tab keeps the picture its step drew. The chapter’s own pictures are files the same host drew natively, held to it byte for byte by a test, so they show before the page’s kernel loads, without it, and in print.

  • installs: 0
  • refused: the declaration could not be read: not found: urn:iki:tutorial:live:declaration

An empty space, with a name

Start the host, and ask the space what it holds: its status door, urn:iki:space:dynamic:workshop:status, and camel-case, a name the registry could serve. The host starts the first time a cell in it runs:

space: urn:iki:space:dynamic:workshop
declaration: urn:iki:tutorial:live:declaration
in force: nothing (the space is empty)
installs: 0
refused: the declaration could not be read: not found: urn:iki:tutorial:live:declaration
[uncacheable]
error: no endpoint resolved for urn:iki:tutorial:camel-case

The space as it starts, which is the first stage:

installs: 0 · refused: the declaration could not be read: not found: urn:iki:tutorial:live:declaration

The live host's dynamic space urn:iki:space:dynamic:workshop, drawn as nested boxes: the space and the declaration it is built from, and inside it what it holds now. The host's other two layers are not drawn. At this stage: dynamic space urn:iki:space:dynamic:workshop, built from urn:iki:tutorial:live:declaration; holding: an endpoint space with no doors.The live host's dynamic space urn:iki:space:dynamic:workshop, drawn as nested boxes: the space and the declaration it is built from, and inside it what it holds now. The host's other two layers are not drawn. At this stage: dynamic space urn:iki:space:dynamic:workshop, built from urn:iki:tutorial:live:declaration; holding: an endpoint space with no doors.

dynamic space urn:iki:space:dynamic:workshop, built from urn:iki:tutorial:live:declaration
  holding: an endpoint space with no doors

The status says what is in force (nothing), how many declarations have bound (none), and why: the loop’s first read found no declaration. A space that cannot read its declaration binds nothing, rather than guessing. Until a declaration binds, every name in it answers Unresolved.

Doors, added by a write

Now write a declaration that binds all four endpoints. Three doors use the names Part I used. toUpper gets one in the tutorial’s namespace, urn:iki:tutorial:upper, because the declaration chooses the names and the endpoint does not. In the page, the write is a sink to the declaration, on one line, and the reads after it ask each door and the status. Run it after stage 1’s cell:

ok
[uncacheable]
resourceOrientedComputing
[computed]
RESOURCE ORIENTED COMPUTING
[computed]
resourceOrientedComputing
[computed]
space: urn:iki:space:dynamic:workshop
declaration: urn:iki:tutorial:live:declaration
in force: b3:1fb3e978a7d7700944d5cb5ece16d741453c3de61ad7547be867d4ff14f0bbef
installs: 1
refused: nothing since the last bind
[uncacheable]

The write bound, so the space changed, and this is the second stage:

installs: 1 · refused: nothing since the last bind

The live host's dynamic space urn:iki:space:dynamic:workshop, drawn as nested boxes: the space and the declaration it is built from, and inside it what it holds now. The host's other two layers are not drawn. At this stage: dynamic space urn:iki:space:dynamic:workshop, built from urn:iki:tutorial:live:declaration; holding: an endpoint space with 4 doors, tried in order; door urn:iki:tutorial:camel-case, answered by camel-case; door urn:iki:tutorial:title, answered by title; door urn:iki:tutorial:camel-title, answered by camel-title; door urn:iki:tutorial:upper, answered by toUpper.The live host's dynamic space urn:iki:space:dynamic:workshop, drawn as nested boxes: the space and the declaration it is built from, and inside it what it holds now. The host's other two layers are not drawn. At this stage: dynamic space urn:iki:space:dynamic:workshop, built from urn:iki:tutorial:live:declaration; holding: an endpoint space with 4 doors, tried in order; door urn:iki:tutorial:camel-case, answered by camel-case; door urn:iki:tutorial:title, answered by title; door urn:iki:tutorial:camel-title, answered by camel-title; door urn:iki:tutorial:upper, answered by toUpper.

dynamic space urn:iki:space:dynamic:workshop, built from urn:iki:tutorial:live:declaration
  holding: an endpoint space with 4 doors, tried in order
    door urn:iki:tutorial:camel-case, answered by camel-case
    door urn:iki:tutorial:title, answered by title
    door urn:iki:tutorial:camel-title, answered by camel-title
    door urn:iki:tutorial:upper, answered by toUpper

All four answer, and camel-title still reads title through the kernel, as it did in Part I. Its sub-request resolves from the root, finds title in the dynamic space, and composes as before. The status counts one install. Its in force line is now the digest of the declaration that bound.

The picture changed without anyone asking for it to change. urn:diagram:kernel is a pure function of urn:kernel:topology, so it is cached, and the topology hangs from the kernel’s bindings thread, urn:kernel:bindings. A successful bind cuts that thread. That cut is the only thing that makes the picture stale: nothing polls, and nothing redraws a picture nobody asks for. The words hang from the same thread, for the same reason, and the page’s next read of each draws the stage below the tabs.

A page that listens for that thread (see Spreadsheet, VII, later in the book) would redraw the picture the moment a change binds, with nobody running anything. That is push, and the bindings thread is what it would listen to.

A door removed, and what it served

Before you change a space, you can ask what the change will cost. Every answer the space has served hangs from its thread, so urn:kernel:dependents lists them, as it did for title’s thread in Golden threads in practice. After stage 2’s cell: read camel-title (cached, since that cell read it), ask what hangs from the space’s thread, remove the door with a declaration without camel-title, and ask both again:

resourceOrientedComputing
[cached]
dependents of urn:iki:space:dynamic:workshop  (cut 1 time)
  4 live cached entries hang from it — a cut makes them stale, and the next read of each recomputes it
  urn:iki:tutorial:camel-case   text/plain                     25 B  2 threads   root
  urn:iki:tutorial:camel-title  text/plain                     25 B  3 threads   root
  urn:iki:tutorial:title        text/plain                     27 B  2 threads   root
  urn:iki:tutorial:upper        text/plain                     27 B  2 threads   root
[uncacheable]
ok
[uncacheable]
error: no endpoint resolved for urn:iki:tutorial:camel-title
dependents of urn:iki:space:dynamic:workshop  (cut 2 times)
  (no live cached entry hangs from it: a cut would recompute nothing)
[uncacheable]

The door is gone from the space, which makes the third stage:

installs: 2 · refused: nothing since the last bind

The live host's dynamic space urn:iki:space:dynamic:workshop, drawn as nested boxes: the space and the declaration it is built from, and inside it what it holds now. The host's other two layers are not drawn. At this stage: dynamic space urn:iki:space:dynamic:workshop, built from urn:iki:tutorial:live:declaration; holding: an endpoint space with 3 doors, tried in order; door urn:iki:tutorial:camel-case, answered by camel-case; door urn:iki:tutorial:title, answered by title; door urn:iki:tutorial:upper, answered by toUpper.The live host's dynamic space urn:iki:space:dynamic:workshop, drawn as nested boxes: the space and the declaration it is built from, and inside it what it holds now. The host's other two layers are not drawn. At this stage: dynamic space urn:iki:space:dynamic:workshop, built from urn:iki:tutorial:live:declaration; holding: an endpoint space with 3 doors, tried in order; door urn:iki:tutorial:camel-case, answered by camel-case; door urn:iki:tutorial:title, answered by title; door urn:iki:tutorial:upper, answered by toUpper.

dynamic space urn:iki:space:dynamic:workshop, built from urn:iki:tutorial:live:declaration
  holding: an endpoint space with 3 doors, tried in order
    door urn:iki:tutorial:camel-case, answered by camel-case
    door urn:iki:tutorial:title, answered by title
    door urn:iki:tutorial:upper, answered by toUpper

camel-title answers Unresolved, the same answer as a name that was never bound, and its cached answer is gone. If the cache had kept it, a reader would be served an answer from a door that no longer exists, and no read could tell them so.

The (cut 1 time) is the first bind. A space’s thread is cut by every bind, including the one that filled it.

A change refused

A declaration may only arrange what the host registered. This cell writes one that keeps toUpper and adds a Lisp function, lisp-reverse, which this host never registered. After the cell above: read camel-case (computed again, because the last cell’s write cut what the space had served), write the refused declaration on one line, ask the status, read camel-case again, ask whether the stage’s picture is still cached (the light one, exactly as the stages viewer asks for it), and try the door the refused declaration wanted:

resourceOrientedComputing
[computed]
ok
[uncacheable]
space: urn:iki:space:dynamic:workshop
declaration: urn:iki:tutorial:live:declaration
in force: b3:f7851677bf49b2836e3bde448d7426881777cf6b4ef1c84eb1ee6da1090b0993
installs: 2
refused: <urn:ikigai:space:_:1:door:2> binds `lisp-reverse`, which the host did not register: a declaration arranges registered endpoints and never mints one
refused at: urn:ikigai:space:_:1:door:2
refused digest: b3:ce2d0b4eba8293992408c920f12d708cb45646334556b046ad6fd1985cb66605
[uncacheable]
resourceOrientedComputing
[cached]
cached
error: no endpoint resolved for urn:iki:tutorial:reverse

installs: 2 counts the two declarations the cells above bound; the refused one is not among them. The in force digest is still the last good one’s, and camel-case, read just before the write, is served from the cache after it. So is the picture: the refusal cut neither the space’s thread nor the kernel’s bindings, so the picture and the words are the same three doors they were before it. No change, then: this cell makes no stage. The line above the tabs moves, because the status says what was refused, but nothing the space holds did.

A view: a name for a name the host already serves

This cell writes a declaration that is one view and nothing else: a name, urn:iki:tutorial:live:picture, asked again at the root as urn:diagram:kernel, the picture the host already serves. A declaration is the whole space, so the three doors the cells above left go. After the write: the status, a trace of the view (its answer is the picture, so the trace shows the first bytes of it, and the sub-request the view made at the root), and then a refused caller. cap narrows the cell’s session to urn:cap:kernel:listen, both names are refused in the same words, and cap reset widens the session back to root:

ok
[uncacheable]
space: urn:iki:space:dynamic:workshop
declaration: urn:iki:tutorial:live:declaration
in force: b3:401d33fef281fe3eba936a037cd2aa691a44f5105f53bc408daec623d9c31991
installs: 3
refused: nothing since the last bind
[uncacheable]
trace  urn:iki:tutorial:live:picture
  client      ikigai repl  ·  capability: root (full authority)
  transport   embedded · in-process
 
urn:iki:tutorial:live:picture   ? · computed · ThreadId(1) · — · answered-by=urn:iki:space:dynamic:workshop   → 9291b  <svg xmlns="http://www.w3.org/2000/svg" …
└─ urn:diagram:kernel   diagram-kernel · computed · ThreadId(1) · — · answered-by=urn:iki:space:diagram defaulted=scheme
   └─ urn:kernel:topology   kernel-topology · computed · ThreadId(1) · — · defaulted=as
narrowed — capability: urn:cap:kernel:listen
error: denied: capability does not grant `urn:cap:kernel:inspect` (declared by `urn:diagram:kernel`)
error: denied: capability does not grant `urn:cap:kernel:inspect` (declared by `urn:diagram:kernel`)
reset to identity — capability: root (full authority)

The trace is the view at work: the dynamic space answered the name, and its answer came from a sub-request for urn:diagram:kernel, which the diagram’s space answered at the root. The two refusals are the same line, word for word: the view added no authority, and it took none away either. cap reset matters to what comes after this cell: the stages viewer reads the picture and the words as lines in the cell’s own session, which a cell left narrowed would leave unable to read either.

The view is now the dynamic space’s only content, one rule, and the fourth stage:

installs: 3 · refused: nothing since the last bind

The live host's dynamic space urn:iki:space:dynamic:workshop, drawn as nested boxes: the space and the declaration it is built from, and inside it what it holds now. The host's other two layers are not drawn. At this stage: dynamic space urn:iki:space:dynamic:workshop, built from urn:iki:tutorial:live:declaration; holding: an alias with 1 rule; rule urn:iki:tutorial:live:picture to urn:diagram:kernel, asked again from the root; over: an endpoint space with no doors.The live host's dynamic space urn:iki:space:dynamic:workshop, drawn as nested boxes: the space and the declaration it is built from, and inside it what it holds now. The host's other two layers are not drawn. At this stage: dynamic space urn:iki:space:dynamic:workshop, built from urn:iki:tutorial:live:declaration; holding: an alias with 1 rule; rule urn:iki:tutorial:live:picture to urn:diagram:kernel, asked again from the root; over: an endpoint space with no doors.

dynamic space urn:iki:space:dynamic:workshop, built from urn:iki:tutorial:live:declaration
  holding: an alias with 1 rule
    rule urn:iki:tutorial:live:picture to urn:diagram:kernel, asked again from the root
    over: an endpoint space with no doors

The stages in Rust

Every stage above, as a listing that runs on every build of the book against the same host, in crates/live-space, with the background the stages lean on.

Stage 1: an empty space, with a name

The same two questions in Rust:

#![allow(unused)]
fn main() {
extern crate live_space;
extern crate ikigai_core;
use ikigai_core::Error;
use live_space::{source, Host};

let host = Host::start("workshop");

let status = host.read(source("urn:iki:space:dynamic:workshop:status", &[])).unwrap();
assert_eq!(
    status,
    "\
space: urn:iki:space:dynamic:workshop
declaration: urn:iki:tutorial:live:declaration
in force: nothing (the space is empty)
installs: 0
refused: the declaration could not be read: not found: urn:iki:tutorial:live:declaration
"
);

// Empty means empty: a name the registry could serve answers nothing yet.
let camel = source("urn:iki:tutorial:camel-case", &[("in", "resource oriented computing")]);
assert!(matches!(host.read(camel), Err(Error::Unresolved(_))));
}

Stage 2: doors, added by a write

In Rust, the same write and reads, and what the write did to the cached picture:

#![allow(unused)]
fn main() {
extern crate live_space;
use live_space::{source, Host};

let mut host = Host::start("workshop");
let picture = || source("urn:diagram:kernel", &[]);
let before = host.read(picture()).unwrap();
assert!(before.contains("6 doors across 3 endpoint spaces"));

host.write(
    r#"(endpoints
  (door "urn:iki:tutorial:camel-case" camel-case)
  (door "urn:iki:tutorial:title" title)
  (door "urn:iki:tutorial:camel-title" camel-title)
  (door "urn:iki:tutorial:upper" toUpper))"#,
);

let words = [("in", "resource oriented computing")];
assert_eq!(
    host.read(source("urn:iki:tutorial:camel-case", &words)).unwrap(),
    "resourceOrientedComputing"
);
assert_eq!(
    host.read(source("urn:iki:tutorial:upper", &words)).unwrap(),
    "RESOURCE ORIENTED COMPUTING"
);
assert_eq!(
    host.read(source("urn:iki:tutorial:camel-title", &[])).unwrap(),
    "resourceOrientedComputing"
);

let status = host.read(source("urn:iki:space:dynamic:workshop:status", &[])).unwrap();
assert!(status.contains("\ninstalls: 1\n"));
assert!(status.ends_with("refused: nothing since the last bind\n"));

// The picture was cut by the bind, and redraws with the four new doors.
assert!(!host.is_cached(&picture()));
let after = host.read(picture()).unwrap();
assert!(after.contains("10 doors across 3 endpoint spaces"));
assert!(after.contains("door 4: urn:iki:tutorial:upper (exact) is answered by toUpper"));
}

Stage 3: a door removed, and what it served

In Rust, the same steps, which also count how often title runs:

#![allow(unused)]
fn main() {
extern crate live_space;
extern crate ikigai_core;
use std::sync::atomic::Ordering;
use ikigai_core::Error;
use live_space::{source, Host};

let mut host = Host::start("workshop");
host.write(
    r#"(endpoints
  (door "urn:iki:tutorial:camel-case" camel-case)
  (door "urn:iki:tutorial:title" title)
  (door "urn:iki:tutorial:camel-title" camel-title)
  (door "urn:iki:tutorial:upper" toUpper))"#,
);
let words = [("in", "resource oriented computing")];
let camel_title = || source("urn:iki:tutorial:camel-title", &[]);
let title = || source("urn:iki:tutorial:title", &[]);
host.read(source("urn:iki:tutorial:camel-case", &words)).unwrap();
host.read(source("urn:iki:tutorial:upper", &words)).unwrap();
host.read(camel_title()).unwrap();
assert_eq!(host.title.reads.load(Ordering::SeqCst), 1);

let dependents = |host: &Host| {
    host.read(source("urn:kernel:dependents", &[("thread", "urn:iki:space:dynamic:workshop")]))
        .unwrap()
};
assert_eq!(
    dependents(&host),
    "\
dependents of urn:iki:space:dynamic:workshop  (cut 1 time)
  4 live cached entries hang from it — a cut makes them stale, and the next read of each recomputes it
  urn:iki:tutorial:camel-case   text/plain                     25 B  2 threads   root
  urn:iki:tutorial:camel-title  text/plain                     25 B  3 threads   root
  urn:iki:tutorial:title        text/plain                     27 B  2 threads   root
  urn:iki:tutorial:upper        text/plain                     27 B  2 threads   root
"
);

host.write(
    r#"(endpoints
  (door "urn:iki:tutorial:camel-case" camel-case)
  (door "urn:iki:tutorial:title" title)
  (door "urn:iki:tutorial:upper" toUpper))"#,
);

// The door is gone, and so is what it served.
assert!(matches!(host.read(camel_title()), Err(Error::Unresolved(_))));
assert!(dependents(&host).contains("a cut would recompute nothing"));

// So is everything else the space served: `title` kept its door, and is read again.
assert!(!host.is_cached(&title()));
host.read(title()).unwrap();
assert_eq!(host.title.reads.load(Ordering::SeqCst), 2);
}

The listing also shows what version one of the cut costs. title kept its door, but its cached answer went too, and the next read ran the endpoint again. This is the coarse cut: any change to a dynamic space cuts everything it served, because every answer hangs from the same thread. Cutting only what the changed doors served (ledger #2034) is the finer version, not built yet. The coarse one is never wrong, only sometimes wasteful: the cost of a change is one recompute of each answer the space had served.

The cut is also what lets the space keep one name across versions. A space’s name is a promise to the cache: same name, same doors. A dynamic space breaks that on every change, and keeps the name anyway, because nothing cached under the old doors survives the change.

Stage 3, unchanged: a change refused

The same refusal in Rust:

#![allow(unused)]
fn main() {
extern crate live_space;
extern crate ikigai_core;
use ikigai_core::Error;
use live_space::{source, Host};

let mut host = Host::start("workshop");
host.write(
    r#"(endpoints
  (door "urn:iki:tutorial:camel-case" camel-case)
  (door "urn:iki:tutorial:upper" toUpper))"#,
);
let words = [("in", "resource oriented computing")];
let camel = || source("urn:iki:tutorial:camel-case", &words);
host.read(camel()).unwrap();

host.write(
    r#"(endpoints
  (door "urn:iki:tutorial:upper" toUpper)
  (door "urn:iki:tutorial:reverse" lisp-reverse))"#,
);

let status = host.read(source("urn:iki:space:dynamic:workshop:status", &[])).unwrap();
assert!(status.contains("\ninstalls: 1\n"));
assert!(status.contains(
    "\nrefused: <urn:ikigai:space:_:1:door:2> binds `lisp-reverse`, which the host did not \
     register: a declaration arranges registered endpoints and never mints one\n\
     refused at: urn:ikigai:space:_:1:door:2\n"
));

// The doors in force are the ones that bound last, and what they served is still cached.
assert!(host.is_cached(&camel()));
assert_eq!(host.read(camel()).unwrap(), "resourceOrientedComputing");
assert!(matches!(
    host.read(source("urn:iki:tutorial:reverse", &[("in", "a b")])),
    Err(Error::Unresolved(_))
));
}

The refusal is the one Tic-tac-toe VII’s undo declaration met, word for word, because it is the same check. Here it does not stop anything. The doors that bound last stay in force, a refused change cuts nothing, and the status says what was refused, where, and the digest of the text that was refused. The next good declaration clears it.

A dynamic space fails closed in both directions. It starts empty rather than guessing, and a bad change keeps the last good doors rather than half-applying. Getting a Lisp function into the space is the host’s job, not the declaration’s: a host that links ikigai-lisp and registers its functions could bind them by name exactly as toUpper is bound here. This book’s crates do not link it.

There is no capability for changing a dynamic space. The authority to change it is the authority to write its declaration, which whatever store holds the declaration already grants or refuses. This host’s declaration takes any write from root, and a real host would put it behind a store that checks.

Stage 4: a view

Why a view, and not the alias rule the book already has: an alias rule, from Scope and alias, renames one name to another inside one space: the rewritten name is looked for in the alias’s own child. Inside a dynamic space that is a trap. A rule that pointed at urn:diagram:kernel would look for it inside the dynamic space and miss, because the host serves it in a different layer.

ikigai-core 0.1.110 adds the other kind of rule: one that resolves at the root. A name it matches is asked again from the root of the caller’s chain, under the caller’s own capability. That is a view. The cell’s declaration, with its comments, is this s-expression:

; One view, and nothing else: `urn:iki:tutorial:live:picture` is another name for
; `urn:diagram:kernel`, which the host serves outside the dynamic space. `:resolve-at root` is
; what makes it a view: the rewritten name is asked from the root, under the caller's own
; capability, instead of inside this alias's empty child. view.ttl is the same declaration as
; the `ik:` Turtle core reads.
(alias
  (exact "urn:iki:tutorial:live:picture" "urn:diagram:kernel" :resolve-at root)
  (endpoints))

:resolve-at root is what makes the rule a view; without it, the rule resolves in its own child, as every rule did before. The declaration’s Source answers it as the Turtle core reads, where the same word is ik:resolveAt "root":

# One view, and nothing else: `urn:iki:tutorial:live:picture` is another name for
# `urn:diagram:kernel`, which the host serves outside the dynamic space. `ik:resolveAt "root"`
# is what makes it a view: the rewritten name is asked from the root, under the caller's own
# capability, instead of inside this alias's empty child.
@prefix ik: <https://ikigai-rs.dev/ns#> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .

<urn:ikigai:space:_:1> a ik:Alias ;
    ik:maxHops 8 ;
    ik:rewrites <urn:ikigai:space:_:1:rule:1> ;
    ik:space <urn:ikigai:space:_:2> .

<urn:ikigai:space:_:1:rule:1> a ik:RewriteRule ;
    ik:ruleKind "exact" ;
    ik:logical "urn:iki:tutorial:live:picture" ;
    ik:canonical "urn:diagram:kernel" ;
    ik:resolveAt "root" .

<urn:ikigai:space:_:2> a ik:EndpointSpace ;
    ik:doors rdf:nil .

The two are one declaration. ikigai-sexpr 0.2.4 reads and prints :resolve-at, so the s-expression goes to Turtle and back without losing the view, and the listing, which starts from an empty space, checks that round trip as well as the view itself:

#![allow(unused)]
fn main() {
extern crate live_space;
extern crate ikigai_core;
extern crate ikigai_sexpr;
use ikigai_core::{Capability, Error, Topology};
use live_space::{source, Host, VIEW, VIEW_TURTLE};

let mut host = Host::start("workshop");
host.write(VIEW);
let status = host.read(source("urn:iki:space:dynamic:workshop:status", &[])).unwrap();
assert!(status.ends_with("refused: nothing since the last bind\n"));

// The view answers what the name it points at answers.
let view = || source("urn:iki:tutorial:live:picture", &[]);
let picture = host.read(view()).unwrap();
assert_eq!(picture, host.read(source("urn:diagram:kernel", &[])).unwrap());
assert!(picture.contains("1 rule tried in order"));

// It grants nothing. `urn:diagram:kernel` needs `urn:cap:kernel:inspect`; a caller without
// it is refused through the view in the same words as when asking directly.
let listener = Capability::scoped(["urn:cap:kernel:listen"]);
let direct = host.read_as(source("urn:diagram:kernel", &[]), &listener);
let through = host.read_as(view(), &listener);
assert!(matches!(direct, Err(Error::Denied(_))));
assert_eq!(direct.unwrap_err().to_string(), through.unwrap_err().to_string());

// The s-expression and the Turtle are one declaration, and the round trip keeps the view.
let read = ikigai_sexpr::arrangement_to_topology(VIEW).unwrap();
assert_eq!(read, Topology::from_turtle(VIEW_TURTLE).unwrap());
let printed = ikigai_sexpr::topology_to_arrangement(&read).unwrap();
assert!(printed.contains(":resolve-at root"));
assert_eq!(ikigai_sexpr::arrangement_to_topology(&printed).unwrap(), read);
}

A view can only read what the caller could already read. Its forwarding endpoint declares no capability, and its sub-request carries the caller’s. It also never stands in for a name an earlier layer answers, because the dynamic space is consulted only after the layers ahead of it. The view’s answer is a cache entry of its own that depends on the target’s: cutting urn:kernel:bindings redraws both. Inside a dynamic space it also hangs from the space’s thread, so the next change to the declaration cuts it, like everything else the space served.

What it does not do yet

  • The cut is coarse. Any successful change cuts everything the space served, including the answers of doors that did not change (ledger #2034).
  • One loop per space. Two loops rebuilding the same space are not serialized, and the last to bind wins. A host runs one.
  • No sealing levels. A level that seals names is refused in a dynamic space, because a running kernel’s seals are fixed when it is built.
  • A loop under a narrowed capability can miss a write (ledger #2048). drive hears a write to the declaration only through an answer cached under its own capability that hangs from the declaration’s thread. Under root it hears every write. Under a narrower capability that can listen, a declaration served uncacheable is never cached, so the loop binds once, at start, and never hears a write again. The same happens to this chapter’s own declaration until it is first written, because “not found” is not cached either. This host runs its loop under root; a host that narrows it must refresh on a signal of its own.
  • The status door is not in the topology (ledger #2049). …:status answers, and the space’s entries (so the kernel’s catalog) list it, but DynamicSpace’s topology reports only the inner space. No picture above shows it, nor do the words, and a reader who learns the host from its picture cannot find it.

The same mechanism, one thread per space, also gives a server a cheap way to forget a session. Spreadsheet, VII, later in the book, shows it.

What was built

whatwherewhat it is
the endpointslive_space::registrycode: four endpoints Part I and ikigai-fn already had, registered by name and bound nowhere
the declarationurn:iki:tutorial:live:declarationdata: an arrangement, written by a Sink, read as Turtle
the spaceurn:iki:space:dynamic:workshopcore’s DynamicSpace: empty until a declaration binds, rebuilt on every write, one thread for everything it served
its statusurn:iki:space:dynamic:workshop:statuslive state: what is in force, how many binds, the last refusal and where
the loopDynamicSpace::drivea future the host runs: hear the declaration’s cut, read, check, bind
the pictureurn:diagram:kernelikigai-diagram 0.2.4: the host’s arrangement, cached and redrawn when a bind cuts urn:kernel:bindings; focus= draws one space of it
the wordsurn:iki:tutorial:live:arrangementa composition: the dynamic space in words, read from the same topology, cached and cut with the picture
a viewview.ttldata: an alias rule that resolves at the root, and grants nothing

Tic-tac-toe VII moved where the endpoints are bound out of the code. This chapter makes that data live: the host’s arrangement now changes the way any other resource does, by a write, and the cache, the status and the picture all follow from one cut.

The next chapter uses the same declaration resource and the same loop for a smaller job: a dynamic space that binds only a prefix table, carried as a corridor around a composition, so short names like json:query exist inside it and nowhere else.

Short names in a space

A space that changes while it runs put a declaration resource in front of a dynamic space and changed the space by writing it. This chapter uses the same mechanism for a smaller job: names. A composition that reads a dozen resources spells out a dozen full names, urn:json:query and urn:iki:tutorial:shop:stock again and again. Short names would make it easier to write and to read. The question is where short names may exist without changing what any other name means.

The answer here is the one Scope and alias gave for renaming: a name is resolved in a space, and an alias rule is a declaration in one. A prefix table is an alias whose rules rewrite json: to urn:json: and shop: to urn:iki:tutorial:shop:. It lives in a dynamic space, and the space is carried as a corridor around the requests that compose. Inside, shop:stock is a name. Outside, there is no such name.

The data is the shop’s stock from Transreption: a CSV table, read as JSON by urn:json:query. The code is crates/shop. Nothing here needs a server or a REPL. The live host on the page of A space that changes while it runs, if it is still open, is a different page’s kernel and changes nothing here.

The prefix table is data

The table is an arrangement, in the s-expression form A space that changes while it runs wrote:

; The shop's prefix table: two short names, each a prefix rule that resolves from the root under
; the caller's own capability (`:resolve-at root`), over an empty space. `json:query` is
; `urn:json:query`, and `shop:stock` is `urn:iki:tutorial:shop:stock`. ikigai-fn's
; `prefix_check` refuses any other shape, and any prefix under a scheme the host's own names use.
(alias
  (prefix "json:" "urn:json:" :resolve-at root)
  (prefix "shop:" "urn:iki:tutorial:shop:" :resolve-at root)
  (endpoints))

Each rule is :resolve-at root, which that chapter’s view introduced: the rewritten name is asked again from the root of the caller’s chain, under the caller’s own capability. A short name is a view of a full name, so it grants nothing. A caller who may not read urn:iki:tutorial:shop:stock may not read shop:stock either.

The table lives at urn:iki:tutorial:shop:prefixes, a declaration resource of the same kind as that chapter’s (the crate reuses live_space::declaration): Sink writes an arrangement, and Source answers the ik: Turtle core builds a space from. The space is ikigai-fn’s prefix_space, which is a DynamicSpace with a check of its own:

/// The shop's names: the stock, the prefix table, the three templates, and the two views that
/// fill them. Nothing here knows that the templates' names are short: they are filled by
/// whatever the request's resolution chain makes of them.
pub fn space(stock_state: Arc<StockState>, prefixes: Arc<DeclarationState>) -> EndpointSpace {
    let mut space = EndpointSpace::new()
        .bind(Exact::new(STOCK), stock(stock_state))
        .bind(Exact::new(PREFIXES), live_space::declaration(prefixes));
    for (name, text) in TEMPLATES {
        space = space.bind(
            Exact::new(format!("urn:iki:tutorial:shop:template:{name}")),
            template(name, text),
        );
    }
    space
        .bind(
            Exact::new(SUMMARY),
            ikigai_fn::compose_over(iri("urn:iki:tutorial:shop:template:summary"))
                .named("shop-summary"),
        )
        .bind(
            Exact::new(RECORDS),
            ikigai_fn::compose_over(iri("urn:iki:tutorial:shop:template:records"))
                .named("shop-records"),
        )
}

/// The prefix space called `name`: an empty dynamic space built from [`PREFIXES`], which binds
/// only a prefix table (ikigai-fn's `prefix_space` checks it), and is named
/// `urn:iki:space:dynamic:{name}`.
pub fn prefixes(name: &str) -> DynamicSpace {
    ikigai_fn::prefix_space(name, iri(PREFIXES))
}

/// The host's root: the shop's names, then ikigai-json's (`urn:json:*`), then ikigai-fn's
/// (`urn:iki:fn:*`). The prefix space is not in it: it is a corridor a request carries.
pub fn root(stock_state: Arc<StockState>, prefixes: Arc<DeclarationState>) -> Fallback {
    Fallback::new(vec![
        Arc::new(space(stock_state, prefixes)) as Arc<dyn Space>,
        Arc::new(ikigai_json::space()),
        Arc::new(ikigai_fn::space()),
    ])
}

The check refuses a table that could do anything but rename. It accepts one alias, of rules that resolve from the root, over an empty space with no doors. A prefix under a scheme the host’s own names use is refused too: urn, http, https, file, data and the rest. The corridor is consulted ahead of the root, so a prefix urn: could stand in for every name the host has. Under the reserved schemes it never can.

Carried as a corridor

The host starts an empty prefix space called shop and the loop that rebinds it, exactly as that chapter’s host did:

    /// Make the root, an empty prefix space called `name`, and the loop that rebinds it from
    /// [`PREFIXES`]; the corridor is the space, alone.
    pub fn start(name: &str) -> Host {
        let kernel = Arc::new(Kernel::with_meta_renderer(
            Arc::new(root(Arc::default(), Arc::default())),
            Arc::new(ikigai_vocab::TurtleRenderer),
        ));
        let space = Arc::new(prefixes(name));
        let corridor = Scope::empty().with(Arc::clone(&space) as Arc<dyn Space>);
        let pool = LocalPool::new();
        let (looping, on) = (Arc::clone(&space), Arc::clone(&kernel));
        pool.spawner()
            .spawn_local(async move {
                let stopped = looping.drive(&on, &Capability::root()).await;
                panic!("the prefix space's loop stopped: {stopped}");
            })
            .expect("spawn the rebuild loop");
        let mut host = Host {
            kernel,
            space,
            corridor,
            pool,
        };
        host.settle();
        host
    }

What is new is how a request gets into it. The prefix space is not a layer of the root. It is a corridor, injected around one request:

    /// `request` under root, issued IN the prefix space: the space rides the request as a
    /// corridor, so every sub-request it gives rise to, at any depth, resolves the short names.
    pub fn read_in(&self, request: Request) -> Result<String, Error> {
        let root = Capability::root();
        let answer = block_on(self.kernel.issue_in(request, &root, self.corridor.clone()))?;
        Ok(String::from_utf8_lossy(&answer.bytes).into_owned())
    }

A corridor rides the request it was injected for, and every sub-request that request gives rise to, at any depth, inherits it. That matters here. A marker’s shop:stock is not asked by the caller: it is asked by urn:json:query, an endpoint that lives in the root, which asks for its src while it runs. A Level scopes only the sub-requests of endpoints found inside it, and urn:json:query is not inside anything, so a Level would not reach that read. The corridor does.

In the page, the cells below that say “in the prefix space shop” run on the page’s kernel with that space carried as a corridor. The others run at the page’s root. Run them in order: each one changes what the next one reads.

Empty, then a table

Until a table is written, the space holds nothing, and the summary, whose template is written with short names, cannot be composed:

space: urn:iki:space:dynamic:shop
declaration: urn:iki:tutorial:shop:prefixes
in force: nothing (the space is empty)
installs: 0
refused: the declaration could not be read: not found: urn:iki:tutorial:shop:prefixes
[uncacheable]
error: no endpoint resolved for shop:stock
       did you mean `urn:iki:tutorial:shop:stock`? (bound here)

Write the table, on one line, and ask again. The summary composes, and shop:summary, the summary’s own short name, answers the same: a name in a cell’s line is resolved in the cell’s chain like any other. The last line passes a short name as a value, src=shop:stock, and the query reads it in the corridor it inherited:

ok
[uncacheable]
space: urn:iki:space:dynamic:shop
declaration: urn:iki:tutorial:shop:prefixes
in force: b3:24a60324ce76a9284a2c82696491fea287d9f88b1f304992fbe4dc480d4cba74
installs: 1
refused: nothing since the last bind
[uncacheable]
4 items, 49 on the shelves; out of stock: sugar.
[computed]
4 items, 49 on the shelves; out of stock: sugar.
[computed]
flour
[computed]

shop:summary is computed, not served from the line before: a name is a cache key, so the short name has an answer of its own, which depends on the full name’s.

The summary: CSV, JSON, jq and $j

Here is the summary’s template. Each $j is a jq selection of the stock, spliced as text (Tic-tac-toe, V showed that $j is shorthand for a $h of urn:json:query):

$j{shop:stock length} items, $j{shop:stock map(.count | tonumber) | add} on the shelves; out of stock: $j{shop:stock [.[] | select(.count == "0") | .item] | if length == 0 then "nothing" else join(", ") end}.

Follow one marker down. $j{shop:stock length} asks urn:json:query (its own name, fixed) for src=shop:stock and q=length. The query endpoint reads shop:stock, which the corridor rewrites to urn:iki:tutorial:shop:stock and asks again from the root. That answers text/csv, so the query plans the CSV transreptor, urn:json:from-csv, and runs length over the array of records it makes. Four resources, two of them reached by short names, and none of them written for this summary.

The same, in Rust:

#![allow(unused)]
fn main() {
extern crate shop;
extern crate ikigai_core;
use ikigai_core::Error;
use shop::{source, Host, PREFIXES, PREFIX_TABLE, SUMMARY};

let mut host = Host::start("shop");
host.write(PREFIXES, PREFIX_TABLE);

let summary = || source(SUMMARY, &[]);
assert_eq!(
    host.read_in(summary()).unwrap(),
    "4 items, 49 on the shelves; out of stock: sugar."
);
assert!(host.is_cached_in(&summary()));

// Outside the space, the template's short names do not exist.
assert!(matches!(host.read(summary()), Err(Error::Unresolved(_))));
assert!(matches!(host.read(source("shop:stock", &[])), Err(Error::Unresolved(_))));
}

Outside, no such name

At the page’s root, with no corridor, the same two names:

error: no endpoint resolved for shop:stock
       did you mean `urn:iki:tutorial:shop:stock`? (bound here)
error: no endpoint resolved for shop:stock
       did you mean `urn:iki:tutorial:shop:stock`? (bound here)

shop:stock is not a name the page’s kernel has. The summary is, but its template asks for shop:stock, so outside the space it fails the same way. The prefix table changed what names exist for the requests that carry it, and for nothing else on the page.

A collection: each

urn:iki:fn:each fills one template once per element of a JSON array. The array is shop:records, a template that is one $r of json:query over the stock with the program ., and the row is $h{{item}} ($h{{count}}):

flour (12), sugar (0), rice (30), lentils (7)
[computed]
error: no endpoint resolved for tmpl:row
       did you mean `urn:iki:tutorial:shop:template:row`? (bound here)

The first line fills the row once per record. The second asks for tmpl:row, and the table has no tmpl: prefix.

A new prefix, while it runs

Writing a new prefix is a Sink on the declaration. The host’s loop hears the write as a cut of the declaration’s thread, rebinds the space, and the names inside it change. Nothing restarts. Add tmpl: for the shop’s templates, and ask again. Then ask about the summary, which the first table composed:

ok
[uncacheable]
$h{{item}} ($h{{count}})
[computed]
not cached
4 items, 49 on the shelves; out of stock: sugar.
[computed]

The summary was cached, and the new table cut it, though the summary uses no tmpl: name. That is that chapter’s coarse cut: every answer served through the space hangs from the space’s one thread, and every successful bind cuts it. It is never wrong, only sometimes wasteful: what was composed with the old table recomputes once.

The data changes too

A write to the stock cuts the stock’s thread, and the summary hangs from it through every $j. This line keeps only what is on a shelf, the same round trip Transreption ran, with the stock named short where it is a value:

ok
[2 computed · 1 uncacheable]
3 items, 49 on the shelves; out of stock: nothing.
[computed]

A table refused

A table that declares a prefix under a scheme the host’s names use is refused before it binds. Here it claims file::

ok
[uncacheable]
space: urn:iki:space:dynamic:shop
declaration: urn:iki:tutorial:shop:prefixes
in force: b3:680caaa80e550dcaf0c1c73445e5c5afd4a8e8a6cdd326c9b034921867d3e8ac
installs: 2
refused: the host's check refused it: prefix `file:` is under the reserved scheme `file:` — the host's own names live there, and a prefix may never stand in for one
refused digest: b3:5c613c265b34d7e9f9166633782a60a66117656c2ebbe07601ba97f5b99636fd
[uncacheable]
cached
3 items, 49 on the shelves; out of stock: nothing.
[cached]

As in A space that changes while it runs, a refused change cuts nothing, and the table that bound last stays in force. The status says what was refused and why.

What it does not do

  • The cut is coarse, as in that chapter (ledger #2034): any new table recomputes everything composed through the space.
  • The reserved schemes are the whole guarantee. A host that serves names under a scheme of its own (page:, say) and also declares a prefix page: would have that prefix stand in for its names inside the corridor. Core has no placement for “answer only if the root misses”, so ikigai-fn refuses the schemes every ikigai host uses, and every ikigai host’s names are urn:.
  • A short name is not a line’s target when it takes arguments. Inside a composition every short name works, arguments and all: a marker’s json:query?src=shop:stock&q=. builds its request directly. A REPL line is different. The engine recognizes key=value by the target’s contract, which it reads from the target’s Meta, and the Meta of a short name describes the forwarding endpoint the rule binds, which declares no inputs. So source json:query src=shop:stock q=. sends src=… as positional text and is refused for a missing q. The cells above name the endpoint in full and pass short names as values.
  • A sandbox bound in the page is an abort. jq runs metered and bounded. Natively a bound reached is a typed refusal. In this page, WebAssembly cannot unwind, so it traps the page’s kernel until a reload. The cells here are far inside the bounds; keep an edited program small.

What was built

whatwherewhat it is
the stockurn:iki:tutorial:shop:stockan atom: CSV, written by a Sink, cached and cut
the CSV as JSONurn:json:query, urn:json:from-csvikigai-json: jq over the stock, through the CSV transreptor
the prefix tableurn:iki:tutorial:shop:prefixesdata: an alias of root-resolving prefix rules, written by a Sink
the prefix spaceurn:iki:space:dynamic:shopikigai-fn’s prefix_space: the table, bound, and checked
the corridorKernel::issue_inthe space carried by a request and every sub-request it makes
the summaryurn:iki:tutorial:shop:summarya template of three $j markers, filled
the listurn:iki:fn:eacha row template filled once per record

The only code is the two atoms. The CSV reader, the query language, the template language, the iteration and the short names are published resources, composed. A prefix is not a feature of the template language: it is a name, declared in a space, and the space decides where it exists.

Time is a resource

Everything the book has built so far changes only when something writes. A Sink plays a mark, the kernel cuts the stored cell’s golden thread, and whatever was computed from that cell is computed again the next time somebody asks. Nothing changes on its own.

Time does. The answer to “what time is it?” goes out of date with nobody writing anything, so a golden thread cannot say when to let it go. The kernel has a second way to let an answer go, and this chapter is about it: expiry. A representation can say keep me until this instant, and the kernel serves it from its cache until then and not a millisecond longer.

The chapter builds a clock, a date, a named event and a countdown to it, and ends with a clock on the page that asks for the time every second. The lesson is in how often the kernel actually does any work when it is asked that often.

The model, on paper

As in the tic-tac-toe parts, the names come first:

the setits namewhat it is
nowurn:iki:tutorial:time:nowthe time of day, HH:MM, read from the kernel’s clock; good until the next minute
todayurn:iki:tutorial:time:todaythe date, YYYY-MM-DD; good until the next midnight
an eventurn:iki:tutorial:time:event:{name}a named instant, YYYY-MM-DDTHH:MMZ, set with Sink
the instanturn:iki:tutorial:time:instanttoday and now as one reading, YYYY-MM-DDTHH:MMZ; good until the next minute
a countdownurn:iki:tutorial:time:until:{name}minutes from now until the event
a templateurn:iki:tutorial:time:template:{name}the chapter’s HTML
the clock viewurn:iki:tutorial:time:view:clockthe time as HTML, for a page to show

Only the event holds state: it is the one name a Sink writes, and it holds an instant until somebody moves it. now and today hold nothing. They read the clock, and the clock is not theirs.

/// The time of day, `HH:MM`, UTC — until the next minute.
pub const NOW: &str = "urn:iki:tutorial:time:now";

/// The date, `YYYY-MM-DD`, UTC — until the next midnight.
pub const TODAY: &str = "urn:iki:tutorial:time:today";

/// The current minute as one instant, `YYYY-MM-DDTHH:MMZ`, never a torn reading of `today`
/// and `now` — until the next minute.
pub const INSTANT: &str = "urn:iki:tutorial:time:instant";

/// A named instant, `YYYY-MM-DDTHH:MMZ`: the chapter's one piece of state.
pub const EVENT: &str = "urn:iki:tutorial:time:event:{name}";

/// Whole minutes from now until the event named `name`; negative once it has passed.
pub const UNTIL: &str = "urn:iki:tutorial:time:until:{name}";

/// A piece of the chapter's HTML.
pub const TEMPLATE: &str = "urn:iki:tutorial:time:template:{name}";

/// The clock as HTML: the `clock` template, composed.
pub const VIEW_CLOCK: &str = "urn:iki:tutorial:time:view:clock";

All the times in this chapter are UTC. A time zone is not part of what time it is; it is part of where the person asking is standing, which makes it context rather than a name, and this chapter leaves it out.

A kernel has a clock only if its host gives it one

The core crate reads no clock of its own. A host that wants its kernel to know the time injects a clock with Kernel::with_clock, the way it injects a space or a Meta renderer, and an endpoint asks for the time with Invocation::now. So “what time is it?” is a question the host answers, which is what lets a test answer it with a clock that stands still, and a replay answer it with the instant being replayed.

Almost every page of this book runs a kernel with no clock. That is why a trace in What resolution buys you prints — where a duration would go. This page asks for one, and so do Spreadsheet, IV, whose cells read the time, and Spreadsheet, VI, which keeps a story clock so its week of history reads the same for every reader. This page’s markup carries a data-clock attribute, and when the page sees it, it builds its kernel with the browser’s clock before anything resolves:

/// [`page_kernel`], with `clock` if there is one: the browser's on a page that asked for
/// it, a clock a test holds in a test.
pub fn page_kernel_with_clock(clock: Option<Arc<dyn Clock>>) -> Kernel {
    page_kernel_over(Arc::default(), Arc::default(), clock)
}
/// The browser's clock: `Date.now()`, milliseconds since 1970. `std::time` has no clock on
/// `wasm32-unknown-unknown`, and ikigai-core reads none itself, so the page supplies one,
/// the way `ikigai-web-demo` does.
#[cfg(target_family = "wasm")]
struct BrowserClock;

#[cfg(target_family = "wasm")]
impl Clock for BrowserClock {
    fn now(&self) -> ikigai_core::Time {
        ikigai_core::Time::from_millis(js_sys::Date::now() as u64)
    }
}

Without a clock, the time endpoints do not guess. They refuse, and say why:

/// The kernel's time, or a refusal that says why there is none.
///
/// [`Invocation::now`] is the kernel's clock as this request should see it, and a kernel
/// has one only when its host injected one with `Kernel::with_clock`. Without one there is
/// no honest answer to "what time is it", so there is none: the endpoint refuses rather
/// than read the wall clock behind the host's back.
fn clock(inv: &Invocation<'_>) -> Result<Time> {
    inv.now().ok_or_else(|| {
        Error::Endpoint(
            "this kernel has no clock: its host did not give it one (Kernel::with_clock)"
                .to_string(),
        )
    })
}

On any other page of this book, source urn:iki:tutorial:time:now answers with that refusal.

Now, until the next minute

/// `time-now`: the time of day, `HH:MM`, UTC.
///
/// Cacheable until the next minute boundary, and not a millisecond longer: the answer is
/// right for exactly that long. A caller may ask every second; the kernel answers from its
/// cache until the deadline and runs this again once it has passed.
pub fn now() -> FnEndpoint {
    FnEndpoint::new("time-now", |inv: &Invocation<'_>| {
        let minute = clock(inv)?.as_millis() / MINUTE_MS;
        let text = format!("{:02}:{:02}", (minute / 60) % 24, minute % 60);
        let next_minute = Time::from_millis((minute + 1) * MINUTE_MS);
        Ok(Representation::new(text_plain_utf8(), text.into_bytes()).cacheable_until(next_minute))
    })
    .with_description(
        Description::new("time-now")
            .title("Now")
            .summary("The time of day, HH:MM, UTC, by the kernel's clock. Cacheable until the next minute.")
            .verb(Verb::Source)
            .verb(Verb::Meta)
            .output(TEXT_PLAIN_UTF8),
    )
}

The last line is the whole idea. The time is 14:05 from 14:05:00 to 14:05:59.999, so the answer says so: cacheable_until the start of the next minute. That is an Expiry::At, a deadline, and the kernel checks it against the same clock the answer was read from.

Ask twice. Your times will be your own; the outputs under each cell are from a test that stopped a clock at 14:05:20 UTC on 29 September 2026.

14:05
[computed]
14:05
[cached]

The second answer ran nothing. Now wait for the minute to turn, and ask whether the kernel still holds it:

not cached
14:06
[computed]

Nobody wrote anything and no thread was cut. The deadline passed, so the answer went. The crate’s tests put the boundary exactly where the code says it is, with a clock the test holds and moves by hand:

#[test]
fn now_is_cached_until_the_minute_turns_and_not_a_millisecond_longer() {
    let (kernel, clock) = host(millis_at("2026-09-29T14:05Z", 20));

    assert_eq!(source(&kernel, NOW).unwrap(), "14:05");
    assert_eq!(
        expiry(&kernel, NOW),
        Expiry::At(Time::from_millis(millis_at("2026-09-29T14:06Z", 0)))
    );

    clock.set(millis_at("2026-09-29T14:05Z", 59) + 999);
    assert!(cached(&kernel, NOW), "one millisecond before the minute");

    clock.advance(1);
    assert!(!cached(&kernel, NOW), "on the minute");
    assert_eq!(source(&kernel, NOW).unwrap(), "14:06");
}

today is the same resource with a longer deadline: the date, cacheable until the next midnight.

2026-09-29
[computed]
cached

An event, and a countdown to it

The event is the atom: a map from a name to an instant, in memory, like the tic-tac-toe stored cell. Sink takes an instant spelled YYYY-MM-DDTHH:MMZ, or +N for N minutes from now, and answers the instant it now holds. +90 does not store “ninety minutes from whenever you ask”; it reads the time once, when you set it, and stores where that lands.

The countdown is a composite. It reads the event, today and now, all through the kernel, and subtracts:

/// `time-until`: whole minutes from now until the event, negative once it has passed.
///
/// A composite over three names, all read through the kernel: the event, `today` and
/// `now`. It declares itself `.cacheable()`, with no deadline, and says nothing else about
/// time. The kernel does the rest: a result is never fresher than its least fresh
/// dependency, so this one is cached until `now`'s deadline, the next minute, and cut
/// early by the event's golden thread when the event is moved.
pub fn until() -> AsyncFnEndpoint {
    AsyncFnEndpoint::new("time-until", |inv: &Invocation<'_>| -> InvokeFuture<'_> {
        Box::pin(async move {
            let name = event_binding(inv)?;
            let at = text_of(inv, &event_name(&name)).await?;
            let at = read_back(&event_name(&name), &at, parse_instant(&at))?;
            let minutes = at - this_minute(inv).await?;
            Ok(
                Representation::new(text_plain_utf8(), minutes.to_string().into_bytes())
                    .cacheable(),
            )
        })
    })
    .with_description(
        Description::new("time-until")
            .title("Until")
            .summary("Whole minutes from now until the event; negative once it has passed.")
            .verb(Verb::Source)
            .verb(Verb::Meta)
            .input(name_input())
            .output(TEXT_PLAIN_UTF8),
    )
}

It says .cacheable(), with no deadline, as if its answer were good forever. Set an event ninety minutes out and count down to it:

2026-09-29T15:36Z
[uncacheable]
90
[computed]
90
[cached]

Now let the minute turn again, and ask:

not cached
cached
89
[computed]

The countdown went at the minute, and the event it was computed from did not. Nothing in the countdown’s code mentions a minute. The kernel met the countdown’s own expiry, never, with the expiry of everything it read, and took the soonest: today’s midnight, now’s next minute, and the event’s never come to now’s next minute. A composite is never fresher than the least fresh thing it read. ikigai’s own working rules put it as cacheability is only as good as the least cacheable dependency, and here it is working for you: the countdown is exactly as cacheable as now, and not because anybody said so.

The rule has a second edge, and it cuts the other way. Add a clock to a composite that used to be cached for good, and it is cached for a minute from then on, with nothing in its code or its types to show for it. That is a performance change, even when it is not a correctness one, and the only way to see it is to measure the read.

The event’s golden thread still works as it always did. Move the event, and the countdown goes at once, in the middle of the minute, while now stays cached:

2026-09-29T14:37Z
[uncacheable]
not cached
cached
30
[computed]

Two ways for an answer to go, then, and a composite gets both from what it reads: a thread that a write cuts, and a deadline that a clock passes.

#[test]
fn a_countdown_is_exactly_as_cacheable_as_now() {
    let (kernel, clock) = host(millis_at("2026-09-29T14:06Z", 10));
    let (event, until) = (event_name("tea"), until_name("tea"));

    assert_eq!(sink(&kernel, &event, "+90").unwrap(), "2026-09-29T15:36Z");
    assert_eq!(source(&kernel, &until).unwrap(), "90");

    // It declared no deadline. It has now's.
    assert_eq!(expiry(&kernel, &until), expiry(&kernel, NOW));
    assert!(matches!(expiry(&kernel, &until), Expiry::At(_)));

    // The minute turns: the countdown goes with now, and comes back one less.
    clock.set(millis_at("2026-09-29T14:07Z", 0));
    assert!(!cached(&kernel, &until));
    assert_eq!(source(&kernel, &until).unwrap(), "89");
    assert!(cached(&kernel, &until));

    // The event moves: its golden thread goes, and the countdown with it, mid-minute.
    sink(&kernel, &event, "2026-09-29T14:37Z").unwrap();
    assert!(!cached(&kernel, &until));
    assert!(
        cached(&kernel, NOW),
        "moving the event does not touch the clock"
    );
    assert_eq!(source(&kernel, &until).unwrap(), "30");
}

Two readings of one clock

There is one trap in reading the date and the time as two resources, and it is worth a paragraph because every composition over time meets it. They are read at two different instants. Read today at 23:59:59.999 and now a millisecond later, and you have yesterday’s date with today’s time: a countdown a whole day out. It is not cached a day out: the date it read expired at the midnight it straddled, so the kernel never serves that answer again. But it has already been served once, wrong.

So the countdown reads the date on both sides of the time. If the date is the same both times, the time was read on that date:

/// The current minute as one instant, read from `today` and `now` through the kernel.
///
/// Two resources, two deadlines, two reads: `today` can be read on one side of midnight and
/// `now` on the other, and the pair would name a minute a day away from the real one. So
/// the date is read on both sides of the time. If it is the same both times, the time was
/// read on that date; if it is not, midnight fell between the reads, and one more try
/// cannot meet another midnight.
async fn this_minute(inv: &Invocation<'_>) -> Result<i64> {
    for _ in 0..2 {
        let date = text_of(inv, TODAY).await?;
        let time = text_of(inv, NOW).await?;
        if text_of(inv, TODAY).await? == date {
            let day = read_back(TODAY, &date, parse_date(&date))?;
            let of_day = read_back(NOW, &time, parse_time(&time))?;
            return Ok(day * MINUTES_PER_DAY + of_day);
        }
    }
    Err(Error::Endpoint(
        "the date changed twice while one minute was read".to_string(),
    ))
}

The test for it starts a clock that moves on a millisecond every time anybody reads it, just before midnight, two hundred times over, so that some resolution straddles the boundary. With the second read of the date taken out, it fails, a day out; with it, it passes.

A guard that every composite has to remember is a guard some composite will forget, so the crate also gives the guarded reading a name of its own, urn:iki:tutorial:time:instant: the current minute as one instant, YYYY-MM-DDTHH:MMZ. Whatever wants the date and the time together reads that one name and cannot tear them. It declares no deadline of its own, and is cached until the next minute, which it inherits from now:

/// `time-instant`: the current minute as one instant, `YYYY-MM-DDTHH:MMZ`.
///
/// The guarded reading of `this_minute`, given a name, so that anything wanting "the date
/// and the time" reads one resource rather than two and cannot tear them. Declared
/// `.cacheable()` with no deadline; it read `now`, so the kernel caches it until the next
/// minute, and not a millisecond longer.
pub fn instant() -> AsyncFnEndpoint {
    AsyncFnEndpoint::new("time-instant", |inv: &Invocation<'_>| -> InvokeFuture<'_> {
        Box::pin(async move {
            let minute = this_minute(inv).await?;
            Ok(
                Representation::new(text_plain_utf8(), instant_text(minute).into_bytes())
                    .cacheable(),
            )
        })
    })
    .with_description(
        Description::new("time-instant")
            .title("Instant")
            .summary(
                "The current minute as one instant, YYYY-MM-DDTHH:MMZ, UTC: the date and the \
                 time read so that they agree. Cacheable until the next minute.",
            )
            .verb(Verb::Source)
            .verb(Verb::Meta)
            .output(TEXT_PLAIN_UTF8),
    )
}

The spreadsheet’s NOW() reads it, in Spreadsheet, IV.

A page that polls

Here is the clock. It is paused until you start it, so the cells above answer as they say; press Poll and it asks the kernel for the time once a second, and counts what that cost.

The clock appears here when the in-page kernel has loaded.

Watch the count. The page asks sixty times a minute, and the kernel computes the time once: the first request after each minute boundary. The other fifty-nine are answered from the cache, which is a lookup, not a computation. So a poll is cheap when the answer is cached, and the thing that decides when work happens is the answer’s expiry, not how often anybody asks. Poll every tenth of a second and the kernel still computes the time once a minute.

The same thing as a test, with a clock the test moves one second at a time:

/// The page's poll, as a test: the clock view asked once a second for a minute. The cache
/// answers 59 of the 60; the one computation is the first, and the next one waits for the
/// minute, however often anybody asks.
#[test]
fn a_poll_every_second_computes_once_a_minute() {
    let (kernel, clock) = host(millis_at("2026-09-29T14:05Z", 0));
    let mut computed = 0;
    for _ in 0..60 {
        if !cached(&kernel, VIEW_CLOCK) {
            computed += 1;
        }
        source(&kernel, VIEW_CLOCK).unwrap();
        clock.advance(1000);
    }
    assert_eq!(computed, 1);
    assert!(
        !cached(&kernel, VIEW_CLOCK),
        "14:06:00, and the minute has turned"
    );
    assert_eq!(
        source(&kernel, VIEW_CLOCK).unwrap(),
        "<time datetime=\"14:06\">14:06</time>"
    );
}

The markup

The clock’s markup is two templates, served as resources like tic-tac-toe’s. The one the page loads is the poller:

<p class="time-clock" hx-get="iki/tutorial/time/view/clock" hx-trigger="load, every 1s" hx-target="find .time-clock-now">It is <span class="time-clock-now">--:--</span> UTC.</p>

It asks for iki/tutorial/time/view/clock on load and every second after, by the same path rule as the board in Tic-tac-toe, V, and puts the answer into the <span> inside it: hx-target names a child, not the element itself. The web demo’s first poller targeted itself, and its host processed the swap target again after every answer, which broke the poll cycle. A child is never the element htmx polls from, so whatever a host does with the target, the poller is left alone and only what it shows changes.

The view is the other template, and it has no view code of its own to speak of:

<time datetime="$h{urn:iki:tutorial:time:now}">$h{urn:iki:tutorial:time:now}</time>

A $h{…} marker names a resource, and the generic composer from ikigai-fn resolves every name it finds and splices the answer in, escaped for HTML. That is the template language Tic-tac-toe, V teaches, and the clock uses the smallest part of it. The template names what it shows, so nothing fills it from code, and the view is the template bound at a name of its own with compose_over, the way the game’s board and squares are:

/// `urn:iki:tutorial:time:view:clock`: the `clock` template, bound at a name of its own.
///
/// The template names the resource it shows (`$h{urn:iki:tutorial:time:now}`), and compose
/// resolves every name it finds and splices the answer in. `compose_over` is compose with
/// the template fixed, bound at the view's name, so the view has no code of its own.
pub fn view_clock() -> ComposeOver {
    ikigai_fn::compose_over(
        Iri::parse(template_name("clock")).expect("a template's name is a name"),
    )
}
<time datetime="14:07">14:07</time>
[computed]
cached

Compose knows nothing about time. It declares its answer cacheable and lets the kernel meet that with what it read, so the view expires with now, like the countdown.

There is no view code: compose_over is the composer with its template fixed, so binding it at view:clock makes the view a resource like any other, with nothing of its own to run. (Before ikigai-fn 0.3.0 the view was one request of glue, because a name could not carry the argument that tells compose which template to fill.)

$h escapes what it splices. Here that changes nothing, since the only thing spliced is the kernel’s own clock, but it is the marker to reach for by default: a value that is text somebody else chose, a tic-tac-toe mark or a spreadsheet cell, must never be spliced in as markup.

What was built

the setthe namewhat it is
now, todayurn:iki:tutorial:time:now, …:time:todaycode: small functions of the kernel’s clock, each with a deadline
the instanturn:iki:tutorial:time:instantcode: the guarded reading of today and now, with now’s deadline inherited
an eventurn:iki:tutorial:time:event:{name}code: the one atom
a countdownurn:iki:tutorial:time:until:{name}code: a small function of three names, with its deadline inherited
templatesurn:iki:tutorial:time:template:{name}resources: HTML, one of which names what it shows
the clock viewurn:iki:tutorial:time:view:clocka template bound at a name: no code

Nothing here polls on the server side, nothing sets a timer, and nothing invalidates a cache. The page asks as often as it likes; each answer says how long it is good for; the kernel does the arithmetic. What is left for later is the time zone, which is context rather than a name, and making things happen at a time rather than answering when asked.

Spreadsheet, I: cells are atoms

Tic-tac-toe built an application out of names, and every answer in it changed for one reason: somebody played a move. This arc builds something a little bigger, a spreadsheet, and it brings two things tic-tac-toe did not have. A cell can hold code: a formula, which has to be compiled before it can run. And one edit ripples: change one number and every total that reads it, and every total that reads those, is wrong until it is computed again. Spreadsheet engines are built around that second problem. This arc’s claim is that the kernel already solves it, and that an application built as resources never writes a recalculation engine at all.

It comes in parts, as tic-tac-toe did. This one draws the model on paper and builds the atoms: the cells as a person types into them, the values of those that hold no formula, and the grid that shows them. Part II makes a formula a resource, compiled and cached like any other answer. Part III edits one cell and watches exactly the cells that depend on it compute again.

The model, on paper

As before, the names come first. A cell is named by its column and row, A1 to D6: a small sheet, four columns by six rows, which is enough to show everything and small enough to see all of it at once.

the setits namewhat it isbuilt in
an inputurn:iki:tutorial:sheet:input:{ref}what a person typed: a number, some text, or a formula starting with =this part
a valueurn:iki:tutorial:sheet:cell:{ref}what the cell shows: a literal as typed, or its formula’s resultthis part (literals), part II (formulas)
the gridurn:iki:tutorial:sheet:view:gridevery value, as an HTML table, from a template that is a resourcethis part
the compilerurn:iki:tutorial:sheet:compilea transreptor from formula text to an s-expressionpart II
a compiled formulaurn:iki:tutorial:sheet:formula:{ref}the cell’s formula, compiled: code, as a resourcepart II
a cell’s referencesurn:iki:tutorial:sheet:refs:{ref}the cells its formula reads, by namepart II
a rangeurn:iki:tutorial:sheet:range:{from}:{to}the cells in a rectangle, by name, for SUMpart II
a cell’s precedentsurn:iki:tutorial:sheet:precedents:{ref}every cell its value depends on, however far backpart II
what a redraw costsurn:iki:tutorial:sheet:view:costwhich values drawing the grid again will compute, and which the cache will servepart III

Only the input holds state. Everything below it in the table is a question about inputs: what does this one compile to, which cells does it read, what is its value. That is the same bet tic-tac-toe made, with one difference that matters later. There, the questions were about marks, and a mark is data. Here, some inputs are programs.

/// What was typed into a cell: the atom.
pub const INPUT: &str = "urn:iki:tutorial:sheet:input:{ref}";

/// The compiler: formula text in, an s-expression out.
pub const COMPILE: &str = "urn:iki:tutorial:sheet:compile";

/// A cell's formula, compiled.
pub const FORMULA: &str = "urn:iki:tutorial:sheet:formula:{ref}";

/// The cells a cell's formula reads.
pub const REFS: &str = "urn:iki:tutorial:sheet:refs:{ref}";

/// Every cell a cell's value depends on, however far back.
pub const PRECEDENTS: &str = "urn:iki:tutorial:sheet:precedents:{ref}";

/// The cells in a rectangle.
pub const RANGE: &str = "urn:iki:tutorial:sheet:range:{from}:{to}";

/// A cell's value.
pub const CELL: &str = "urn:iki:tutorial:sheet:cell:{ref}";

/// A piece of the sheet's HTML.
pub const TEMPLATE: &str = "urn:iki:tutorial:sheet:template:{name}";

/// A cell's value, as HTML.
pub const VIEW_CELL: &str = "urn:iki:tutorial:sheet:view:cell:{ref}";

/// The grid, as HTML.
pub const VIEW_GRID: &str = "urn:iki:tutorial:sheet:view:grid";

/// The formula bar's write: set or clear one cell's input.
pub const VIEW_EDIT: &str = "urn:iki:tutorial:sheet:view:edit";

There is no name for “the dependents of a cell”, the list a spreadsheet engine keeps so it knows what to recompute after an edit. That is not an oversight. It is the subject of Part III.

A cell has one name

A cell is written A1: a capital letter for the column, then the row, with no leading zero. Only that spelling names a cell, and only the cells on the sheet have names:

/// The cell a name's `{ref}` (or other binding) names, on the sheet, in its one spelling.
fn cell_binding(inv: &Invocation<'_>, binding: &str) -> Result<CellRef> {
    let text = inv
        .bindings
        .get(binding)
        .ok_or_else(|| Error::MissingArgument(binding.to_string()))?;
    on_sheet(binding, text)
}

/// `text` as a cell of the sheet, or a refusal naming `argument` and saying what to fix.
fn on_sheet(argument: &str, text: &str) -> Result<CellRef> {
    let refuse = |detail: String| Error::InvalidArgument {
        name: argument.to_string(),
        detail,
    };
    let cell = CellRef::parse(text).ok_or_else(|| {
        refuse(format!(
            "`{text}` is not a cell: a capital letter for the column, then the row (e.g. B3)"
        ))
    })?;
    if !cell.on_sheet() {
        return Err(refuse(format!(
            "{cell} is off the sheet, which is columns {}–{} and rows 1–{}",
            &formula::COLUMNS[..1],
            &formula::COLUMNS[formula::COLUMNS.len() - 1..],
            formula::ROWS
        )));
    }
    Ok(cell)
}

The strictness is the one tic-tac-toe had, for the same reason. The kernel caches an answer under the name it was asked by, and cuts a golden thread by name. If a1 and A1 both named the top-left cell, a write to one would leave a cached answer under the other, still wrong, with nothing to cut it. So there is one spelling, and the rest are refused with a word on what to fix:

error: invalid argument `ref`: `a1` is not a cell: a capital letter for the column, then the row (e.g. B3)
       `ref`: the cell, A1 to D6
error: invalid argument `ref`: E1 is off the sheet, which is columns A–D and rows 1–6
       `ref`: the cell, A1 to D6

The page’s formula bar, further down, is more forgiving: it is the edge where a person types, so it takes a1 and writes to A1. The names themselves are not.

The input: the atom

What was typed into each cell is kept in memory, in a map the host holds. (It keeps every write, each stamped with the time; until Part VI only the latest one matters.)

/// What has been typed into each cell, and when: every write, oldest first. The sheet's one
/// piece of state. A write is stamped with the kernel's clock (0 on a kernel with none), and a
/// clear is a write of nothing.
#[derive(Debug, Default)]
pub struct InputStore {
    inputs: Mutex<BTreeMap<CellRef, Vec<Write>>>,
}

/// One write to a cell: when, in milliseconds since 1970, and what was typed (`None` clears).
#[derive(Clone, Debug)]
struct Write {
    at: u64,
    typed: Option<String>,
}

The endpoint over it takes three verbs. Sink stores what was typed, trimmed, and answers what it stored; Source reads it back; Delete clears it. A cell nobody has typed into is NotFound, the way an unplayed square is:

/// `sheet-input`: what was typed into the cell `ref`, held in memory.
///
/// `Source` answers the text as typed, `.cacheable()` with no thread named: the kernel
/// hangs it from its own name, and cuts that after every `Sink` or `Delete` here. The media
/// type says what the text is. A formula, which starts with `=`, is `text/x-formula`;
/// anything else is `text/plain`. A cell nobody has typed into is `NotFound`.
///
/// `Sink` stores `content`, trimmed, and answers what it stored; an empty input is refused,
/// since "nothing" is what `Delete` says. Every write is kept, stamped with the kernel's
/// clock, so the sheet can be read as it stood at any instant (part VI).
pub fn input(store: Arc<InputStore>) -> FnEndpoint {
    FnEndpoint::new("sheet-input", move |inv: &Invocation<'_>| {
        let at = cell_binding(inv, "ref")?;
        match inv.request.verb {
            Verb::Sink => {
                let typed = typed_content(inv)?;
                store.write(at, stamp(inv), Some(typed.to_string()))?;
                Ok(Representation::new(
                    text_plain_utf8(),
                    typed.as_bytes().to_vec(),
                ))
            }
            Verb::Delete => {
                store.write(at, stamp(inv), None)?;
                Ok(Representation::new(text_plain_utf8(), b"ok".to_vec()))
            }
            _ => match store.latest(at) {
                Some(typed) => Ok(typed_repr(typed).cacheable()),
                None => Err(Error::NotFound(format!("nothing has been typed into {at}"))),
            },
        }
    })
    .with_description(
        Description::new("sheet-input")
            .title("Input")
            .summary("What was typed into a cell: read it, type into it, or clear it.")
            .verb(Verb::Meta)
            .action(
                ActionSpec::new(Verb::Source)
                    .summary("the input as typed; text/x-formula for a formula, NotFound if empty")
                    .input(ref_input("the cell, A1 to D6"))
                    .output(TEXT_PLAIN_UTF8)
                    .output(FORMULA_TYPE),
            )
            .action(
                ActionSpec::new(Verb::Sink)
                    .summary("type into the cell, and answer what was stored")
                    .input(ref_input("the cell, A1 to D6"))
                    .input(
                        ArgSpec::new("content")
                            .summary("a number, some text, or a formula starting with =")
                            .class(XSD_STRING),
                    )
                    .output(TEXT_PLAIN_UTF8),
            )
            .action(
                ActionSpec::new(Verb::Delete)
                    .summary("clear the cell")
                    .input(ref_input("the cell, A1 to D6"))
                    .output(TEXT_PLAIN_UTF8),
            ),
    )
}

One detail is new. The answer’s media type says what kind of input it is: a formula, anything starting with =, is served as text/x-formula, and everything else as text/plain. Nothing in this part reads that, but Part II’s compiler is found by it.

Type into a cell, and read it back:

42
[uncacheable]
42
[computed]
42
[cached]
error: not found: nothing has been typed into B1

The value, and the empty cell

The input is what a person typed. The value is what the cell shows, and it is the name everything else reads, just as tic-tac-toe’s platonic cell was. For a cell holding a number or some text, the value is the input, passed through. For a cell nobody has typed into, the value is empty: every cell of the sheet has a value before anything is typed, so nothing has to create a sheet before reading one.

The value endpoint is one function, and most of it belongs to Part II, which gives the formula branch its meaning. The first two arms are this part’s:

/// `sheet-cell`: the cell's value.
///
/// A cell nobody has typed into is empty. A literal (a number, some text) passes through as
/// typed. A formula is evaluated: first the cell's precedents are read, and a cell among its
/// own is `#CYCLE`; otherwise the compiled form is read back and evaluated over the values
/// of the cells it names.
///
/// Every answer is `.cacheable()` and names no thread. It hangs from what it read: the
/// input, the compiled formula, the precedents, and the values of the cells the formula
/// named. So typing into a cell cuts that cell's value, and the values that read it, and the
/// values that read those, and nothing else.
pub fn cell() -> AsyncFnEndpoint {
    AsyncFnEndpoint::new("sheet-cell", |inv: &Invocation<'_>| -> InvokeFuture<'_> {
        Box::pin(async move {
            let at = cell_binding(inv, "ref")?;
            let typed = typed_into(inv, at).await?;
            let value = match &typed {
                None => String::new(),
                Some(literal) if literal.repr_type.media_type != FORMULA_TYPE => {
                    String::from_utf8_lossy(&literal.bytes).into_owned()
                }
                Some(_) => {
                    let precedents = text_of(inv, &precedents_name(at)).await?;
                    if lines_of(&precedents).any(|name| cell_of(name) == Some(at)) {
                        CYCLE_ERROR.to_string()
                    } else {
                        let expr = compiled(inv, at, typed.as_ref()).await?.ok_or_else(|| {
                            Error::Endpoint(format!("{at} held a formula and then did not"))
                        })?;
                        eval(inv, &expr).await?.text()
                    }
                }
            };
            Ok(Representation::new(text_plain_utf8(), value.into_bytes()).cacheable())
        })
    })
    .with_description(
        Description::new("sheet-cell")
            .title("Cell")
            .summary(
                "A cell's value: a literal as typed, or its formula evaluated. Errors are \
                 values: #REF, #DIV/0, #CYCLE, #VALUE, #SYNTAX, #N/A.",
            )
            .verb(Verb::Source)
            .verb(Verb::Meta)
            .input(ref_input("the cell, A1 to D6"))
            .output(TEXT_PLAIN_UTF8),
    )
}

Type text into a cell, and ask for its value twice:

Rent
[uncacheable]
Rent
[computed]
Rent
[cached]

Now ask for a cell nobody has touched:

[cached]

No text, since the value is empty, and [cached] the first time you asked. Nothing on this page had asked for C5, as far as the cells go. The grid at the bottom of the page had: it shows every value on the sheet, and it asked for all twenty-four when the page loaded. The grid and the cells are one kernel with one cache, so the grid’s answer is the cell’s.

A write cuts what was built on it

The value of C5 is cached, and it is empty. What happens when somebody types into C5? Nothing in the value endpoint names a golden thread, and nothing in the input endpoint cuts one. Ask:

cached
hello
[uncacheable]
not cached
hello
[computed]

The kernel did both halves. When the value was computed, it read the input through the kernel, and the kernel recorded that read as a dependency, a failed one: the input was NotFound. A failed read counts, because an answer built on a resource’s absence is as wrong as one built on its content once it appears. So the empty value hung from the thread named after the input, and the Sink to the input cut that thread.

That is the same thing tic-tac-toe’s empty square did, and it is the rule the whole arc rests on: a value depends on exactly what it read, and the kernel keeps the list.

Delete is a write too, so clearing a cell brings the empty value back, computed again:

ok
[uncacheable]
[computed]

The grid

The grid is HTML, and it is resources in the way tic-tac-toe’s board was: templates, and views that fill them. The grid’s template is a table with a marker in each cell:

<table class="sheet-grid">
<caption>The sheet: the value of each cell</caption>
<thead><tr><td></td><th scope="col">A</th><th scope="col">B</th><th scope="col">C</th><th scope="col">D</th></tr></thead>
<tbody>
<tr><th scope="row">1</th><td>$r{urn:iki:tutorial:sheet:view:cell:A1}</td><td>$r{urn:iki:tutorial:sheet:view:cell:B1}</td><td>$r{urn:iki:tutorial:sheet:view:cell:C1}</td><td>$r{urn:iki:tutorial:sheet:view:cell:D1}</td></tr>
<tr><th scope="row">2</th><td>$r{urn:iki:tutorial:sheet:view:cell:A2}</td><td>$r{urn:iki:tutorial:sheet:view:cell:B2}</td><td>$r{urn:iki:tutorial:sheet:view:cell:C2}</td><td>$r{urn:iki:tutorial:sheet:view:cell:D2}</td></tr>
…

Each marker names one cell’s view, view:cell:{ref}, and that view is a template too, of one marker:

$h{urn:iki:tutorial:sheet:cell:{ref}}

The markers are the language of urn:iki:fn:compose, the generic composer from ikigai-fn. A marker names a resource; compose resolves it and splices the answer in, and there are three ways to splice. $h{…} splices the answer as text, escaped for HTML. $r{…} splices it as it is, as trusted markup: here, a view the kernel has already composed. $a{…} splices it as it is and then expands any markers in it, which is how one template includes another. The time chapter’s clock is one marker of the first kind, and Tic-tac-toe, V teaches the whole language.

A view is a template bound at a name. compose_over makes the binding, and the variables the name captures are the template’s arguments, so view:cell:B2 is the cell template with {ref} = B2:

/// A view: the template `name`, filled by `urn:iki:fn:compose`'s rules and bound at a name
/// of its own. The variables the name captures (`{ref}`) are the template's arguments.
/// There is no code here that reads a cell: the template names what it shows.
pub fn view(name: &str) -> ComposeOver {
    ikigai_fn::compose_over(iri(&template_name(name)).expect("a template's name is a name"))
}
        .bind(template(VIEW_CELL), view("cell"))
        .bind(template(VIEW_GRID), view("grid"))
        .bind(template(VIEW_CACHED), view("cached"))
        .bind(template(VIEW_COST_CELL), view("cost-cell"))
        .bind(template(VIEW_COST), view("cost"))

That is all the code the grid has. (The last three bindings are the cost table Part III adds.) The template says which cells there are and where they go, and each cell’s view says how a value is shown, so there is no four and no six in any code, and no code that reads a cell.

How a value is shown is the part that matters, because a value is text a person typed. $h escapes what it splices, so a cell holding <b> shows <b> rather than turning the rest of the row bold. And $h never expands what it splices, so a cell holding a marker shows the marker as typed. If it did, anybody who could type into a cell could make the page resolve any name they liked, and show the answer: $a{urn:kernel:cache} in a cell would print the kernel’s cache into the grid. Only $a expands what it splices, and the sheet never uses it: every marker in its templates is $h or $r.

<b>not bold</b> $a{urn:kernel:cache}
[uncacheable]
&lt;b&gt;not bold&lt;/b&gt; $a{urn:kernel:cache}
[computed]

A view reads a value and nothing else, so it is cached with the value and cut with it, like any other composite.

A sheet you can type into

Here is the sheet. The grid is the view above; the formula bar under it names a cell and what to type into it, and Enter writes it. Empty input clears the cell.

The sheet appears here when the in-page kernel has loaded.

The formula bar is a form, and the form’s write is one more resource, view:edit. It takes the cell and the input as named arguments, the way a server would read a form body, and it writes the input with a Sink through the kernel, or clears it with a Delete:

/// `sheet-view-edit`: the formula bar's write. `Sink` only.
///
/// `ref` is the cell as a person typed it (`b3` will do: this is the human edge, so it is
/// forgiving here and nowhere else); `content` is what to type into it. Empty content
/// clears the cell. The write is a `Sink` (or a `Delete`) to the cell's input, through the
/// kernel, and the answer is one line of HTML text saying what happened, a refusal included,
/// in the kernel's own words.
pub fn view_edit() -> AsyncFnEndpoint {
    AsyncFnEndpoint::new(
        "sheet-view-edit",
        |inv: &Invocation<'_>| -> InvokeFuture<'_> {
            Box::pin(async move {
                if inv.request.verb != Verb::Sink {
                    return Err(Error::Endpoint(
                        "an edit is made, not read: `sink` it (read the grid view)".to_string(),
                    ));
                }
                let typed_ref = inv.inline_str("ref")?.trim().to_ascii_uppercase();
                let content = inv
                    .inline_str("content")
                    .unwrap_or_default()
                    .trim()
                    .to_string();
                let said = match on_sheet("ref", &typed_ref) {
                    Err(refused) => refused.to_string(),
                    Ok(at) => {
                        let target = iri(&input_name(at))?;
                        let outcome = if content.is_empty() {
                            inv.issue(Request::new(Verb::Delete, target)).await
                        } else {
                            let request = Request::new(Verb::Sink, target)
                                .with_arg("content", ArgRef::Inline(content.clone().into_bytes()));
                            inv.issue(request).await
                        };
                        match outcome {
                            Ok(_) if content.is_empty() => format!("{at} is clear."),
                            Ok(_) => format!("{at} is now {content}."),
                            Err(refused) => refused.to_string(),
                        }
                    }
                };
                Ok(Representation::new(
                    text_html_utf8(),
                    escape(&said).into_bytes(),
                ))
            })
        },
    )
    .with_description(
        Description::new("sheet-view-edit")
            .title("Edit, as a view")
            .summary(
                "Type into a cell (or clear it) and answer, as HTML text, what happened — a \
                 refusal included.",
            )
            .verb(Verb::Sink)
            .verb(Verb::Meta)
            .input(
                ArgSpec::new("ref")
                    .summary("the cell, e.g. B3 (any case)")
                    .class(XSD_STRING),
            )
            .input(
                ArgSpec::new("content")
                    .summary("what to type into it; empty clears it")
                    .class(XSD_STRING)
                    .optional(),
            )
            .output(TEXT_HTML_UTF8),
    )
}

The page’s markup is one more template, template:page. It asks for the grid when it loads, and posts the form to iki/tutorial/sheet/view/edit, by the same rule as tic-tac-toe’s board: a relative path’s segments, joined by :, after urn:. After an edit, and after any cell on this page runs, the page draws the grid again, because a grid on the screen is HTML the kernel handed out earlier and nothing cuts that. Drawing it again is cheap: every value the edit did not touch is served from the cache.

Type a number into A3, and then a formula into A4: =A3*2. It works, and why it works is the next part.

What was built

the setthe namewhat it is
an inputurn:iki:tutorial:sheet:input:{ref}code: the one atom
a valueurn:iki:tutorial:sheet:cell:{ref}code: a small function of the input (and, in Part II, of other values)
a value, as HTMLurn:iki:tutorial:sheet:view:cell:{ref}a template bound at a name: no code
the gridurn:iki:tutorial:sheet:view:grida template bound at a name: no code
the formula bar’s writeurn:iki:tutorial:sheet:view:editcode: the human edge, a write through the kernel

No part of it caches anything or invalidates anything, and the empty cell went stale and came back without being told. The input is the only thing that holds state. The next part puts code in it.

Spreadsheet, II: a formula is code as a resource

Part I stored what a person typed and passed it through as the cell’s value. That is enough for 42 and for Rent. It is not enough for =A1*2, which is not a value but a program: it has to be parsed, checked and run, and what it produces depends on other cells.

The usual way to handle that is to parse the formula when it is typed and keep the parse tree in the cell, beside the text. This part does something else. The compiled form of a formula is a resource of its own, with a name, and compiling is a transreption: the kernel finds the compiler by what it converts, runs it, and caches what it produces like any other answer. NetKernel treated code this way (its Java and Groovy were compiled by transreptors, and the compiled classes were cached representations), and the reason it matters is the same here: when a formula changes, exactly that formula is compiled again, and the kernel works out which one without being told.

The formula language

The sheet’s formulas are small on purpose: numbers, cells, + - * /, parentheses, a minus sign in front of anything, and SUM over any mix of cells, numbers and ranges (A1:B3). That is enough to show a formula reading other cells, a range reading a rectangle of them, and every kind of error a formula can make. (Later parts add three functions, NOW() and TODAY() in Part IV and FEED(name) in Part V; the listings below already show them.)

A formula compiles to an expression:

/// A compiled formula.
#[derive(Clone, Debug, PartialEq)]
pub enum Expr {
    /// A number, `2.5`.
    Number(f64),
    /// The value of a cell, `A1`.
    Cell(CellRef),
    /// Every cell in a rectangle, top-left to bottom-right, `(range A1 B2)`. Only ever an
    /// argument of `sum`.
    Range(CellRef, CellRef),
    /// Minus one thing, `(- A1)`.
    Negate(Box<Expr>),
    /// Two things and an operator, `(* B1 2)`.
    Binary(Op, Box<Expr>, Box<Expr>),
    /// The sum of its arguments, `(sum (range A1 A3) 10)`.
    Sum(Vec<Expr>),
    /// The current instant, `(now)`: the date and the time, read from the time chapter's
    /// `instant`.
    Now,
    /// Today's date, `(today)`.
    Today,
    /// The latest value written to a feed, `(feed acme)`: data that something other than the
    /// sheet writes.
    Feed(String),
    /// A formula that does not compile, and why: `(syntax-error "…")`. Its value is
    /// `#SYNTAX`. The compiler answers with this rather than refusing, so a formula with a
    /// mistake in it is still a representation the kernel can cache.
    SyntaxError(String),
}

/// An arithmetic operator.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Op {
    /// `+`
    Add,
    /// `-`
    Subtract,
    /// `*`
    Multiply,
    /// `/`
    Divide,
}

and the expression is written as an s-expression, in the one spelling this crate reads back:

/// The expression as an s-expression, in the one spelling the reader takes back.
///
/// ```
/// use spreadsheet::formula::compile;
/// assert_eq!(compile("=A1 + b2*2").to_string(), "(+ A1 (* B2 2))");
/// assert_eq!(compile("=SUM(A3:A1, 10)").to_string(), "(sum (range A1 A3) 10)");
/// assert_eq!(compile("=now()").to_string(), "(now)");
/// assert_eq!(compile("=FEED(Acme) * B1").to_string(), "(* (feed acme) B1)");
/// ```
impl fmt::Display for Expr {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Expr::Number(n) => f.write_str(&number_text(*n)),
            Expr::Cell(r) => write!(f, "{r}"),
            Expr::Range(a, b) => write!(f, "(range {a} {b})"),
            Expr::Negate(inner) => write!(f, "(- {inner})"),
            Expr::Binary(op, left, right) => write!(f, "({} {left} {right})", op.symbol()),
            Expr::Sum(args) => {
                f.write_str("(sum")?;
                for arg in args {
                    write!(f, " {arg}")?;
                }
                f.write_str(")")
            }
            Expr::Now => f.write_str("(now)"),
            Expr::Today => f.write_str("(today)"),
            Expr::Feed(name) => write!(f, "(feed {name})"),
            Expr::SyntaxError(why) => {
                let quoted = why.replace('\\', "\\\\").replace('"', "\\\"");
                write!(f, "(syntax-error \"{quoted}\")")
            }
        }
    }
}

S-expressions are the ecosystem’s way of writing code down as data (ikigai-sexpr compiles them to SPARQL), and they suit a compiled formula because they make the structure visible: the precedence of * over +, which the text only implies, is the nesting of the lists. The sheet carries its own small reader rather than ikigai-sexpr’s, because a formula needs decimals and that datum has only integers.

The compiler itself is a short recursive-descent parser, and its one entry point never fails:

/// Compile a formula, `=A1*2`, into an expression.
///
/// This never fails. A formula that does not parse compiles to [`Expr::SyntaxError`],
/// saying what was expected where, so a mistake is a value the sheet shows (`#SYNTAX`)
/// rather than a request that failed. The compiler accepts a formula however it is
/// spelled (`=a1 + 2`, `=SUM(a3:a1)`) and writes the one spelling back (`(+ A1 2)`,
/// `(sum (range A1 A3))`).
pub fn compile(formula: &str) -> Expr {
    let mut parser = Parser {
        text: formula.as_bytes(),
        at: 0,
        depth: 0,
    };
    match parser.formula() {
        Ok(expr) => expr,
        Err(why) => Expr::SyntaxError(why),
    }
}

The section on errors says why it does not.

The compiler is a transreptor

Wrapped as an endpoint, the compiler takes formula text as content and answers the s-expression. What makes it a transreptor is its description:

/// `sheet-compile`: a transreptor from formula text to its compiled form, an s-expression.
///
/// A pure function of `content`, so `.cacheable()`: the kernel keeps one entry per formula
/// text, and a formula typed into two cells is compiled once. It never refuses a formula:
/// one that does not parse compiles to `(syntax-error "…")`, and its value is `#SYNTAX`.
///
/// The description is what makes it the compiler. `.transreptor(from, to)` is what the
/// kernel selects on, so `formula:{ref}` finds it by what it converts, never by its name.
/// And `.lossy()` is true: compiling forgets how the formula was spelled (`=a1 +2` and
/// `=A1+2` compile alike), so the formula cannot be recovered from what comes out.
pub fn compile() -> FnEndpoint {
    FnEndpoint::new("sheet-compile", |inv: &Invocation<'_>| {
        let compiled = formula::compile(inv.inline_str("content")?);
        Ok(
            Representation::new(ReprType::new(SEXPR_TYPE), compiled.to_string().into_bytes())
                .cacheable(),
        )
    })
    .with_description(
        Description::new("sheet-compile")
            .title("Compile a formula")
            .summary("Compiles a formula (=A1*2) to an s-expression ((* A1 2)).")
            .verb(Verb::Source)
            .verb(Verb::Meta)
            .input(
                ArgSpec::new("content")
                    .summary("the formula, starting with =")
                    .class(XSD_STRING),
            )
            .input(
                ArgSpec::new("as")
                    .summary("the target type; only text/x-sexpr")
                    .class(XSD_STRING)
                    .optional(),
            )
            .output(SEXPR_TYPE)
            .transreptor([FORMULA_TYPE], [SEXPR_TYPE])
            .lossy(),
    )
}

.transreptor([FORMULA_TYPE], [SEXPR_TYPE]) says it converts text/x-formula into text/x-sexpr, and that declaration is what the kernel selects on, as in Transreption. describe shows it, as the description’s Turtle; the lines to look for are ik:transreptsFrom, ik:transreptsTo and ik:lossless:

@prefix ik: <https://ikigai-rs.dev/ns#> .
 
<urn:ikigai:endpoint:sheet-compile> a ik:Endpoint, ik:Transreptor ;
    ik:id "sheet-compile" ;
    ik:title "Compile a formula" ;
    ik:summary "Compiles a formula (=A1*2) to an s-expression ((* A1 2))." ;
    ik:verb "Source", "Meta" ;
    ik:output "text/x-sexpr" ;
    ik:transreptsFrom "text/x-formula" ;
    ik:transreptsTo "text/x-sexpr" ;
    ik:lossless false ;
    ik:input <urn:ikigai:endpoint:sheet-compile:input:content> ;
    ik:input <urn:ikigai:endpoint:sheet-compile:input:as> ;
    ik:action <urn:ikigai:contract:sheet-compile:source:b3:…> .
 
<urn:ikigai:endpoint:sheet-compile:input:content> ik:inputName "content" ;
    ik:source "argument" ;
    ik:required true ;
    ik:summary "the formula, starting with =" ;
    ik:class <http://www.w3.org/2001/XMLSchema#string> .
 
<urn:ikigai:endpoint:sheet-compile:input:as> ik:inputName "as" ;
    ik:source "argument" ;
    ik:required false ;
    ik:summary "the target type; only text/x-sexpr" ;
    ik:class <http://www.w3.org/2001/XMLSchema#string> .
 
<urn:ikigai:contract:sheet-compile:source:b3:…:input:content> ik:inputName "content" ;
    ik:source "argument" ;
    ik:required true ;
    ik:summary "the formula, starting with =" ;
    ik:class <http://www.w3.org/2001/XMLSchema#string> .
 
<urn:ikigai:contract:sheet-compile:source:b3:…:input:as> ik:inputName "as" ;
    ik:source "argument" ;
    ik:required false ;
    ik:summary "the target type; only text/x-sexpr" ;
    ik:class <http://www.w3.org/2001/XMLSchema#string> .
 
<urn:ikigai:contract:sheet-compile:source:b3:…> a ik:Action ;
    ik:verb "Source" ;
    ik:output "text/x-sexpr" ;
    ik:input <urn:ikigai:contract:sheet-compile:source:b3:…:input:content> ;
    ik:input <urn:ikigai:contract:sheet-compile:source:b3:…:input:as> .
[computed]

.lossy() is the honest half. A transreption proper changes form without changing what is represented, so the original could be recovered from what comes out. Compiling cannot promise that: =a1 +2 and =A1+2 compile to the same (+ A1 2), and nothing in the output says which was typed. So the compiler declares itself a projection, and the kernel will not route through it unless the caller consents.

The compiled formula is a resource

formula:{ref} is the cell’s formula, compiled. It reads the cell’s input, asks the kernel for a plan from the input’s type to text/x-sexpr, and runs the plan:

/// `sheet-formula`: the cell's formula, compiled.
///
/// It reads the input, and asks the kernel for a plan from what the input is
/// (`text/x-formula`) to what this answers (`text/x-sexpr`), then runs the plan through the
/// kernel. The compiler is whatever transreptor the plan names; this code does not know it.
/// The consent to a lossy step is this endpoint's to give, because it is the one that knows
/// losing the spelling is what compiling means.
///
/// Cacheable, with no thread named: it read the input, so the kernel hangs it from the
/// input's thread, and typing into that cell, and only that cell, recompiles it. A cell
/// holding no formula is `NotFound`.
pub fn formula() -> AsyncFnEndpoint {
    AsyncFnEndpoint::new(
        "sheet-formula",
        |inv: &Invocation<'_>| -> InvokeFuture<'_> {
            Box::pin(async move {
                let at = cell_binding(inv, "ref")?;
                let typed = inv.source(&iri(&input_name(at))?).await?;
                if typed.repr_type.media_type != FORMULA_TYPE {
                    return Err(Error::NotFound(format!("{at} holds no formula")));
                }
                let plan = inv
                    .select_transreptor_with(
                        FORMULA_TYPE,
                        SEXPR_TYPE,
                        &TransreptionPolicy::allow_lossy(),
                    )
                    .ok_or_else(|| {
                        Error::Endpoint(format!("nothing converts {FORMULA_TYPE} to {SEXPR_TYPE}"))
                    })?;
                let mut compiled = typed;
                for step in plan {
                    let request = Request::new(Verb::Source, iri(&step.endpoint)?)
                        .with_arg("content", ArgRef::Inline(compiled.bytes))
                        .with_arg("as", ArgRef::Inline(step.to.into_bytes()));
                    compiled = inv.issue(request).await?;
                }
                Ok(Representation::new(compiled.repr_type, compiled.bytes).cacheable())
            })
        },
    )
    .with_description(
        Description::new("sheet-formula")
            .title("Compiled formula")
            .summary("The cell's formula, compiled to an s-expression; NotFound if it holds none.")
            .verb(Verb::Source)
            .verb(Verb::Meta)
            .input(ref_input("the cell, A1 to D6"))
            .output(SEXPR_TYPE),
    )
}

This is where Part I’s media type earns its place. The input says it is text/x-formula, the compiler says it converts text/x-formula, and the kernel joins the two. formula:{ref} never names the compiler, and a different one bound in its place would be found the same way. The consent to the lossy step is given here, by the one endpoint that knows losing the spelling is what compiling means.

Type a formula, and ask for its compiled form twice:

5
[uncacheable]
=a1 * 2
[uncacheable]
(* A1 2)
[computed]
(* A1 2)
[cached]

The second time, nothing was compiled. Now give the sheet a second formula, and read both values:

=A2+1
[uncacheable]
10
[cached]
11
[computed]

A2 was cached before this cell asked for it. The grid at the bottom of the page draws itself again after every cell runs, so it read A2’s value, and compiled A2’s formula, as soon as the last cell had typed it. A3 is new, so it was computed here.

Editing a formula compiles exactly that formula

Both formulas are compiled and cached now (the grid below read every value after that cell ran, and a value reads its formula). Three edits, three different things to change:

7
[uncacheable]
cached
=A1*3
[uncacheable]
not cached
cached

Changing A1, a cell the formula names, leaves the formula compiled: =A1*2 is the same program whatever A1 holds, and the compiled form never read A1. Changing A2’s own input cuts A2’s compiled form, and only that one: A3’s formula names A2 too, and is still cached, because =A2+1 did not change either.

Nothing here decides any of that. The compiled form read the input, and only the input, so it hangs from the input’s thread and nothing else. Which is the whole point of making the compiled form a resource: “recompile when the source changes” is not code anybody wrote, it is what caching a transreption means.

Code as data: which cells a formula reads

A compiled formula is data, and the next question is one a spreadsheet asks constantly: which cells does this formula read? The text could be scanned for things that look like cells, but the compiled form already knows, so refs:{ref} reads it:

/// `sheet-refs`: the cells the cell's formula reads, one name per line, each once.
///
/// Read from the compiled form, not the text: the cells it names and the cells of each
/// range, which `range:{from}:{to}` lists. A cell off the sheet is not listed (nothing is
/// read for it; its value is `#REF`). A cell holding no formula reads nothing: the empty list.
pub fn refs() -> AsyncFnEndpoint {
    AsyncFnEndpoint::new("sheet-refs", |inv: &Invocation<'_>| -> InvokeFuture<'_> {
        Box::pin(async move {
            let at = cell_binding(inv, "ref")?;
            let mut names: Vec<String> = Vec::new();
            let typed = typed_into(inv, at).await?;
            if let Some(expr) = compiled(inv, at, typed.as_ref()).await? {
                let (cells, ranges) = expr.names();
                for (from, to) in ranges
                    .into_iter()
                    .filter(|(a, b)| a.on_sheet() && b.on_sheet())
                {
                    for name in lines_of(&text_of(inv, &range_name(from, to)).await?) {
                        names.push(name.to_string());
                    }
                }
                names.extend(cells.into_iter().filter(CellRef::on_sheet).map(cell_name));
            }
            let mut once = Vec::new();
            for name in names {
                if !once.contains(&name) {
                    once.push(name);
                }
            }
            Ok(name_list(&once))
        })
    })
    .with_description(
        Description::new("sheet-refs")
            .title("References")
            .summary("The cells a cell's formula reads, one name per line.")
            .verb(Verb::Source)
            .verb(Verb::Meta)
            .input(ref_input("the cell, A1 to D6"))
            .output(TEXT_PLAIN_UTF8),
    )
}

A range is listed cell by cell, and the listing is a resource too, range:{from}:{to}, a pure function of its own name:

/// `sheet-range`: the cells from `from` (top-left) to `to` (bottom-right), row by row, one
/// name per line. A pure function of its name, so it is computed once.
///
/// One spelling per rectangle: `A1:B2`, never `B2:A1` or `A2:B1`, so a range has one name
/// and one cache entry. The compiler writes that spelling whatever order a formula used.
pub fn range() -> FnEndpoint {
    FnEndpoint::new("sheet-range", |inv: &Invocation<'_>| {
        let (from, to) = (cell_binding(inv, "from")?, cell_binding(inv, "to")?);
        if from.column > to.column || from.row > to.row {
            let (top_left, bottom_right) = (
                CellRef::new(from.column.min(to.column), from.row.min(to.row)),
                CellRef::new(from.column.max(to.column), from.row.max(to.row)),
            );
            return Err(Error::InvalidArgument {
                name: "from, to".to_string(),
                detail: format!("{from}:{to} is spelled top-left first: {top_left}:{bottom_right}"),
            });
        }
        let names: Vec<String> = (from.row..=to.row)
            .flat_map(|row| (from.column..=to.column).map(move |c| cell_name(CellRef::new(c, row))))
            .collect();
        Ok(name_list(&names))
    })
    .with_description(
        Description::new("sheet-range")
            .title("Range")
            .summary("The cells in a rectangle, row by row, one name per line.")
            .verb(Verb::Source)
            .verb(Verb::Meta)
            .input(
                ArgSpec::new("from")
                    .summary("the top-left cell")
                    .class(XSD_STRING)
                    .binding(),
            )
            .input(
                ArgSpec::new("to")
                    .summary("the bottom-right cell")
                    .class(XSD_STRING)
                    .binding(),
            )
            .output(TEXT_PLAIN_UTF8),
    )
}
=SUM(A1:A3) * 2
[uncacheable]
(* (sum (range A1 A3)) 2)
[computed]
urn:iki:tutorial:sheet:cell:A1
urn:iki:tutorial:sheet:cell:A2
urn:iki:tutorial:sheet:cell:A3
[computed]
error: invalid argument `from, to`: A3:A1 is spelled top-left first: A1:A3

A range has one spelling, top-left first, for the reason a cell does: one name, one cache entry, one thread. The compiler writes that spelling whatever order the formula used, so SUM(A3:A1) and SUM(A1:A3) read the same range.

Evaluating: a value reads the values it names

Part I showed the value endpoint and skipped its formula branch. Here it is again, and now the branch is the interesting part:

/// `sheet-cell`: the cell's value.
///
/// A cell nobody has typed into is empty. A literal (a number, some text) passes through as
/// typed. A formula is evaluated: first the cell's precedents are read, and a cell among its
/// own is `#CYCLE`; otherwise the compiled form is read back and evaluated over the values
/// of the cells it names.
///
/// Every answer is `.cacheable()` and names no thread. It hangs from what it read: the
/// input, the compiled formula, the precedents, and the values of the cells the formula
/// named. So typing into a cell cuts that cell's value, and the values that read it, and the
/// values that read those, and nothing else.
pub fn cell() -> AsyncFnEndpoint {
    AsyncFnEndpoint::new("sheet-cell", |inv: &Invocation<'_>| -> InvokeFuture<'_> {
        Box::pin(async move {
            let at = cell_binding(inv, "ref")?;
            let typed = typed_into(inv, at).await?;
            let value = match &typed {
                None => String::new(),
                Some(literal) if literal.repr_type.media_type != FORMULA_TYPE => {
                    String::from_utf8_lossy(&literal.bytes).into_owned()
                }
                Some(_) => {
                    let precedents = text_of(inv, &precedents_name(at)).await?;
                    if lines_of(&precedents).any(|name| cell_of(name) == Some(at)) {
                        CYCLE_ERROR.to_string()
                    } else {
                        let expr = compiled(inv, at, typed.as_ref()).await?.ok_or_else(|| {
                            Error::Endpoint(format!("{at} held a formula and then did not"))
                        })?;
                        eval(inv, &expr).await?.text()
                    }
                }
            };
            Ok(Representation::new(text_plain_utf8(), value.into_bytes()).cacheable())
        })
    })
    .with_description(
        Description::new("sheet-cell")
            .title("Cell")
            .summary(
                "A cell's value: a literal as typed, or its formula evaluated. Errors are \
                 values: #REF, #DIV/0, #CYCLE, #VALUE, #SYNTAX, #N/A.",
            )
            .verb(Verb::Source)
            .verb(Verb::Meta)
            .input(ref_input("the cell, A1 to D6"))
            .output(TEXT_PLAIN_UTF8),
    )
}

For a formula, the value reads the cell’s precedents (the next section but one), then the compiled formula, reads it back into an expression, and evaluates it:

/// Evaluate `expr`, reading every cell it names through the kernel.
///
/// Each read is a sub-request, so the value being computed depends on exactly the cells
/// this evaluation read, and on nothing else: the kernel records them as it goes. Nothing
/// here writes down a dependency. A sheet error is a [`Value::Error`], and the first one met
/// (left to right) is the answer; a failure of the kernel's own is an `Err` and passes on.
fn eval<'a>(inv: &'a Invocation<'_>, expr: &'a Expr) -> Evaluated<'a> {
    Box::pin(async move {
        let arithmetic = |result: std::result::Result<f64, &'static str>| match result {
            Ok(n) if n.is_finite() => Value::Number(n),
            Ok(_) => Value::Error(VALUE_ERROR),
            Err(e) => Value::Error(e),
        };
        Ok(match expr {
            Expr::Number(n) => Value::Number(*n),
            Expr::Cell(cell) => read_cell(inv, *cell).await?,
            Expr::Negate(inner) => arithmetic(eval(inv, inner).await?.number().map(|n| -n)),
            Expr::Binary(op, left, right) => {
                let left = match eval(inv, left).await?.number() {
                    Ok(n) => n,
                    Err(e) => return Ok(Value::Error(e)),
                };
                let right = match eval(inv, right).await?.number() {
                    Ok(n) => n,
                    Err(e) => return Ok(Value::Error(e)),
                };
                arithmetic(match op {
                    formula::Op::Add => Ok(left + right),
                    formula::Op::Subtract => Ok(left - right),
                    formula::Op::Multiply => Ok(left * right),
                    formula::Op::Divide if right == 0.0 => Err(DIV_ERROR),
                    formula::Op::Divide => Ok(left / right),
                })
            }
            Expr::Sum(args) => {
                let mut total = 0.0;
                for arg in args {
                    match sum_of(inv, arg).await? {
                        Ok(n) => total += n,
                        Err(e) => return Ok(Value::Error(e)),
                    }
                }
                arithmetic(Ok(total))
            }
            Expr::Now => read_time(inv, TIME_INSTANT).await?,
            Expr::Today => read_time(inv, TIME_TODAY).await?,
            Expr::Feed(name) => read_feed(inv, name).await?,
            // A range means something only as an argument of SUM.
            Expr::Range(..) => Value::Error(VALUE_ERROR),
            Expr::SyntaxError(_) => Value::Error(SYNTAX_ERROR),
        })
    })
}

/// The value of `cell`, read through the kernel; `#REF` for a cell off the sheet, which is
/// never asked for, since no name answers for it.
async fn read_cell(inv: &Invocation<'_>, cell: CellRef) -> Result<Value> {
    if !cell.on_sheet() {
        return Ok(Value::Error(REF_ERROR));
    }
    Ok(Value::of(&text_of(inv, &cell_name(cell)).await?))
}

/// `NOW()` or `TODAY()`: the text of the time chapter's `instant` or `today`, read through
/// the kernel, so the value that asked inherits its deadline. A kernel with no clock has no
/// time to give, and never will (its clock is fixed when it is built), so that is `#N/A`,
/// and nothing is asked.
async fn read_time(inv: &Invocation<'_>, name: &str) -> Result<Value> {
    if inv.now().is_none() {
        return Ok(Value::Error(NA_ERROR));
    }
    Ok(Value::Text(text_of(inv, name).await?))
}

/// `FEED(name)`: the latest value written to the feed, read through the kernel.
///
/// A feed nothing has written to is `#N/A`. That fallback is safe to cache: the value that
/// caught the `NotFound` hangs from the feed's golden thread, so the first write to the feed
/// cuts it. It catches the ATOM's own `NotFound`, which says exactly what is missing; before
/// core 0.1.82 that was also the only safe choice, since a composite's `NotFound` hung the
/// fallback from a thread nobody cuts.
async fn read_feed(inv: &Invocation<'_>, name: &str) -> Result<Value> {
    match inv.source(&iri(&feed_name(name))?).await {
        Ok(latest) => Ok(Value::of(&String::from_utf8_lossy(&latest.bytes))),
        Err(Error::NotFound(_)) => Ok(Value::Error(NA_ERROR)),
        Err(other) => Err(other),
    }
}

/// What one argument of SUM adds. A range, or a cell named on its own, adds its numbers
/// and skips its empty cells and its text, as spreadsheets do; anything else is arithmetic.
async fn sum_of(
    inv: &Invocation<'_>,
    arg: &Expr,
) -> Result<std::result::Result<f64, &'static str>> {
    let cells = match arg {
        Expr::Range(from, to) if from.on_sheet() && to.on_sheet() => {
            let listed = text_of(inv, &range_name(*from, *to)).await?;
            lines_of(&listed).filter_map(cell_of).collect()
        }
        Expr::Range(..) => return Ok(Err(REF_ERROR)),
        Expr::Cell(cell) => vec![*cell],
        other => return Ok(eval(inv, other).await?.number()),
    };
    let mut total = 0.0;
    for cell in cells {
        match read_cell(inv, cell).await? {
            Value::Number(n) => total += n,
            Value::Error(e) => return Ok(Err(e)),
            Value::Empty | Value::Text(_) => {}
        }
    }
    Ok(Ok(total))
}

Every cell the expression names is read through the kernel, as cell:{ref}. So a value is a composite over other values, the way tic-tac-toe’s line was a composite over cells, and its dependencies are exactly the cells the evaluation read, recorded by the kernel as it read them. An empty cell is 0 in arithmetic; SUM skips text and empty cells, as spreadsheets do.

Errors are values

A formula can go wrong five ways, and each is a value the cell shows, never a failed request or a crashed page:

/// A reference to a cell that is not on the sheet.
pub const REF_ERROR: &str = "#REF";
/// A division by zero.
pub const DIV_ERROR: &str = "#DIV/0";
/// A cell whose value depends on itself.
pub const CYCLE_ERROR: &str = "#CYCLE";
/// Text where a number was needed.
pub const VALUE_ERROR: &str = "#VALUE";
/// A formula that does not compile.
pub const SYNTAX_ERROR: &str = "#SYNTAX";
/// A value that is not available: a feed nothing has written to, or the time on a kernel
/// with no clock.
pub const NA_ERROR: &str = "#N/A";

An error is also passed on: a formula that reads a cell showing #DIV/0 shows #DIV/0.

Rent
[uncacheable]
=A1/0
[uncacheable]
=C1+1
[uncacheable]
=E1+1
[uncacheable]
=B2*2
[uncacheable]
#DIV/0
[computed]
#DIV/0
[computed]
#REF
[computed]
#VALUE
[computed]

E1 is #REF because the sheet has no column E, so no name answers for it. The evaluator knows that without asking, and asks for nothing.

The fifth error is the reason the compiler never fails. A formula with a mistake in it still compiles, to an expression that says what was wrong:

=A1+
[uncacheable]
(syntax-error "expected a number, a cell or (, and the formula ended")
[computed]
#SYNTAX
[computed]
#SYNTAX
[cached]

A compiler that refused =A1+ would be the more obvious design, and it would cost the cache. The kernel never caches an answer built on a refusal of that kind, because it cannot name what would make the refusal go away: it records a failed read as a dependency that has already expired. So every read of a cell with a typo in it would compile the typo again, and every value that read that cell would be computed again with it. Compiled to a value instead, (syntax-error …) is cached, hangs from the input like any other compiled form, and goes away when the input is fixed.

A cycle is named, not chased

A formula can read itself, the long way round: D1 reads D2, and D2 reads D1. Evaluated naively, D1 asks for D2, which asks for D1, which asks for D2, until something stops it. Here something would: the kernel bounds how deep one resolution may go (64 levels, by default), and refuses past it. But that refusal is a kernel error with the whole chain in it, the kind of answer the last section said cannot be cached, and it would arrive as a failed page rather than a cell saying what is wrong.

So a value checks first, with one more resource. precedents:{ref} is every cell a cell’s value depends on, however far back, found by walking refs:{ref}:

/// `sheet-precedents`: every cell the cell's value depends on, however far back, one name
/// per line, nearest first.
///
/// A walk over `refs:{ref}`: the cells this one reads, then the cells those read, and so on,
/// each visited once. It reads formulas and never values, so it ends even when the formulas
/// go round in a circle, and a cell that turns up among its own precedents is in one. That
/// is how a cell's value can say `#CYCLE` instead of chasing itself down to the kernel's
/// depth bound.
pub fn precedents() -> AsyncFnEndpoint {
    AsyncFnEndpoint::new(
        "sheet-precedents",
        |inv: &Invocation<'_>| -> InvokeFuture<'_> {
            Box::pin(async move {
                let at = cell_binding(inv, "ref")?;
                let mut found: Vec<String> = Vec::new();
                let mut queue = VecDeque::from([at]);
                while let Some(next) = queue.pop_front() {
                    for name in lines_of(&text_of(inv, &refs_name(next)).await?) {
                        if found.iter().any(|seen| seen == name) {
                            continue;
                        }
                        found.push(name.to_string());
                        if let Some(cell) = cell_of(name).filter(|cell| *cell != at) {
                            queue.push_back(cell);
                        }
                    }
                }
                Ok(name_list(&found))
            })
        },
    )
    .with_description(
        Description::new("sheet-precedents")
            .title("Precedents")
            .summary("Every cell a cell's value depends on, however far back, one name per line.")
            .verb(Verb::Source)
            .verb(Verb::Meta)
            .input(ref_input("the cell, A1 to D6"))
            .output(TEXT_PLAIN_UTF8),
    )
}

It reads formulas and never values, and it visits each cell once, so it ends even when the formulas go round in a circle. A cell that turns up among its own precedents is in one, and its value is #CYCLE:

=D2+1
[uncacheable]
=D1*2
[uncacheable]
#CYCLE
[computed]
urn:iki:tutorial:sheet:cell:D2
urn:iki:tutorial:sheet:cell:D1
[cached]

Break the cycle by giving D2 a number, and D1 is computed again from it. Its precedents were built from D2’s old formula, so D2’s input cut them too:

4
[uncacheable]
urn:iki:tutorial:sheet:cell:D2
[computed]
5
[computed]

The sheet

The same sheet as Part I, on this page’s kernel, with everything the cells above typed into it. Try a formula of your own, a range, and a mistake.

The sheet appears here when the in-page kernel has loaded.

What was built

the setthe namewhat it is
the compilerurn:iki:tutorial:sheet:compilecode: a pure function, declared as a lossy transreptor
a compiled formulaurn:iki:tutorial:sheet:formula:{ref}glue: the input, run through whatever plan the kernel finds
referencesurn:iki:tutorial:sheet:refs:{ref}code: a small function of the compiled form and the ranges it names
a rangeurn:iki:tutorial:sheet:range:{from}:{to}code: a pure function of its own name
precedentsurn:iki:tutorial:sheet:precedents:{ref}code: a walk over refs, the question “is this a cycle?”
a valueurn:iki:tutorial:sheet:cell:{ref}code: the evaluator, reading every value it needs by name

formula:{ref} is glue for the reason the time chapter’s view used to be: a name cannot carry an argument. A view no longer needs one, since compose_over fixes the template when it is bound (Part I’s grid is built that way), but nothing fixes a transreption’s target type. If a name could mean “this resource, transrepted to that type”, the compiled formula would be a rule, formula:{ref} = input:{ref} as text/x-sexpr, and no code at all.

Every one of these is cacheable, and not one names a golden thread or lists a dependency. The next part edits a cell and asks what that cost.

Spreadsheet, III: recalculation is golden threads

Change one number in a spreadsheet, and every cell that reads it has to be computed again, and every cell that reads those, and nothing else. Getting that right, and fast, is most of what a spreadsheet engine is. The usual design keeps a dependency graph: for every cell, the cells it reads (its precedents) and the cells that read it (its dependents). An edit marks the edited cell dirty, follows the dependents to mark everything downstream dirty too, and then computes the dirty cells in an order that puts every cell after the cells it reads. The graph has to be kept true as formulas change: typing a new formula into a cell removes the edges its old formula had and adds the new ones.

This sheet has none of that. Part II built a value that reads other values by name, and nothing in it writes an edge, marks a cell dirty or orders anything. This part edits a cell and checks that exactly the right cells are computed again, then shows where the graph went.

A sheet with a chain in it

Six cells: a number, two formulas that chain off it, a total over all three, and a second number with a formula of its own that has nothing to do with the first.

5
[uncacheable]
=A1*2
[uncacheable]
=A2+1
[uncacheable]
100
[uncacheable]
=SUM(A1:A3)
[uncacheable]
=B1/4
[uncacheable]
26
[computed]
25
[computed]

B2 is 5 + 10 + 11. After this cell runs, the sheet at the bottom of the page draws itself again, as in the other parts, and that reads every value on the sheet, so all twenty-four are now cached.

Edit one cell

Change A1, and ask the cache about every cell that holds something:

7
[uncacheable]
not cached
not cached
not cached
cached
not cached
cached

A1, A2, A3 and B2 are gone from the cache: the edited cell, the chain that reads it, and the total over the chain. B1 and C1 are still cached, because neither read A1. And the edit itself computed nothing. The four values are computed again when something next reads them, which here is the grid, straight after the cell. A spreadsheet engine recalculates when you type; this sheet recalculates when you look.

Here is one of those reads, traced. Edit A1 once more, and trace A3, the end of the chain:

8
[uncacheable]
trace  urn:iki:tutorial:sheet:cell:A3
  client      ikigai repl  ·  capability: root (full authority)
  transport   embedded · in-process
 
urn:iki:tutorial:sheet:cell:A3   sheet-cell · computed · ThreadId(1) · —   → 2b  17
├─ urn:iki:tutorial:sheet:input:A3   sheet-input · cached · ThreadId(1) · —
├─ urn:iki:tutorial:sheet:precedents:A3   sheet-precedents · computed · ThreadId(1) · —
│  ├─ urn:iki:tutorial:sheet:refs:A3   sheet-refs · cached · ThreadId(1) · —
│  ├─ urn:iki:tutorial:sheet:refs:A2   sheet-refs · cached · ThreadId(1) · —
│  └─ urn:iki:tutorial:sheet:refs:A1   sheet-refs · computed · ThreadId(1) · —
│     └─ urn:iki:tutorial:sheet:input:A1   sheet-input · computed · ThreadId(1) · —
├─ urn:iki:tutorial:sheet:formula:A3   sheet-formula · cached · ThreadId(1) · —
└─ urn:iki:tutorial:sheet:cell:A2   sheet-cell · computed · ThreadId(1) · —
   ├─ urn:iki:tutorial:sheet:input:A2   sheet-input · cached · ThreadId(1) · —
   ├─ urn:iki:tutorial:sheet:precedents:A2   sheet-precedents · computed · ThreadId(1) · —
   │  ├─ urn:iki:tutorial:sheet:refs:A2   sheet-refs · cached · ThreadId(1) · —
   │  └─ urn:iki:tutorial:sheet:refs:A1   sheet-refs · cached · ThreadId(1) · —
   ├─ urn:iki:tutorial:sheet:formula:A2   sheet-formula · cached · ThreadId(1) · —
   └─ urn:iki:tutorial:sheet:cell:A1   sheet-cell · computed · ThreadId(1) · —
      └─ urn:iki:tutorial:sheet:input:A1   sheet-input · cached · ThreadId(1) · —

Read it from the bottom. A1’s value was computed, because its input changed. A2’s was computed because it read A1’s. A3’s was computed because it read A2’s. Their compiled formulas were served from the cache, since no formula changed, and so were their inputs. The trace is the dependency graph, for one cell, drawn after the fact from what was actually read.

Where the graph is

Every cacheable answer the kernel keeps carries the set of golden threads it hangs from: the threads of everything it read while it was being computed, gathered as it read them. A3’s value read A2’s value, which read A1’s, which read A1’s input, so A3’s value carries A1’s input thread, and the edit to A1 cut it. That set is a cell’s precedents, turned inside out and stored on the answer rather than on the cell. Nothing keeps the dependents at all. A cut does not go looking for them; it moves a counter on, and each cached answer, when it is next asked for, checks whether any of its threads has moved since it was stored.

The counters are all the kernel keeps about the edits, and you can read them:

threads (cut generations)
  urn:iki:tutorial:sheet:input:A1  gen 3
  urn:iki:tutorial:sheet:input:A2  gen 1
  urn:iki:tutorial:sheet:input:A3  gen 1
  urn:iki:tutorial:sheet:input:B1  gen 1
  urn:iki:tutorial:sheet:input:B2  gen 1
  urn:iki:tutorial:sheet:input:C1  gen 1
[uncacheable]

Only inputs appear, since inputs are the only things anything writes. A1’s thread has been cut three times, once for each edit on this page; the others once, when they were first typed.

This is why there is no graph to keep true. Give C1 a new formula, and it stops reading B1 and starts reading A3. An engine with a graph has to remove one edge and add another. Here the next evaluation of C1 reads what the new formula names, and the kernel records that:

=A3/4
[uncacheable]
4.25
[computed]
200
[uncacheable]
cached
9
[uncacheable]
not cached

C1 no longer cares about B1, and now cares about A1, through A3 and A2. Nobody told it. The dependencies are dynamic in a way a stored graph finds hard: they are whatever the evaluation actually read, so a formula that read one of two cells depending on a third would depend only on the one it read, each time.

The sheet, and what an edit computed again

Here is the sheet, with one thing added under it: after each edit, before the grid is drawn again, the page asks the kernel what drawing it will cost, and shows the answer as a table the shape of the grid. A cell marked computed is a value the redraw will compute; the rest it will be served. Type into A1 and watch the chain; type into D6, which nothing reads, and watch one cell.

The sheet appears here when the in-page kernel has loaded.

The table is a resource, view:cost, and it is composed like the grid, from templates and names, with no code of its own. The question it asks of each cell is the one the cache command asks: if somebody read this value right now, would the cache serve it? The kernel answers that question as a resource, urn:kernel:cached, which resolves nothing and computes nothing: it looks up the key a read by the asker would use, under the asker’s own capability and chain, and says true or false. A one-marker template gives it a name for each cell:

$h{urn:kernel:cached?target=urn:iki:tutorial:sheet:cell:{ref}}

and each cell of the table branches on it with urn:iki:fn:conditional, which reads the name in if, compares it with equals, and resolves only the branch it takes:

$r{urn:iki:fn:conditional?if=urn:iki:tutorial:sheet:view:cached:{ref}&equals=true&then=urn:iki:tutorial:sheet:template:served&else=urn:iki:tutorial:sheet:template:computed}
/// `true` or `false`: would a read of the cell's value be served from the cache right now?
pub const VIEW_CACHED: &str = "urn:iki:tutorial:sheet:view:cached:{ref}";

/// One cell of the cost of drawing the grid again: `computed` or `cached`.
pub const VIEW_COST_CELL: &str = "urn:iki:tutorial:sheet:view:cost:{ref}";

/// The cost of drawing the grid again, as a table the shape of the grid.
pub const VIEW_COST: &str = "urn:iki:tutorial:sheet:view:cost";

The view has to be read before the redraw, since the redraw is what computes the values again, and it is never cached itself: urn:kernel:cached is a live question, so everything built on it is asked again every time. The crate’s test pins what it says:

/// The cost view: a composition over `urn:kernel:cached` that names exactly the values a
/// redraw of the grid will compute, and resolves none of them.
#[test]
fn the_cost_view_names_exactly_what_an_edit_cut() {
    let kernel = kernel();
    type_into(&kernel, "A1", "5");
    type_into(&kernel, "A2", "=A1*2");
    type_into(&kernel, "B1", "7");
    source(&kernel, spreadsheet::VIEW_GRID).unwrap();
    let computed = |kernel: &Kernel| {
        let cost = source(kernel, spreadsheet::VIEW_COST).unwrap();
        assert_eq!(cost.matches("<td>").count(), 25, "{cost}");
        cost.matches("<strong>computed</strong>").count()
    };
    assert_eq!(computed(&kernel), 0, "every value is cached after the grid");
    type_into(&kernel, "A1", "6");
    assert_eq!(computed(&kernel), 2, "A1 and A2");
    assert_eq!(
        source(&kernel, "urn:iki:tutorial:sheet:view:cost:A2").unwrap(),
        "<strong>computed</strong>"
    );
    assert_eq!(
        source(&kernel, "urn:iki:tutorial:sheet:view:cost:B1").unwrap(),
        "cached"
    );
    // Asking cost nothing: the probe resolved no value, so both are still not cached.
    assert!(!value_cached(&kernel, "A1") && !value_cached(&kernel, "A2"));
    source(&kernel, spreadsheet::VIEW_GRID).unwrap();
    assert_eq!(computed(&kernel), 0);
}

Before core 0.1.82 this table was the page’s own code, and that was a finding rather than a choice. The kernel’s cache readout, urn:kernel:cache, lists what the cache holds, and a cut is lazy: an answer whose thread was cut stays in the cache until the next read of it finds it stale and drops it. So right after an edit the readout listed every value the edit had invalidated exactly as it did before, and the probe that knew the difference belonged to the host; no endpoint could ask it. Now the readout marks each row live, cut or expired, and the probe is a resource anybody holding urn:cap:kernel:inspect can read, so the viewer could become a composition, and did.

What it costs, and what it does not do

An edit costs nothing until somebody reads. The work is done on the next read of each value that was cut, and only if something reads it. A cell nobody shows is never recomputed. That suits a sheet on a page; it is the wrong shape for a sheet whose values must be pushed somewhere the moment they change, which needs something to read them, and a later part’s subject.

A cut is by name, not by content. Type 9 into A1 when it already holds 9, and every value that reads it is still computed again:

9
[uncacheable]
not cached
46
[computed]

The same answer, computed again. A write cuts the thread named after what it wrote, and every answer hanging from that thread goes, whether or not it would come out different. Some build systems stop early here, comparing a recomputed answer with the old one and leaving everything downstream alone when they match. The kernel does not, and it shows in a subtler place too: refs:A1 reads A1’s input to learn that A1 holds no formula, so changing the number in A1 cuts A1’s references, and with them the precedents of every cell downstream, though no formula changed. The trace above shows it: refs:A1 computed, precedents:A2 and precedents:A3 computed with it. It is correct, and it is work a finer design could skip, by putting “which kind of input is this” in a resource of its own.

The cache is bounded. The kernel evicts entries when it holds too many, and an evicted value is simply computed again on its next read. Nothing depends on a value staying cached; the cache only makes reads cheap.

What was built

Nothing, in this part. No endpoint was added, and no line of the sheet’s code is about recalculation: not a dirty flag, not a dependents list, not an ordering. The recalculation engine is the golden threads the kernel already had, and the evaluator is a function that reads other values by name.

What comes next leans on that. A cell whose formula reads the time needs its value to expire as well as be cut, which is the time chapter’s other way for an answer to go (Part IV). A cell that reads outside data needs something outside to cut it (Part V). And because a cell’s value is a name, a personal sheet, my overrides layered over a shared one, is a corridor in front of the same names rather than a copy of the sheet.

Spreadsheet, IV: cells that read the clock

Part III showed one way for a cached value to go: something writes, the kernel cuts a golden thread, and every value that read what was written is computed again the next time somebody looks. Time is a resource showed the other way: an answer says how long it is good for, and the kernel lets it go at that instant, with nobody writing anything.

A spreadsheet needs both. =NOW() is out of date a minute after it is computed, and so is every cell that reads it, however far down the chain. This part adds NOW() and TODAY() to the formula language and shows that nothing else changes: the recalculation that golden threads gave the sheet in Part III, expiry gives it here, and a formula that never reads the time is never computed again because of it.

The model, on paper

No new set, and no new atom. Two new functions in the formula language, each of which reads a name the time chapter already serves:

in a formulacompiles toreadsgood until
NOW()(now)urn:iki:tutorial:time:instant, the date and the time as one readingthe next minute
TODAY()(today)urn:iki:tutorial:time:today, the datethe next midnight
/// What `NOW()` reads: the time chapter's current minute, the date and the time as one
/// reading.
pub const TIME_INSTANT: &str = "urn:iki:tutorial:time:instant";

/// What `TODAY()` reads: the time chapter's date.
pub const TIME_TODAY: &str = "urn:iki:tutorial:time:today";

The sheet reads these names; it does not serve them. The time chapter’s crate does, and the page binds both side by side, as it binds tic-tac-toe beside the book’s Part I endpoints. That is the whole of the integration: the sheet names a resource another chapter built, and the kernel finds it.

NOW() is the date and the time, as a spreadsheet’s NOW() is, written the way the time chapter writes an instant: 2026-09-29T14:05Z. TODAY() is the date alone. Both are text, so a formula that does arithmetic on them gets #VALUE; this part is about when they change, not about what can be done with them.

Evaluating the time

The compiler learns two words and the evaluator one function. Reading the time is reading a name through the kernel, exactly as reading a cell is:

/// `NOW()` or `TODAY()`: the text of the time chapter's `instant` or `today`, read through
/// the kernel, so the value that asked inherits its deadline. A kernel with no clock has no
/// time to give, and never will (its clock is fixed when it is built), so that is `#N/A`,
/// and nothing is asked.
async fn read_time(inv: &Invocation<'_>, name: &str) -> Result<Value> {
    if inv.now().is_none() {
        return Ok(Value::Error(NA_ERROR));
    }
    Ok(Value::Text(text_of(inv, name).await?))
}

There is nothing here about expiry. The evaluator does not say how long a value that read the time is good for, any more than it said in Part III which cells a value depends on. It reads the name, and the kernel records what it read: a golden thread for every name, and a deadline for every name that had one.

This page’s kernel has a clock: the page carries a data-clock attribute, as the time chapter’s does, and so it is built with the browser’s. The outputs under each cell are from a test that stopped a clock where the cell says; your times will be your own.

The minute turns

Four cells: the time, the date, a cell that reads the time without saying so, and some arithmetic that has nothing to do with it. It is 14:05:20 UTC on 29 September 2026.

=NOW()
[uncacheable]
=TODAY()
[uncacheable]
=A1
[uncacheable]
=6*7
[uncacheable]
(now)
[computed]
2026-09-29T14:05Z
[computed]

B1’s formula is =A1. It says nothing about time. Twenty seconds later, everything is still cached, B1 included, because the grid at the bottom of the page read every value after the cell above ran:

cached
cached
2026-09-29T14:05Z
[cached]

Now let the minute turn. It is 14:06:05, nobody has typed anything, and no thread has been cut:

not cached
not cached
cached
cached
cached
not cached
2026-09-29T14:06Z
[computed]

A1 went at the minute, because it read instant, which read now, whose answer was good until 14:06. B1 went with it, because it read A1. The date stayed, and so did C1, and so did the compiled formula: compiling =NOW() reads the formula’s text, not the time, so the compiled form is cached for as long as nobody edits the cell. And the grid went, because it read B1.

That is the rule the time chapter stated for a countdown, now on a sheet: a value is never fresher than the least fresh thing it read. B1 declares itself cacheable with no deadline, like every value, and the kernel meets that with the deadline of everything it read. Here is what it read, one minute further on:

trace  urn:iki:tutorial:sheet:cell:B1
  client      ikigai repl  ·  capability: root (full authority)
  transport   embedded · in-process
 
urn:iki:tutorial:sheet:cell:B1   sheet-cell · computed · ThreadId(1) · 0ms   → 17b  2026-09-29T14:07Z
├─ urn:iki:tutorial:sheet:input:B1   sheet-input · cached · ThreadId(1) · 0ms
├─ urn:iki:tutorial:sheet:precedents:B1   sheet-precedents · cached · ThreadId(1) · 0ms
├─ urn:iki:tutorial:sheet:formula:B1   sheet-formula · cached · ThreadId(1) · 0ms
└─ urn:iki:tutorial:sheet:cell:A1   sheet-cell · computed · ThreadId(1) · 0ms
   ├─ urn:iki:tutorial:sheet:input:A1   sheet-input · cached · ThreadId(1) · 0ms
   ├─ urn:iki:tutorial:sheet:precedents:A1   sheet-precedents · cached · ThreadId(1) · 0ms
   ├─ urn:iki:tutorial:sheet:formula:A1   sheet-formula · cached · ThreadId(1) · 0ms
   └─ urn:iki:tutorial:time:instant   time-instant · computed · ThreadId(1) · 0ms
      ├─ urn:iki:tutorial:time:today   time-today · cached · ThreadId(1) · 0ms
      ├─ urn:iki:tutorial:time:now   time-now · computed · ThreadId(1) · 0ms
      └─ urn:iki:tutorial:time:today   time-today · cached · ThreadId(1) · 0ms

Read it from the bottom. now was computed, since its minute had passed; today was served, twice: instant reads the date on both sides of the time, which is a later section’s subject. instant, A1’s value and B1’s value were computed because they read now. The inputs, the compiled formulas and the precedents were served, since nothing wrote to them. (On this page a trace has a clock to time itself by, so it prints a duration where Part III’s printed —. The test’s clock stood still, which is why it says 0ms; yours will not.)

Midnight is the same thing with a longer deadline. It is 00:00:10 on 30 September:

not cached
cached
2026-09-30
[computed]

The crate’s tests say all of this with a clock the test holds:

#[test]
fn a_time_cell_goes_on_the_minute_and_takes_every_cell_that_reads_it_along() {
    let (kernel, clock) = clocked(millis_at("2026-09-29T14:05Z", 20));
    type_into(&kernel, "A1", "=NOW()");
    type_into(&kernel, "A2", "=TODAY()");
    type_into(&kernel, "B1", "=A1");
    type_into(&kernel, "C1", "=6*7");
    assert_eq!(value(&kernel, "A1"), "2026-09-29T14:05Z");
    assert_eq!(value(&kernel, "B1"), "2026-09-29T14:05Z");
    assert_eq!(value(&kernel, "A2"), "2026-09-29");
    assert_eq!(value(&kernel, "C1"), "42");

    // B1's formula says nothing about time. Its value has the time's deadline all the same;
    // C1's, which read no time, has none.
    let next_minute = Expiry::At(Time::from_millis(millis_at("2026-09-29T14:06Z", 0)));
    assert_eq!(
        expiry(&kernel, "urn:iki:tutorial:sheet:cell:B1"),
        next_minute
    );
    assert_eq!(
        expiry(&kernel, "urn:iki:tutorial:sheet:cell:C1"),
        Expiry::Never
    );

    // The grid reads every value, so it is cached until the minute too.
    source(&kernel, spreadsheet::VIEW_GRID).unwrap();
    assert_eq!(expiry(&kernel, spreadsheet::VIEW_GRID), next_minute);
    assert!(uncached(&kernel).is_empty());

    // The minute turns: nobody wrote anything, and exactly the two values that read the
    // time are gone. The date and the arithmetic stay.
    clock.set(millis_at("2026-09-29T14:06Z", 0));
    assert_eq!(uncached(&kernel), ["A1", "B1"]);
    assert!(!cached(&kernel, spreadsheet::VIEW_GRID));
    source(&kernel, spreadsheet::VIEW_GRID).unwrap();
    assert!(uncached(&kernel).is_empty());
    assert_eq!(value(&kernel, "B1"), "2026-09-29T14:06Z");

    // Midnight: the date goes too.
    clock.set(millis_at("2026-09-30T00:00Z", 5));
    assert_eq!(uncached(&kernel), ["A1", "B1", "A2"]);
    assert_eq!(value(&kernel, "A2"), "2026-09-30");
}

One NOW() in a sheet is a performance change

The grid read every value on the sheet, so the grid expires on the minute as soon as any one cell reads the time. Put =NOW() in a corner of a sheet that was cached until somebody typed, and the whole grid is computed again every minute from then on. Nothing in the grid’s code, or the grid’s types, or the formula’s, shows it. It is the least-cacheable-dependency rule working against you, the edge the time chapter warned about.

What keeps it cheap is the same thing that made Part III’s recalculation cheap. The grid is computed again, but the grid is a composition of twenty-four values, and twenty-two of them are still cached. Drawing it again is one computation of each value that read the time, and twenty-two lookups. The expensive part of a sheet, the formulas over ranges, is only as volatile as the cells it actually reads.

Two readings of one clock, again

The time chapter found a trap in reading the date and the time as two resources: read today just before midnight and now just after, and the pair names a minute a day away from the real one. A spreadsheet’s NOW() is the date and the time, so the trap is in exactly the place this part would have put it.

It is not here, because NOW() reads one name, instant, and instant is the guarded reading: the date read on both sides of the time. The guard is written once, in the resource, and every reader of that name is safe without knowing there was a trap. The crate’s test runs =NOW() two hundred times across midnight, on a clock that moves a millisecond every time it is read:

/// `NOW()` reads the date and the time, and across midnight it never tears them: it reads the
/// time chapter's `instant`, which is one reading, not two.
#[test]
fn now_is_never_a_day_out_across_midnight() {
    let midnight = millis_at("2026-09-30T00:00Z", 0);
    for before in 1..200 {
        let kernel = host(Some(Arc::new(Ticking(AtomicU64::new(midnight - before)))));
        type_into(&kernel, "A1", "=NOW()");
        let now = value(&kernel, "A1");
        assert!(
            now == "2026-09-29T23:59Z" || now == "2026-09-30T00:00Z",
            "{before}ms before midnight: {now}"
        );
    }
}

A sheet can still tear the two by hand. Put =TODAY() in one cell and a time in another, and the grid, which reads both, can be drawn once across midnight with yesterday’s date beside today’s time. It is drawn that way once, never twice: the date it read expired at the midnight it straddled, so the grid it was composed into expired then too, and the next read computes it again. A torn reading is an answer that is wrong once, not an answer the kernel keeps. (The time chapter’s own test for this is an_unguarded_reading_across_midnight_is_wrong_once_and_never_cached.)

A kernel with no clock

Almost every other page of this book runs a kernel with no clock, and the time chapter’s names refuse there. The sheet does not pass the refusal on. A formula that reads the time on a kernel with no clock is #N/A, a value, like the sheet’s other errors, and the evaluator does not even ask:

  • A refusal is never cached. The kernel records every error except NotFound as already expired, so a value that caught one would be computed again on every read, and so would the grid.
  • The answer cannot change. A kernel’s clock is chosen when it is built, so a kernel with no clock will never have one. #N/A is true for as long as the kernel lives, and the kernel caches it for exactly that long.

The sheet, polling

Here is the sheet again, with one difference from Part III’s: it can poll. Press Poll and the grid is asked for again every two seconds, as the time chapter’s clock was. Each poll is counted: answered from the cache, or computed. Type =NOW() into a cell and watch the count. The grid is computed once a minute, on the first poll after the minute turns, and the table under the grid marks exactly the values that read the time.

The sheet appears here when the in-page kernel has loaded.

Polling is the only way this grid can find out that the minute turned. Nothing wrote, so there was no edit for the page to react to. The answer went because its deadline passed, and a deadline passing is not an event anybody is told about: the next read finds it gone. So something has to read. That is also true of a value that goes because something wrote, when the something is not the page, and that is Part V.

What was built

the setthe namewhat it is
NOW(), TODAY()in a formula; (now), (today) compiledtwo words for the compiler, and one read of a name each for the evaluator
the instanturn:iki:tutorial:time:instantthe time chapter’s guarded reading of today and now, given a name (Time is a resource)
the live sheeturn:iki:tutorial:sheet:template:livePart I’s page, with a grid that polls

No endpoint was added to the sheet. The evaluator reads two more names, and the deadlines, the recalculation on the minute and the cells that are left alone all come from the kernel’s rule for composites, which the sheet was already following. One thing in the time chapter’s crate exists for exactly this, and it is not code for the sheet: the guard every composite over the date and the time would otherwise have had to remember, which that chapter moved into a resource so that nothing has to.

Spreadsheet, V: data that changes outside the sheet

Everything the sheet has shown so far changes for one of two reasons: somebody typed into it, or the clock moved. Real sheets read a third kind of thing, data that belongs to somebody else: a stock price, a sensor reading, a figure from another team’s system. It changes when they change it, and the sheet is not told.

This part gives the sheet a feed: a name that something outside the sheet writes, and a formula function, FEED(name), that reads it. The claim is the one Part III made about recalculation, pushed one step further. The sheet needs no machinery for outside data either. A write to a feed is a Sink on a name, the kernel cuts that name’s golden thread, and exactly the values that read the feed are computed again the next time somebody looks. What is new is the last four words. The page did not make the write, so the page does not know to look.

The model, on paper

the setits namewhat it is
a feedurn:iki:tutorial:sheet:feed:{name}the latest value written to a feed: the second atom, and the first the sheet never writes
the market movesurn:iki:tutorial:sheet:tick:{name}Sink only: writes the feed’s next price to it; the outside world, simulated
FEED(name)in a formula; (feed name) compiledreads the feed
the market pageurn:iki:tutorial:sheet:template:marketthe sheet, a grid that polls, and a market that writes a feed
/// The latest value written to a feed: an atom the sheet reads and never writes.
pub const FEED: &str = "urn:iki:tutorial:sheet:feed:{name}";

/// The market moves: `Sink` only. The next price, written to the feed.
pub const TICK: &str = "urn:iki:tutorial:sheet:tick:{name}";

A feed’s name is a lower-case letter, then lower-case letters and digits: acme, fx2. As with a cell, one spelling per feed, so one golden thread per feed. In a formula the name may be written any case, FEED(Acme), and compiles to the one spelling, (feed acme).

The feed: an atom somebody else writes

A feed is the input’s shape again: a map from a name to a string, in memory, Sink to write, Delete to clear, and NotFound for a feed nobody has written to:

/// `sheet-feed`: the latest value written to the feed `name`, held in memory.
///
/// The same shape as the input: `Source` answers what was written, `.cacheable()` with no
/// thread named, so the kernel hangs it from the feed's own name and cuts that on every
/// `Sink` or `Delete` here. A feed nothing has written to is `NotFound`. `Sink` stores
/// `content`, trimmed, and answers it.
///
/// Nothing in the sheet writes here. A price service, a sensor, a person at a shell: whatever
/// writes a feed is outside the sheet, and the sheet finds out the way it finds out about
/// anything, by reading a name whose thread was cut.
pub fn feed(store: Arc<FeedStore>) -> FnEndpoint {
    FnEndpoint::new("sheet-feed", move |inv: &Invocation<'_>| {
        let name = feed_binding(inv)?;
        let mut latest = store.latest.lock().expect("the feed store's lock");
        match inv.request.verb {
            Verb::Sink => {
                let written = inv.inline_str("content")?.trim();
                if written.is_empty() || written.len() > MAX_INPUT {
                    return Err(Error::InvalidArgument {
                        name: "content".to_string(),
                        detail: format!(
                            "{} bytes; a feed holds 1 to {MAX_INPUT} (to clear it, delete it)",
                            written.len()
                        ),
                    });
                }
                latest.insert(name, written.to_string());
                Ok(Representation::new(
                    text_plain_utf8(),
                    written.as_bytes().to_vec(),
                ))
            }
            Verb::Delete => {
                latest.remove(&name);
                Ok(Representation::new(text_plain_utf8(), b"ok".to_vec()))
            }
            _ => match latest.get(&name) {
                Some(written) => Ok(Representation::new(
                    text_plain_utf8(),
                    written.clone().into_bytes(),
                )
                .cacheable()),
                None => Err(Error::NotFound(format!(
                    "nothing has been written to the feed `{name}`"
                ))),
            },
        }
    })
    .with_description(
        Description::new("sheet-feed")
            .title("Feed")
            .summary("The latest value written to a feed: read it, write it, or clear it.")
            .verb(Verb::Meta)
            .action(
                ActionSpec::new(Verb::Source)
                    .summary("the latest value written; NotFound if nothing has been")
                    .input(feed_input())
                    .output(TEXT_PLAIN_UTF8),
            )
            .action(
                ActionSpec::new(Verb::Sink)
                    .summary("write the feed's latest value, and answer it")
                    .input(feed_input())
                    .input(
                        ArgSpec::new("content")
                            .summary("the value: a number, or some text")
                            .class(XSD_STRING),
                    )
                    .output(TEXT_PLAIN_UTF8),
            )
            .action(
                ActionSpec::new(Verb::Delete)
                    .summary("clear the feed")
                    .input(feed_input())
                    .output(TEXT_PLAIN_UTF8),
            ),
    )
}

The page has no price service, so it has an endpoint that plays one. tick:{name} reads the feed, works out a next price, and writes it, all through the kernel:

/// `sheet-tick`: the market moves. `Sink` only.
///
/// Reads the feed `name` through the kernel (a feed nothing has written to opens at 100),
/// writes [`next_price`] to it through the kernel, and answers the new price. The write is an
/// ordinary `Sink` on `feed:{name}`, so it cuts the feed's thread exactly as a price service
/// writing the same name would; this endpoint is here only because the page has no price
/// service. A feed holding something that is not a price is a `Conflict`: the market does not
/// move a feed somebody has set to `halted`.
pub fn tick() -> AsyncFnEndpoint {
    AsyncFnEndpoint::new("sheet-tick", |inv: &Invocation<'_>| -> InvokeFuture<'_> {
        Box::pin(async move {
            if inv.request.verb != Verb::Sink {
                return Err(Error::Endpoint(
                    "the market moves when it is sunk: `sink` it (and read the feed)".to_string(),
                ));
            }
            let name = feed_binding(inv)?;
            let target = iri(&feed_name(&name))?;
            let cents = match inv.source(&target).await {
                Ok(latest) => {
                    let text = String::from_utf8_lossy(&latest.bytes).into_owned();
                    formula::parse_number(&text)
                        .filter(|n| *n > 0.0 && *n < 1e12)
                        .map(|n| (n * 100.0).round() as u64)
                        .ok_or_else(|| {
                            Error::Conflict(format!(
                                "the feed `{name}` holds `{text}`, which is not a price"
                            ))
                        })?
                }
                Err(Error::NotFound(_)) => OPENING_CENTS,
                Err(other) => return Err(other),
            };
            let price = formula::number_text(next_price(cents) as f64 / 100.0);
            let write = Request::new(Verb::Sink, target)
                .with_arg("content", ArgRef::Inline(price.clone().into_bytes()));
            inv.issue(write).await?;
            Ok(Representation::new(text_plain_utf8(), price.into_bytes()))
        })
    })
    .with_description(
        Description::new("sheet-tick")
            .title("The market moves")
            .summary("Writes the feed's next price to it, and answers the price. Sink only.")
            .verb(Verb::Sink)
            .verb(Verb::Meta)
            .input(feed_input())
            .output(TEXT_PLAIN_UTF8),
    )
}

The next price is a pure function of the current one, so this chapter’s outputs are the same every time it is run:

/// The market, simulated: the price after `cents`, in cents, somewhere within 1.50 of it and
/// never below one cent.
///
/// A pure function of the price, so the chapter's outputs are the same on every run: a
/// generator seeded by the price, not by the time. The sheet never sees this; it sees only
/// what gets written to the feed.
///
/// ```
/// use spreadsheet::next_price;
/// assert_eq!(next_price(10_000), next_price(10_000));
/// assert!(next_price(10_000).abs_diff(10_000) <= 150);
/// assert!(next_price(1) >= 1);
/// ```
pub fn next_price(cents: u64) -> u64 {
    let mixed = cents
        .wrapping_mul(6_364_136_223_846_793_005)
        .wrapping_add(1_442_695_040_888_963_407);
    let step = (mixed >> 33) % 301;
    (cents.saturating_add(step)).saturating_sub(150).max(1)
}

None of that is the sheet. From the sheet’s side a feed is a name, and the evaluator reads it the way it reads a cell:

/// `FEED(name)`: the latest value written to the feed, read through the kernel.
///
/// A feed nothing has written to is `#N/A`. That fallback is safe to cache: the value that
/// caught the `NotFound` hangs from the feed's golden thread, so the first write to the feed
/// cuts it. It catches the ATOM's own `NotFound`, which says exactly what is missing; before
/// core 0.1.82 that was also the only safe choice, since a composite's `NotFound` hung the
/// fallback from a thread nobody cuts.
async fn read_feed(inv: &Invocation<'_>, name: &str) -> Result<Value> {
    match inv.source(&iri(&feed_name(name))?).await {
        Ok(latest) => Ok(Value::of(&String::from_utf8_lossy(&latest.bytes))),
        Err(Error::NotFound(_)) => Ok(Value::Error(NA_ERROR)),
        Err(other) => Err(other),
    }
}

A write the sheet did not make

Write a price to the feed acme, as a price service would, and build a small holding on it: the price in A1, a number of shares in A2, their value in A3. C1 is arithmetic that has nothing to do with it.

100
[uncacheable]
=FEED(acme)
[uncacheable]
10
[uncacheable]
=A1*A2
[uncacheable]
=6*7
[uncacheable]
1000
[computed]

Now the price changes. The line below writes the feed, and nothing in it mentions the sheet:

101.5
[uncacheable]
not cached
cached
not cached
cached
1015
[computed]

The write cut the feed’s thread. A1 read the feed and A3 read A1, so both went; A2 and C1 read neither and stayed. It is Part III’s edit again, with one difference that is the whole point of this part: the write came from outside the sheet, and the sheet’s code has no idea it happened. It does not need one. The dependency was recorded when A1 read the feed, and the cut found it.

The market moving is the same write, made by an endpoint rather than by hand. Let it move, and trace the holding:

100.34
[uncacheable]
trace  urn:iki:tutorial:sheet:cell:A3
  client      ikigai repl  ·  capability: root (full authority)
  transport   embedded · in-process
 
urn:iki:tutorial:sheet:cell:A3   sheet-cell · computed · ThreadId(1) · —   → 6b  1003.4
├─ urn:iki:tutorial:sheet:input:A3   sheet-input · cached · ThreadId(1) · —
├─ urn:iki:tutorial:sheet:precedents:A3   sheet-precedents · cached · ThreadId(1) · —
├─ urn:iki:tutorial:sheet:formula:A3   sheet-formula · cached · ThreadId(1) · —
├─ urn:iki:tutorial:sheet:cell:A1   sheet-cell · computed · ThreadId(1) · —
│  ├─ urn:iki:tutorial:sheet:input:A1   sheet-input · cached · ThreadId(1) · —
│  ├─ urn:iki:tutorial:sheet:precedents:A1   sheet-precedents · cached · ThreadId(1) · —
│  ├─ urn:iki:tutorial:sheet:formula:A1   sheet-formula · cached · ThreadId(1) · —
│  └─ urn:iki:tutorial:sheet:feed:acme   sheet-feed · computed · ThreadId(1) · —
└─ urn:iki:tutorial:sheet:cell:A2   sheet-cell · cached · ThreadId(1) · —

The feed was read and computed again, because the market wrote it. A1’s value read the feed, A3’s read A1’s, and A2’s was served. Nothing in the trace is about the market: the sheet sees a name whose thread was cut, and never who cut it.

A feed nobody has written to

A formula can name a feed before anything has been written to it. The feed answers NotFound, and the value is #N/A, the sheet’s word for “not available”:

=FEED(globex)
[uncacheable]
#N/A
[computed]
#N/A
[cached]
55
[uncacheable]
not cached
55
[computed]

#N/A was cached, and the first write to the feed cut it. When a request fails, the kernel records the failure as a dependency of whatever asked, so an answer built on a feed’s absence hangs from the feed’s thread, and the first write to the feed cuts it.

That has only been true all the way down since core 0.1.82, and the story of the gap is worth knowing. Before it, a failure was recorded under the thread of the name that was asked for, and nothing else. B1 asked for feed:globex itself, the atom, and the atom’s own thread is exactly what a write to it cuts, so B1 was safe. Had it asked for some composite that read the feed, and fallen back on that composite’s NotFound, the #N/A would have hung from the composite’s thread, which no write ever cuts, and stayed #N/A after the feed was written. The sheet was built around the gap: Part II’s evaluator reads a cell’s input before its compiled formula, never falling back on the formula’s NotFound. Building it is how the gap was found (ledger item 611), and 0.1.82 closed it: a failed request now carries the dependencies it recorded on its way to failing, so a fallback hangs from the atom’s thread however far down the NotFound came from.

The rule the sheet follows is still a good one, for a different reason: fall back on an atom’s own NotFound, because that is the one that says exactly what is missing. A composite’s NotFound may mean the atom is empty, or that it holds the wrong kind of thing (a cell holding a number has no compiled formula), and a fallback cannot tell which.

/// A feed nothing has written to is `#N/A`, cached — and the first write cuts it, because the
/// fallback caught the ATOM's own NotFound, so it hangs from the feed's thread.
#[test]
fn an_unwritten_feed_is_not_available_until_its_first_write() {
    let kernel = host(None);
    type_into(&kernel, "B1", "=FEED(globex)");
    assert_eq!(value(&kernel, "B1"), "#N/A");
    assert!(value_cached(&kernel, "B1"));
    sink(&kernel, "urn:iki:tutorial:sheet:feed:globex", "55").unwrap();
    assert!(!value_cached(&kernel, "B1"));
    assert_eq!(value(&kernel, "B1"), "55");
}

Bad data is a value

A feed can hold anything its writer sends, and one day that will not be a number. Say the exchange halts trading and the feed says so:

halted
[uncacheable]
#VALUE
[computed]
#VALUE
[cached]
error: conflict: the feed `acme` holds `halted`, which is not a price
100
[uncacheable]

A3 is #VALUE, and the second read is served from the cache. That is a choice, and the other choice is worse than it looks. Had the evaluator refused, the refusal would never have been cached: the kernel records every error except NotFound as already expired, since most errors are transient. So A3, and the grid that shows it, would be computed again on every read for as long as the feed said halted, which could be all afternoon. When the honest answer is “this input is wrong”, say it as a value, and it caches like any other.

The market itself does refuse, with a Conflict: it will not move a price that is not a price. That refusal is right where it is, because it answers a write, and a write is never cached. The last line puts the price back, for the page below.

Finding out: polling

The runnable cells above redraw the sheet at the bottom of the page after they run, as they did in earlier parts. That is the page reacting to something the page did. The market is different. Press Run the market below and two things start, both driven by the page’s timers and both paused until you press it: the market writes a new price to acme every three seconds, and the grid asks for itself again every two. Neither knows about the other. The market does not redraw the grid; it only writes the feed. The grid finds out the way anything that reads a name finds out about a write it did not make: it asks again.

Put =FEED(acme) somewhere, and something that reads it, and watch the count and the table under the grid. Most polls are answered entirely from the cache, and cost a lookup. The poll after each write computes exactly the values that read the feed, and serves the rest.

The sheet appears here when the in-page kernel has loaded.

The market moves writes once, by hand. The grid does not change until its next poll, which is the lesson rather than a bug: the button is somebody else, and the grid is not told.

The same thing as a test, with the market writing every third second and the grid polled every second:

/// The page's poll, as a test: the grid asked every two seconds while the market writes the
/// feed every three. A poll between writes is answered from the cache; a poll after one
/// computes the feed's dependents and nothing else.
#[test]
fn a_polled_grid_computes_only_after_a_write_and_only_what_it_cut() {
    let kernel = host(None);
    type_into(&kernel, "A1", "=FEED(acme)");
    type_into(&kernel, "A2", "10");
    type_into(&kernel, "A3", "=A1*A2");
    type_into(&kernel, "B1", "=SUM(C1:C6)");
    type_into(&kernel, "C1", "=6*7");
    let mut polls = Vec::new();
    for second in 0..12 {
        if second % 3 == 0 {
            sink(&kernel, "urn:iki:tutorial:sheet:tick:acme", "").unwrap();
        }
        if second % 2 == 0 {
            let was_cached = cached(&kernel, spreadsheet::VIEW_GRID);
            let recomputed = uncached(&kernel);
            source(&kernel, spreadsheet::VIEW_GRID).unwrap();
            polls.push((second, was_cached, recomputed));
        }
    }
    let wrote = ["A1".to_string(), "A3".to_string()];
    for (second, was_cached, recomputed) in &polls[1..] {
        // The market wrote since the last poll (at 3 and 9, for the polls at 4 and 10; at 6,
        // for the poll at 6 itself) exactly when the poll computed, and then only its cells.
        let wrote_since = [4, 6, 10].contains(second);
        assert_eq!(*was_cached, !wrote_since, "the poll at {second}s");
        if wrote_since {
            assert_eq!(recomputed, &wrote, "the poll at {second}s");
        } else {
            assert!(recomputed.is_empty(), "the poll at {second}s");
        }
    }
}

A poll answered from the cache is cheap in the page, and a host serving the sheet over HTTP makes it cheaper still. ikigai’s web edge gives a cached answer a strong ETag, and answers a poll that sends it back (If-None-Match) with 304 Not Modified and no body: the grid is not even sent again. That is the whole cost of polling a resource whose answer has not changed: a lookup, and a few bytes of headers. It is why polling is a reasonable first answer, and the time chapter’s clock, polled every second and computed once a minute, was the same argument.

Its cost is latency and waste at the edges. A write is seen up to one polling interval late, and every poll between writes is a request that learns nothing. For a sheet that must show a price the moment it moves, and for a thousand readers of one feed, the answer is the other direction: the kernel tells the page.

Next: push

This part stops at polling on purpose. Push needs pieces this book does not have yet, and it is worth saying exactly what they are, and which of them the kernel already provides.

From the kernel: somebody to tell. A cut moves a counter on, and by default tells nobody; that laziness is what makes an edit cost nothing until somebody reads (Part III). Push needs a host to be able to say “tell me when this thread is cut”, for an exact name or for a prefix like urn:iki:tutorial:sheet:feed:, and to be told, with the thread’s name, when it is. This is NetKernel’s golden-thread listener, and since core 0.1.82 the kernel has one, Kernel::listen, built to the constraints a kernel with no runtime puts on it. It cannot call anyone back on a task of its own, and a cut runs inside the write that caused it, so it calls nobody: it appends the cut to a queue the host drains when it chooses, and issues no request from inside a cut. Listening is an authority, so it is a capability, and a listener hears only the names its own reads were already given. And the queue is bounded: a cut that finds it full is counted as dropped, so a slow host learns that it missed something rather than acting on half a history.

From the host: what to redraw. Hearing that feed:acme was cut is not yet knowing that the grid changed, but the listener says that too: each cut comes with the cached answers it invalidated, the grid among them when the grid read the feed. So a host that hears a cut can push the views it has handed out that are on that list, or read them again straight away, so the recomputation is done before the reader asks: a recalculating golden thread, which NetKernel also had. The host half is Part VII: there the page registers a listener with its kernel and drains it.

From the page: a channel. In this book the host is the page, so being told is a function call. A sheet served by a real host needs a connection that the server can write to: server-sent events or a WebSocket, which htmx can already consume. The markup would change from “ask every two seconds” to “listen for grid”, and nothing else in the sheet would.

Time does not cut. A value that goes because its deadline passed was never cut, so a listener never hears about it. A NOW() cell pushed to a page needs the host to set a timer for the earliest deadline among the answers it has handed out; the kernel knows each deadline, and would have to say what it is.

And the same answer twice. A cut is by name, not by content, so the market writing the price the feed already holds cuts it anyway, and push would send a grid identical to the last. Stopping there needs the recomputed answer compared with the old one before anything downstream is told: early cutoff, which Part III noted the kernel does not do.

What was built

the setthe namewhat it is
a feedurn:iki:tutorial:sheet:feed:{name}code: the second atom, written from outside the sheet
the marketurn:iki:tutorial:sheet:tick:{name}code: the outside world, simulated; a Sink that reads the feed and writes the next price
FEED(name)in a formula; (feed name) compileda word for the compiler, and one read of a name for the evaluator
the market pageurn:iki:tutorial:sheet:template:marketa template: the sheet, a grid that polls, and the market’s controls

The sheet gained an atom and learned to read it, and nothing about recalculation changed. The market is the only new code that does anything, and it is not the sheet’s: in a real deployment it is somebody else’s system, writing a name. A cell’s value is still a plain name, cell:{ref}, and so are an input and a feed, which is what the next part needs. A personal scenario, my own numbers laid over the shared sheet’s, is a corridor in front of input:{ref}, and the sheet reads the names it always read.

Spreadsheet, VI: a sheet is a space, and spaces stack

Five parts have composed resources: a value is a function of other values, a view is a template over names, and the kernel keeps the dependencies. This part composes the context the names are read in. The same name, urn:iki:tutorial:sheet:cell:B2, answers one thing on the shared sheet, another in the sheet one person is playing with (“what if we sold twelve?”), and a third in the sheet as it stood last Tuesday. None of the three is a copy of the sheet, and not one line of the code that computes a value knows which of them it is computing in.

Each is a corridor: a space pushed in front of the shared names for the length of a request, answering a few of them its own way and letting every other name through. Tic-tac-toe used one per game (Part IV of that arc). This part builds two kinds, a personal one and a temporal one, and then stacks them.

The model, on paper

One atom changes, and two corridors are added:

the spaceits namewhat it answersbuilt from
the shared sheetthe rootevery name, as Parts I–V built itthe input, which now keeps every write it was given
a person’s scenariourn:iki:tutorial:sheet:scenario:{who}input:{ref} for the cells that person overrode, and nothing elsea store of overrides, behind a capability of their own
the sheet as of an instanturn:ctx:time:{instant}every input:{ref} as it stood then; now and today at that instantthe input’s history, and the time chapter’s clock
both at oncea stack of the twowhatever the innermost corridor answers, then the nextScope::stack

A corridor’s name is not a resource: nothing binds it, and nothing resolves it. It is the name the request’s chain carries, and the cache files every answer computed in the corridor under it.

A clock the story keeps

This part is about “as of last Tuesday”, so its page cannot use your clock: on your page, last Tuesday would be a date before anything here was typed, and every cell below would answer an empty sheet. So the page keeps a story clock. Its markup carries data-clock='2026-09-22T12:00Z', an instant where Part IV’s page carried a bare data-clock, and the page’s kernel is built with a clock stopped there. A cell that says when it runs (data-at) moves the clock before it runs, and says so beside its label:

/// Build this page's kernel with a STORY clock, stopped at `instant` (`YYYY-MM-DDTHH:MMZ`),
/// which the page moves when a cell says when it runs (`data-at`, [`set_story_clock`]).
///
/// For a chapter about time as context, where a cell reads the sheet as of last Tuesday: with
/// the browser's clock, "last Tuesday" would be a date before anything on the page was
/// typed, and every such cell would answer an empty sheet. With the story's, every reader's
/// page runs at the instants the chapter says, and answers what the chapter shows.
#[wasm_bindgen(js_name = useStoryClock)]
pub fn use_story_clock(instant: String) -> Result<(), JsValue> {
    before_the_kernel()?;
    let clock = Arc::new(ManualClock::at(story_millis(&instant)?));
    STORY.with(|story| *story.borrow_mut() = Some(Arc::clone(&clock)));
    CLOCK.with(|slot| *slot.borrow_mut() = Some(clock as Arc<dyn Clock>));
    Ok(())
}

/// Move the story clock to `instant`: what `js/run.js` does before a cell that carries
/// `data-at` runs. Refused on a page with no story clock.
#[wasm_bindgen(js_name = setStoryClock)]
pub fn set_story_clock(instant: String) -> Result<(), JsValue> {
    let millis = story_millis(&instant)?;
    STORY.with(|story| match story.borrow().as_ref() {
        Some(clock) => {
            clock.set(millis);
            Ok(())
        }
        None => Err(JsValue::from_str(
            "this page has no story clock: its markup does not carry data-clock='<instant>'",
        )),
    })
}

A week of the sheet

It is Tuesday, 22 September, at noon. A price, a quantity and their product; a cost, and the profit; and, in C1, the time:

100
[uncacheable]
10
[uncacheable]
=A1*A2
[uncacheable]
600
[uncacheable]
=A3-B1
[uncacheable]
=NOW()
[uncacheable]
400
[computed]

A week later, the price goes up:

110
[uncacheable]
500
[computed]

The sheet as it was

Since Part I the input has kept every write, each stamped with the kernel’s clock, and a clear is a write of nothing. Until now only the latest one mattered:

/// What has been typed into each cell, and when: every write, oldest first. The sheet's one
/// piece of state. A write is stamped with the kernel's clock (0 on a kernel with none), and a
/// clear is a write of nothing.
#[derive(Debug, Default)]
pub struct InputStore {
    inputs: Mutex<BTreeMap<CellRef, Vec<Write>>>,
}

/// One write to a cell: when, in milliseconds since 1970, and what was typed (`None` clears).
#[derive(Clone, Debug)]
struct Write {
    at: u64,
    typed: Option<String>,
}

That history is all the temporal view needs from the sheet. The rest is a corridor. Inside it, input:{ref} is answered from the history, as of the instant the corridor is pinned to:

/// `sheet-input-as-of`: what was typed into the cell `ref` at the instant the request's chain
/// is pinned to — the input read from the atom's history, not its latest write.
///
/// It is bound only inside a temporal corridor ([`as_of_doors`]), whose clock is that
/// instant, so [`Invocation::now`] is "then". The answer is cacheable and names no thread:
/// the kernel hangs it from its own name, `input:{ref}`, which every write to the cell cuts.
/// For an instant in the past that is more than it needs (a write now cannot change what the
/// cell held then), and never less.
///
/// The past is read-only: `Sink` and `Delete` are a `Conflict`, the state saying no.
pub fn input_as_of(store: Arc<InputStore>) -> FnEndpoint {
    FnEndpoint::new("sheet-input-as-of", move |inv: &Invocation<'_>| {
        let at = cell_binding(inv, "ref")?;
        let then = inv.now().ok_or_else(|| {
            Error::Endpoint(
                "an input as of an instant needs the instant: bind it in a temporal corridor \
                 (Scope::with_named_at), whose clock says when"
                    .to_string(),
            )
        })?;
        match inv.request.verb {
            Verb::Sink | Verb::Delete => Err(Error::Conflict(format!(
                "the sheet in `{}` is the sheet as it was, and the past does not change: \
                 write to the live sheet, or to a scenario",
                inv.scope()
            ))),
            _ => match store.as_of(at, then.as_millis()) {
                Some(typed) => Ok(typed_repr(typed).cacheable()),
                None => Err(Error::NotFound(format!(
                    "nothing had been typed into {at} by then"
                ))),
            },
        }
    })
    .with_description(
        Description::new("sheet-input-as-of")
            .title("Input, as of an instant")
            .summary(
                "What was typed into a cell at the instant this corridor is pinned to, from \
                 the input's history. Read-only.",
            )
            .verb(Verb::Source)
            .verb(Verb::Meta)
            .input(ref_input("the cell, A1 to D6"))
            .output(TEXT_PLAIN_UTF8)
            .output(FORMULA_TYPE),
    )
}

/// The sheet's doors for a temporal corridor: `input:{ref}` answered from the history in
/// `store` (the live sheet's own store) as of the corridor's instant. A host hands these, with
/// the time chapter's, to whatever builds its `as-of=` corridors. Nothing else in the sheet
/// changes: every other name reads the inputs, and in this corridor the inputs are "then".
pub fn as_of_doors(store: Arc<InputStore>) -> EndpointSpace {
    EndpointSpace::new().bind(template(INPUT), input_as_of(store))
}

A line of the REPL asks for one with as-of=: the engine builds a corridor named for the instant, urn:ctx:time:2026-09-22T18:00:00Z, over doors the host hands it, with a clock stopped at that instant. Which names the corridor rebinds is the host’s business, so the page hands the engine the sheet’s doors and the time chapter’s:

/// The doors a temporal corridor binds on this page, for every line that says `as-of=`: the
/// sheet's inputs as of the instant, from the page's own store, and the time chapter's `now`
/// and `today`, pinned. Which names a corridor rebinds is a fact about the host, so the host
/// hands them to the engine; the engine builds the corridor, named for its instant.
pub fn as_of_doors(inputs: Arc<InputStore>) -> Arc<dyn Space> {
    Arc::new(Fallback::new(vec![
        Arc::new(spreadsheet::as_of_doors(inputs)) as Arc<dyn Space>,
        Arc::new(time_resource::as_of_doors()),
    ]))
}

Here is the profit as it stood on Tuesday evening, twice, and at eleven that morning, before anything had been typed:

400
[computed]
400
[cached]
[computed]

The price was 100 on Tuesday, so the profit was 400. The formulas, the compiler, the evaluator and the views are the code Parts I–V wrote, and they read input:{ref} as they always did. In this corridor, that name means “then”. At eleven there was nothing to read, and an empty cell’s value is empty.

The answer is cached, and it hangs from the input’s golden thread, as every value does. For an instant in the past that is more than it needs: a write today cannot change what a cell held last Tuesday, but it cuts the thread, and the value as of Tuesday is computed again, the same. It is never less than it needs, which is the direction that matters.

And the past is read-only. A write in the corridor is a Conflict: the state says no. The sheet as it was is something to read, and a change belongs to the live sheet, or to a scenario.

Inside the corridor, there is one now

C1 holds =NOW(). Read it as of Tuesday evening:

2026-09-22T18:00Z
[computed]
2026-09-22T18:00Z
[cached]
2026-09-29T14:05Z
[cached]

NOW() reads the time chapter’s instant, which reads today and now. The corridor binds both, pinned to its instant, so every read of the time in the corridor gets the same one:

/// The time chapter's doors for a temporal corridor: `now` and `today` at the instant the
/// corridor is pinned to, and cacheable for as long as the corridor, since a "then" does not
/// change. (The live `now` and `today` would answer the same text, from the same pinned clock,
/// but with the next minute and the next midnight as deadlines, and those have already passed
/// by the kernel's own clock, so nothing built on them would ever be cached.)
///
/// ⚠ Bind these only in a corridor whose name is its instant (`Scope::with_named_at`). At the
/// root they would cache the first minute they were asked forever.
pub fn as_of_doors() -> EndpointSpace {
    EndpointSpace::new()
        .bind(
            template(NOW),
            pinned("time-now-then", |minute| {
                format!("{:02}:{:02}", (minute / 60) % 24, minute % 60)
            }),
        )
        .bind(
            template(TODAY),
            pinned("time-today-then", |minute| {
                date_text(minute.div_euclid(MINUTES_PER_DAY))
            }),
        )
}

/// A door that answers `text` of the corridor's minute, cacheable with no deadline.
fn pinned(id: &'static str, text: fn(i64) -> String) -> FnEndpoint {
    FnEndpoint::new(id, move |inv: &Invocation<'_>| {
        let minute = (clock(inv)?.as_millis() / MINUTE_MS) as i64;
        Ok(Representation::new(text_plain_utf8(), text(minute).into_bytes()).cacheable())
    })
    .with_description(
        Description::new(id)
            .title("The time, pinned")
            .summary("The time at the instant this corridor is pinned to; cacheable, since a then does not change.")
            .verb(Verb::Source)
            .verb(Verb::Meta)
            .output(TEXT_PLAIN_UTF8),
    )
}

Two things follow. The first is the answer to the torn reading Part IV guarded against by hand, the date read on one side of midnight and the time on the other: inside a temporal corridor there is only one instant to read, however many reads a composite makes. The second is that a “then” is cached. Live, NOW() is good until the next minute and computed again after it; as of Tuesday evening it is good for as long as the corridor, because Tuesday evening does not change. (The live value above says [cached] because the sheet at the bottom of the page read it after the last cell.)

A scenario: what if we sold twelve?

Alice wants to know what twelve units would make, without changing the sheet everybody else reads. Her scenario is a corridor too, and what it holds is a store of the cells she overrode:

/// The cells one person has overridden, and what with: a scenario's one piece of state.
#[derive(Debug, Default)]
pub struct OverrideStore {
    overrides: Mutex<BTreeMap<CellRef, String>>,
}

As a space, it answers input:{ref} for the cells in that store, and misses for every other read, so the resolution falls through to whatever is outside the corridor. A write is always the scenario’s: overriding a cell nobody has overridden is how an override starts, and it must never reach the shared sheet:

/// A scenario, as a space: `input:{ref}` for the cells the person overrode, and a miss for
/// every other read, so the resolution falls through to whatever is outside the corridor (the
/// shared sheet, or the sheet as of an instant). A write is always the scenario's: overriding
/// a cell nobody overrode yet is how an override starts, and it must never reach the shared
/// sheet.
pub struct Scenario {
    store: Arc<OverrideStore>,
    doors: EndpointSpace,
}

impl Space for Scenario {
    fn resolve(&self, request: &Request, scope: &Scope) -> Resolution {
        let reads = matches!(request.verb, Verb::Source | Verb::Exists | Verb::Meta);
        let overridden = request
            .target
            .as_str()
            .strip_prefix("urn:iki:tutorial:sheet:input:")
            .and_then(CellRef::parse)
            .is_some_and(|cell| self.store.holds(cell));
        if reads && !overridden {
            return Resolution::Miss;
        }
        self.doors.resolve(request, scope)
    }

    fn entries(&self) -> Option<Vec<SpaceEntry>> {
        self.doors.entries()
    }

    fn topology(&self) -> Topology {
        self.doors.topology()
    }
}

/// `who`'s scenario: a corridor named for them, binding their overrides in `store`.
pub fn scenario(who: &str, store: Arc<OverrideStore>) -> Result<Scope> {
    let name = scenario_name(who)?;
    let doors = EndpointSpace::new().bind(template(INPUT), override_input(who, Arc::clone(&store)));
    Ok(Scope::empty().with_named(name, Arc::new(Scenario { store, doors })))
}

A cell with data-scenario='alice' runs in her scenario, and says so beside its label. Like a game, the scenario is markup, not something a line can choose: whoever may push a corridor chooses what the names in it mean.

12
[uncacheable]
720
[computed]

110 × 12 − 600. On the shared sheet, nothing moved:

500
[computed]
10
[cached]

Still 500, and A2 is still 10. But look at the verdict: the shared B2 was computed, not served, although nothing on the shared sheet changed. Alice’s write cut it. A golden thread is a name, input:A2, and a write in her corridor cuts that name’s thread for everybody: for her values, which needed it, and for the shared sheet’s, which did not. The shared value is computed again and comes out the same, so nothing is ever wrong. It is only work, and the next section says what it costs.

One name, a cache entry in each space

Every answer is filed under the chain it was computed in, so cell:B2 on the shared sheet and cell:B2 in Alice’s scenario are two entries. A write to the shared sheet cuts both, because both read the shared B1:

650
[uncacheable]
not cached
not cached
670
[computed]

Alice’s value followed the shared cost without her doing anything: her scenario holds the one cell she changed, and reads every other one from the sheet everybody reads. It is a corridor, not a copy.

A write in her scenario should cut only her own values. It does not, yet:

15
[uncacheable]
1000
[computed]
not cached
450
[computed]

This is the one thing a personal space should buy that the kernel does not deliver yet. It is sound: the kernel only ever computes too much, never serves a wrong answer. But the waste grows with the number of people. A hundred people with scenarios over one sheet, each editing, would each cut the shared values, and every other scenario’s, for every name they touch. The fix is for a thread to know which corridor answered the read it hangs from, so that a write in Alice’s corridor cuts only what her corridor answered (ledger item 581). The crate’s tests pin the behavior as it is, so the day it changes, a test fails and this section is rewritten.

Spaces stack

Alice’s scenario and the sheet as of Tuesday are two corridors. A request can carry both, and then the order matters, because the innermost corridor is asked first. The page’s engine for a scenario cell holds her chain, and a line’s as-of= brings another; the page stacks the line’s inside hers (Scope::stack, core 0.1.82):

    /// This chain with the line's inside it: the line's corridor innermost, answering first.
    fn with_line(&self, line: &Scope) -> Scope {
        self.scope.clone().stack(line)
    }

So a line that says as-of= in her scenario is Tuesday’s sheet, whole. The temporal corridor answers every input, hers included, before her scenario is asked:

400
[computed]

The other order puts her scenario inside Tuesday: her overrides first, and Tuesday’s sheet for everything else. That is “what would fifteen units have made last week?”, and the page builds it for a cell that says both data-scenario='alice' and data-as-of:

    /// Chain `spec`, made on first use, handed to `with`. A spec is what the page's markup
    /// says: `scenario:alice` is alice's scenario over the shared sheet;
    /// `scenario:alice@2026-09-22T18:00Z` is her scenario inside the sheet as of that instant,
    /// so her overrides win over the past; anything else is a tic-tac-toe game's id.
    fn chain<T>(&self, spec: &str, with: impl FnOnce(&Chain) -> T) -> Result<T, String> {
        self.build(spec)?;
        Ok(with(self.chains.borrow().get(spec).expect("built above")))
    }

    /// Make chain `spec`, unless the page has it already.
    fn build(&self, spec: &str) -> Result<(), String> {
        if self.chains.borrow().contains_key(spec) {
            return Ok(());
        }
        let scope = match spec.strip_prefix("scenario:") {
            // One person, one corridor: the scenario over an instant stacks the SAME scope
            // (the same store, under the same name) as the scenario alone, since a name is a
            // claim that its doors are one set.
            Some(rest) => match rest.split_once('@') {
                Some((who, at)) => {
                    let alone = format!("scenario:{who}");
                    self.build(&alone)?;
                    let alone = self.chains.borrow()[&alone].scope.clone();
                    self.as_of(at)?.stack(&alone)
                }
                None => spreadsheet::scenario(rest, Arc::default()).map_err(|e| e.to_string())?,
            },
            None => {
                let store = Arc::new(tic_tac_toe::stored_space(Arc::default()));
                tic_tac_toe::game(spec, store).map_err(|e| e.to_string())?
            }
        };
        let chain = Chain {
            engine: Rc::new(
                Engine::new(InChain::new(Arc::clone(&self.kernel), scope.clone()))
                    .with_as_of_doors(Arc::clone(&self.doors)),
            ),
            scope,
        };
        self.chains.borrow_mut().insert(spec.to_string(), chain);
        Ok(())
    }

    /// The sheet as of `instant` (`YYYY-MM-DDTHH:MMZ`): the corridor a line's `as-of=` builds,
    /// named the way the engine names it, over the same doors.
    fn as_of(&self, instant: &str) -> Result<Scope, String> {
        let millis =
            story_millis(instant).map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))?;
        let canonical = format!("{}:00Z", instant.trim_end_matches('Z'));
        let name = Iri::parse(format!("urn:ctx:time:{canonical}")).map_err(|e| e.to_string())?;
        Ok(Scope::empty().with_named_at(
            name,
            Arc::clone(&self.doors),
            Arc::new(FixedClock::at(millis)),
        ))
    }
900
[computed]
95
[uncacheable]
825
[computed]

Tuesday’s price and cost, 100 and 600, and her fifteen units: 900. And a write in this chain goes to her, since her corridor is the innermost: a price of 95 is her override now, and last Tuesday’s sheet is untouched. In the first order, the same write would have reached Tuesday’s corridor first, and been refused, because the past is read-only. The crate’s test says all of this at once:

#[test]
fn stacked_corridors_answer_in_order_innermost_first() {
    let host = Host::at("2026-09-22T12:00Z");
    let root = the_week(&host);
    let alice = spreadsheet::scenario("alice", Arc::new(OverrideStore::default())).unwrap();
    host.type_into(&alice, "A2", "15").unwrap();
    let tuesday = host.as_of("2026-09-22T18:00Z");

    // The scenario innermost: alice's override over Tuesday's sheet, 100 × 15 − 600.
    let what_if_then = tuesday.clone().stack(&alice);
    assert_eq!(host.value(&what_if_then, "B2"), "900");
    // The instant innermost: its doors answer every input, alice's included. Tuesday's sheet.
    let then_in_alice = alice.clone().stack(&tuesday);
    assert_eq!(host.value(&then_in_alice, "B2"), "400");
    // Two orders, two chains, two cache entries.
    assert_ne!(what_if_then.fingerprint(), then_in_alice.fingerprint());
    // And writes go where the innermost corridor says: to alice, or refused by the past.
    assert_eq!(host.type_into(&what_if_then, "A1", "95").unwrap(), "95");
    assert_eq!(host.value(&what_if_then, "B2"), "825");
    assert!(matches!(
        host.type_into(&then_in_alice, "A1", "95"),
        Err(Error::Conflict(_))
    ));
    assert_eq!(
        host.value(&root, "B2"),
        "500",
        "and the shared sheet never moved"
    );
}

Neither corridor knows the other exists. The scenario was written for the shared sheet, and the temporal view for the live one; stacked, each answers what it binds and misses the rest, and the kernel asks them in order.

Who may read a scenario

Alice’s overrides are hers. The endpoint that answers them declares a capability named for her, and the kernel enforces what an endpoint declares before it runs it:

/// The name a person's scenario corridor is injected under: `urn:iki:tutorial:sheet:scenario:`
/// and who it is. Not a resource: nothing binds it, and the cache files every answer computed
/// in the scenario under it.
pub const SCENARIO: &str = "urn:iki:tutorial:sheet:scenario:{who}";

/// The capability that reads and writes `who`'s overrides.
pub const SCENARIO_CAP: &str = "urn:cap:iki:tutorial:sheet:scenario:{who}";

Narrow this cell’s capability to Bob’s, and read her profit in her scenario:

narrowed — capability: urn:cap:iki:tutorial:sheet:scenario:bob
error: denied: capability does not grant `urn:cap:iki:tutorial:sheet:scenario:alice` (declared by `urn:iki:tutorial:sheet:input:A1`)
650
[computed]
reset to identity — capability: root (full authority)

Bob can stand in Alice’s corridor, and the door is still locked. B2 reads her price, which is an override, so it is refused; B1 she never overrode, so it is the shared sheet’s, and needs nothing. Two authorities are at work, and they are different ones. Who may create a scenario, or put a reader in one, is whoever may push a corridor: the host, here the page. Who may read one is whoever holds its capability. A corridor decides what a name means; a capability decides who may hear the answer.

The shared sheet

The sheet everybody reads, at the story’s current instant. Its edits land on the shared sheet, and Alice’s scenario reads them through.

The sheet appears here when the in-page kernel has loaded.

What was built

the setthe namewhat it is
the input’s historyurn:iki:tutorial:sheet:input:{ref}code: the same atom, every write it kept, read for the first time
an input as of an instantinput:{ref} in urn:ctx:time:{instant}code: the history, read at the corridor’s clock; writes refused
the time, pinnedurn:iki:tutorial:time:now, …:today in urn:ctx:time:{instant}code: the corridor’s clock, as text, cacheable for as long as the corridor
a person’s overrideinput:{ref} in urn:iki:tutorial:sheet:scenario:{who}code: another atom, a store per person, behind their capability
a scenariourn:iki:tutorial:sheet:scenario:{who}a corridor: a space that misses on every read it does not hold
a scenario over an instanta stack of the twoScope::stack: no code

Not one line in the formulas, the compiler, the evaluator or the views changed, and none of them knows about people or time. The whole of what this part added is one atom’s worth of state (a store of overrides), a first reading of the input’s history, three small doors, and the arrangement: which corridors a request carries, and in what order. That arrangement is where the new behavior came from: a “what if” that costs one cell, a past that caches, a single now, and a capability that locks a person’s cells without locking the sheet. The one thing it promised and could not deliver, a personal edit that leaves everybody else’s cache alone, is a kernel change, and it has a name.

Spreadsheet, VII: push

Part V ended with a page that found out about a write it did not make the only way a reader of a name can: it asked again, every two seconds, whether anything had changed or not. It also listed what the other direction needs, the kernel telling the page. There were four pieces, and a caveat. Somebody to tell: the kernel has had that since core 0.1.82, Kernel::listen. What to redraw: the host’s business. A channel: in this book the host is the page, so being told is a function call. And a timer, because time does not cut. (The caveat, the same answer twice, is early cutoff, a cost this part notes, not a piece it builds.)

This part builds the host’s half, in the page. It needs no server and no new core, and the sheet gains one template, the page that asks each cell to listen, and nothing else: not a line of its formulas, its evaluator or its views changes.

The model, on paper

the setits namewhat it is
a cut listenerurn:iki:tutorial:push:listener:{name}Sink a prefix to listen for cuts of every thread under it; Source takes what it has heard; Delete stops it. The state is the kernel’s queue
what a read carriesurn:iki:tutorial:push:carriesthe golden threads an answer hangs from, and when it expires: a question about another read, answered by making it
the views drawnnot a name: the page’s own memoryfor each view the page drew, what its answer carried
the listening pageurn:iki:tutorial:sheet:template:pushthe sheet with each cell a view of its own, drawn again when the page says it is stale
/// A cut listener, by name: `Sink` a prefix to listen, `Source` to take what it heard,
/// `Delete` to stop.
pub const LISTENER: &str = "urn:iki:tutorial:push:listener:{name}";

/// What a read of `target` carries: its golden threads and its deadline.
pub const CARRIES: &str = "urn:iki:tutorial:push:carries";

A listener is a resource so that it can be shown in a cell, and so that listening is checked the way everything else is: by the kernel, against what the resource declares. The page’s own push uses the same listener, and waits on it with a function call rather than reading it.

Listening is an authority

A listener learns the name of every thread cut under its prefix, and a thread is named after the resource that was written. That is information, so listening needs a capability, urn:cap:kernel:listen, and the listener declares it on every verb:

/// `push-listener`: a cut listener, as a resource.
///
/// * `Sink` registers a listener named `{name}` for every thread under the prefixes in
///   `content` (one per line), under the request's own capability, replacing any listener of
///   that name. The kernel decides what it may hear: under the root authority, every cut
///   there; under any other, only a cut of a name some read made under that same capability
///   rests on.
/// * `Source` takes what it has heard since it was last read ([`render`]). Reading takes: a
///   second read answers only what came after the first. Never cached.
/// * `Delete` stops it.
pub fn listener(listening: Arc<Listening>) -> FnEndpoint {
    FnEndpoint::new("push-listener", move |inv: &Invocation<'_>| {
        let name = inv
            .bindings
            .get("name")
            .ok_or_else(|| Error::MissingArgument("name".to_string()))?
            .to_string();
        match inv.request.verb {
            Verb::Sink => {
                let prefixes: Vec<&str> = inv
                    .inline_str("content")?
                    .lines()
                    .map(str::trim)
                    .filter(|line| !line.is_empty())
                    .collect();
                if prefixes.is_empty() {
                    return Err(Error::InvalidArgument {
                        name: "content".to_string(),
                        detail: "no prefix: say which threads to listen for, e.g. \
                                 urn:iki:tutorial:sheet:"
                            .to_string(),
                    });
                }
                let spec = prefixes
                    .iter()
                    .fold(ListenSpec::new(), |spec, prefix| spec.prefix(*prefix));
                let registered = listening.kernel()?.listen(spec, inv.capability)?;
                listening.lock().insert(name, Arc::new(registered));
                let hears = if inv.capability.is_root() {
                    "as root: every cut there"
                } else {
                    "under this capability: a cut there only when a read made under it rests on it"
                };
                Ok(text(format!(
                    "listening for cuts under {}, {hears}\n",
                    prefixes.join(" and ")
                )))
            }
            Verb::Delete => match listening.lock().remove(&name) {
                Some(_) => Ok(text(format!("stopped listening as `{name}`\n"))),
                None => Err(Error::NotFound(format!("nothing is listening as `{name}`"))),
            },
            _ => match listening.listener(&name) {
                Some(heard) => Ok(text(render(&heard.drain()))),
                None => Err(Error::NotFound(format!(
                    "nothing is listening as `{name}`: sink a prefix to it first"
                ))),
            },
        }
    })
    .with_description(
        Description::new("push-listener")
            .title("Cut listener")
            .summary(
                "A golden-thread listener, as a resource: sink a prefix to listen, source to \
                 take what it heard, delete to stop.",
            )
            .verb(Verb::Meta)
            .action(
                ActionSpec::new(Verb::Source)
                    .summary("take the cuts heard since the last read; never cached")
                    .input(name_input())
                    .requires(CAP_LISTEN)
                    .output(TEXT_PLAIN_UTF8),
            )
            .action(
                ActionSpec::new(Verb::Sink)
                    .summary("listen for cuts of every thread under these prefixes")
                    .input(name_input())
                    .input(
                        ArgSpec::new("content")
                            .summary("the prefixes to listen under, one per line")
                            .class(XSD_STRING),
                    )
                    .requires(CAP_LISTEN)
                    .output(TEXT_PLAIN_UTF8),
            )
            .action(
                ActionSpec::new(Verb::Delete)
                    .summary("stop listening")
                    .input(name_input())
                    .requires(CAP_LISTEN)
                    .output(TEXT_PLAIN_UTF8),
            ),
    )
}

Narrow this cell’s capability to Bob’s, from Part VI, and ask to listen. Then ask again with the page’s own:

narrowed — capability: urn:cap:iki:tutorial:sheet:scenario:bob
error: denied: capability does not grant `urn:cap:kernel:listen` (declared by `urn:iki:tutorial:push:listener:mine`)
reset to identity — capability: root (full authority)
listening for cuts under urn:iki:tutorial:sheet:, as root: every cut there
[uncacheable]
heard nothing
[uncacheable]

The refusal is the kernel’s, made before the listener’s code ran, because the listener declares what it needs. Kernel::listen refuses too, in case something registers a listener without going through the resource. And the refusal is an answer: a page whose reader may not listen says so, in words, and keeps working, as the sheet at the bottom of this page does if its registration is refused.

The listener holds the registration and nothing else. The queue is the kernel’s:

/// The host's registrations: each listener it holds, by the name it was registered under, and
/// a handle on the kernel that registers them.
///
/// The handle is attached after the kernel is built ([`Listening::attach`]), because the kernel
/// is built over the space that binds the listener: the two would otherwise need each other
/// first. It is weak, so the space does not keep its own kernel alive.
#[derive(Default)]
pub struct Listening {
    kernel: OnceLock<Weak<Kernel>>,
    listeners: Mutex<BTreeMap<String, Arc<CutListener>>>,
}

impl Listening {
    /// Give the listener endpoint the kernel it registers with. Once: a second call is ignored.
    pub fn attach(&self, kernel: &Arc<Kernel>) {
        let _ = self.kernel.set(Arc::downgrade(kernel));
    }

    /// The listener registered as `name`, for a host that awaits it ([`CutListener::wait`])
    /// rather than reading the resource.
    pub fn listener(&self, name: &str) -> Option<Arc<CutListener>> {
        self.lock().get(name).cloned()
    }

    fn lock(&self) -> std::sync::MutexGuard<'_, BTreeMap<String, Arc<CutListener>>> {
        self.listeners.lock().expect("the listeners' lock")
    }

    fn kernel(&self) -> Result<Arc<Kernel>> {
        self.kernel
            .get()
            .and_then(Weak::upgrade)
            .ok_or_else(|| Error::Endpoint("this host has not attached its kernel".to_string()))
    }
}

A write, heard

Part V’s holding again: a price in a feed, a quantity, their value, and some arithmetic that has nothing to do with them. Then ask the listener what it heard:

100
[uncacheable]
=FEED(acme)
[uncacheable]
10
[uncacheable]
=A1*A2
[uncacheable]
=6*7
[uncacheable]
1000
[computed]
heard 5 cuts
cut urn:iki:tutorial:sheet:feed:acme
cut urn:iki:tutorial:sheet:input:A1
  invalidated urn:iki:tutorial:sheet:cell:A1
  invalidated urn:iki:tutorial:sheet:view:cell:A1
cut urn:iki:tutorial:sheet:input:A2
  invalidated urn:iki:tutorial:sheet:cell:A2
  invalidated urn:iki:tutorial:sheet:view:cell:A2
cut urn:iki:tutorial:sheet:input:A3
  invalidated urn:iki:tutorial:sheet:cell:A3
  invalidated urn:iki:tutorial:sheet:view:cell:A3
cut urn:iki:tutorial:sheet:input:C1
  invalidated urn:iki:tutorial:sheet:cell:C1
  invalidated urn:iki:tutorial:sheet:view:cell:C1
[uncacheable]

Five writes, five cuts, each with the cached answers it invalidated. The views are the sheet’s at the bottom of this page, which drew all twenty-four of its cells, empty, when the page loaded, and four of those drawings just went out of date. The first cut invalidated nothing: nothing had read the feed yet.

Reading the listener took what it had heard. Now the price moves, and nothing else happens:

101.5
[uncacheable]
heard 1 cut
cut urn:iki:tutorial:sheet:feed:acme
  invalidated urn:iki:tutorial:sheet:cell:A1
  invalidated urn:iki:tutorial:sheet:cell:A3
  invalidated urn:iki:tutorial:sheet:feed:acme
[uncacheable]

One write, one cut, and not one poll. The kernel’s list names the feed and the two values that read it, the ones the previous cell computed. It does not name the views of A1 and A3 that the sheet below is showing, and that is not an omission. The list is the kernel’s account of its cache: what was stored and is now invalid. Those two views were already invalid, cut by the previous cell’s writes, and nobody has drawn them since, because the sheet below is not pushing. They are still on the screen. A page cannot learn what its screen shows from the kernel’s cache, so it keeps its own account.

From a cut to the views

What the page needs to know about each view it drew is what that view’s answer rested on, and the answer already says. The kernel puts on every answer the golden threads of everything read while computing it, however far down, so a view carries the thread of every value, input, formula and feed under it. Ask what a read carries:

/// `push-carries`: what a read of `target` carries. Makes the read, and answers its deadline
/// and its threads, one per line. Never cached: it is a question about a read made now.
pub fn carries() -> AsyncFnEndpoint {
    AsyncFnEndpoint::new("push-carries", |inv: &Invocation<'_>| -> InvokeFuture<'_> {
        Box::pin(async move {
            let named = inv.inline_str("target")?.trim();
            let target = Iri::parse(named).map_err(|e| Error::InvalidArgument {
                name: "target".to_string(),
                detail: format!("`{named}` is not a name: {e}"),
            })?;
            let carried = Carried::of(&inv.source(&target).await?);
            let mut out = format!(
                "{named}\nexpires {}\nhangs from {} threads\n",
                expiry_text(carried.expiry),
                carried.threads.len()
            );
            for thread in &carried.threads {
                out.push_str(&format!("  {thread}\n"));
            }
            Ok(text(out))
        })
    })
    .with_description(
        Description::new("push-carries")
            .title("What a read carries")
            .summary("Read a resource, and answer the golden threads and the deadline it carries.")
            .verb(Verb::Source)
            .verb(Verb::Meta)
            .input(
                ArgSpec::new("target")
                    .summary("the name to read")
                    .class(XSD_ANY_URI),
            )
            .output(TEXT_PLAIN_UTF8),
    )
}
urn:iki:tutorial:sheet:view:cell:A3
expires never: only a cut changes it
hangs from 17 threads
  urn:iki:tutorial:sheet:cell:A1
  urn:iki:tutorial:sheet:cell:A2
  urn:iki:tutorial:sheet:cell:A3
  urn:iki:tutorial:sheet:compile
  urn:iki:tutorial:sheet:feed:acme
  urn:iki:tutorial:sheet:formula:A1
  urn:iki:tutorial:sheet:formula:A3
  urn:iki:tutorial:sheet:input:A1
  urn:iki:tutorial:sheet:input:A2
  urn:iki:tutorial:sheet:input:A3
  urn:iki:tutorial:sheet:precedents:A1
  urn:iki:tutorial:sheet:precedents:A3
  urn:iki:tutorial:sheet:refs:A1
  urn:iki:tutorial:sheet:refs:A2
  urn:iki:tutorial:sheet:refs:A3
  urn:iki:tutorial:sheet:template:cell
  urn:iki:tutorial:sheet:view:cell:A3
[uncacheable]
urn:iki:tutorial:sheet:view:cell:C1
expires never: only a cut changes it
hangs from 8 threads
  urn:iki:tutorial:sheet:cell:C1
  urn:iki:tutorial:sheet:compile
  urn:iki:tutorial:sheet:formula:C1
  urn:iki:tutorial:sheet:input:C1
  urn:iki:tutorial:sheet:precedents:C1
  urn:iki:tutorial:sheet:refs:C1
  urn:iki:tutorial:sheet:template:cell
  urn:iki:tutorial:sheet:view:cell:C1
[uncacheable]

A3’s view hangs from the feed, three names down; C1’s does not. That is the whole rule for push: a cut of thread T makes stale exactly the views whose answers carried T. The page remembers, for each view it draws, what the answer carried, and nothing else:

/// The host's memory of the views it has handed out: for each view's name, what its last
/// answer carried. One per page here; one per session on a server.
#[derive(Debug, Default)]
pub struct Drawn {
    views: BTreeMap<String, Carried>,
}

impl Drawn {
    /// The host drew `view` from `answer`: remember what the answer carried, replacing
    /// whatever the view's last answer did.
    pub fn drew(&mut self, view: impl Into<String>, answer: &Representation) {
        self.views.insert(view.into(), Carried::of(answer));
    }

    /// The views a batch of cuts makes stale: each view whose threads include a thread that
    /// was cut. No dependents index: the answer already named everything it rests on. A batch
    /// that DROPPED cuts makes every view stale, since what was missed is unknown.
    pub fn stale(&self, batch: &CutBatch) -> Vec<String> {
        if batch.dropped > 0 {
            return self.views.keys().cloned().collect();
        }
        self.views
            .iter()
            .filter(|(_, carried)| {
                batch
                    .events
                    .iter()
                    .any(|event| carried.threads.contains(event.thread.as_str()))
            })
            .map(|(view, _)| view.clone())
            .collect()
    }

    /// The earliest deadline among the views, in milliseconds since 1970: when to set the one
    /// timer. A cut is announced; an expiry is not, so the host has to know when to look.
    pub fn deadline(&self) -> Option<u64> {
        self.views.values().filter_map(Carried::deadline).min()
    }

    /// The views whose deadline is at or before `now` (milliseconds since 1970).
    pub fn expired(&self, now: u64) -> Vec<String> {
        self.views
            .iter()
            .filter(|(_, carried)| carried.deadline().is_some_and(|at| at <= now))
            .map(|(view, _)| view.clone())
            .collect()
    }

    /// What the view's last answer carried, if the host has drawn it.
    pub fn carried(&self, view: &str) -> Option<&Carried> {
        self.views.get(view)
    }

    /// How many views the host remembers.
    pub fn len(&self) -> usize {
        self.views.len()
    }

    /// Whether the host remembers none.
    pub fn is_empty(&self) -> bool {
        self.views.is_empty()
    }
}

There is no index of dependents anywhere, in the page or in the kernel. The dependency graph was recorded when the view was computed, and handed to the page on the answer. The same thing as a test, with every one of the sheet’s twenty-four views drawn:

#[test]
fn a_feed_write_is_one_cut_and_makes_exactly_its_readers_stale() {
    let clock = Arc::new(ManualClock::at(millis_at("2026-09-29T14:05Z", 20)));
    let kernel = host(clock);
    the_market(&kernel);
    let mut drawn = Drawn::default();
    draw_all(&kernel, &mut drawn);
    assert_eq!(drawn.len(), 24);

    let listener = kernel
        .listen(ListenSpec::new().prefix(PREFIX), &Capability::root())
        .expect("root may listen");
    sink(&kernel, &format!("{PREFIX}feed:acme"), "101.5").expect("the price moves");

    let batch = listener.drain();
    assert_eq!(batch.dropped, 0);
    assert_eq!(batch.events.len(), 1, "one write, one cut: {batch:?}");
    assert_eq!(
        batch.events[0].thread.as_str(),
        "urn:iki:tutorial:sheet:feed:acme"
    );
    // A1 reads the feed and A3 reads A1: two of the twenty-four views, and no others.
    assert_eq!(drawn.stale(&batch), [view("A1"), view("A3")]);
}

Two of twenty-four. A poll would have asked all twenty-four, and been told twenty-two times that nothing had changed.

Time does not cut

NOW() changes every minute, and nobody writes anything when it does. The kernel lets the answer go at its deadline, and a listener hears nothing, because there was no cut. So the page reads each view’s deadline off its answer too, and keeps one timer, at the earliest. The page’s kernel has the browser’s clock (the sheet carries data-clock), so the outputs below are from a test that stopped a clock at 14:05:20 UTC; your deadline is the next minute of your day.

=NOW()
[uncacheable]
urn:iki:tutorial:sheet:view:cell:D1
expires at 2026-09-29T14:06Z
hangs from 11 threads
  urn:iki:tutorial:sheet:cell:D1
  urn:iki:tutorial:sheet:compile
  urn:iki:tutorial:sheet:formula:D1
  urn:iki:tutorial:sheet:input:D1
  urn:iki:tutorial:sheet:precedents:D1
  urn:iki:tutorial:sheet:refs:D1
  urn:iki:tutorial:sheet:template:cell
  urn:iki:tutorial:sheet:view:cell:D1
  urn:iki:tutorial:time:instant
  urn:iki:tutorial:time:now
  urn:iki:tutorial:time:today
[uncacheable]
heard 1 cut
cut urn:iki:tutorial:sheet:input:D1
  invalidated urn:iki:tutorial:sheet:cell:D1
  invalidated urn:iki:tutorial:sheet:view:cell:D1
[uncacheable]

The view is good until 14:06, and the only cut was the write. Wait for the minute to turn, then:

heard nothing
[uncacheable]
not cached

The view is gone from the cache, and the listener heard nothing. A page that only listened would show 14:05 forever. The timer is what draws it again, and only it, since no other view has a deadline:

#[test]
fn time_does_not_cut_so_the_host_keeps_a_deadline() {
    let clock = Arc::new(ManualClock::at(millis_at("2026-09-29T14:05Z", 20)));
    let kernel = host(Arc::clone(&clock));
    the_market(&kernel);
    type_into(&kernel, "D1", "=NOW()");
    let mut drawn = Drawn::default();
    draw_all(&kernel, &mut drawn);
    let listener = kernel
        .listen(ListenSpec::new().prefix(PREFIX), &Capability::root())
        .expect("root may listen");

    // One view has a deadline, the next minute; every other one lasts until a cut.
    assert_eq!(drawn.deadline(), Some(millis_at("2026-09-29T14:06Z", 0)));
    assert!(drawn.expired(millis_at("2026-09-29T14:05Z", 59)).is_empty());

    clock.set(millis_at("2026-09-29T14:06Z", 0));
    assert!(
        listener.drain().is_empty(),
        "the minute turned, and nobody cut anything"
    );
    assert_eq!(
        drawn.expired(millis_at("2026-09-29T14:06Z", 0)),
        [view("D1")]
    );

    // Drawn again, the view carries the next deadline.
    draw(&kernel, &mut drawn, &view("D1"));
    assert_eq!(drawn.deadline(), Some(millis_at("2026-09-29T14:07Z", 0)));
}

So push has two triggers, and the page needs both: a cut, announced by the kernel, and an expiry, which the page has to know about in advance, because nobody will announce it.

A scenario’s write is heard by everybody

Part VI admitted a limit: a golden thread is a name, not a name in a space, so Alice’s override in her scenario cuts the shared sheet’s values too (ledger item 581). They are computed again and come out the same, so nothing is ever wrong, only more work than necessary. With push, the work becomes visible. Alice sells twelve:

12
[uncacheable]
1218
[computed]
heard 1 cut
cut urn:iki:tutorial:sheet:input:A2
  invalidated urn:iki:tutorial:sheet:cell:A2
  invalidated urn:iki:tutorial:sheet:cell:A3
  invalidated urn:iki:tutorial:sheet:input:A2
  invalidated urn:iki:tutorial:sheet:precedents:A3
  invalidated urn:iki:tutorial:sheet:refs:A2
  invalidated urn:iki:tutorial:sheet:view:cell:A3
[uncacheable]

Every entry on that list is the shared sheet’s: Alice’s scenario had read nothing before her write. The shared view of A3 is among them, so a page showing the shared sheet with push on draws A2 and A3 again, to show the values they already showed. The test says it plainly:

#[test]
fn a_scenario_edit_is_heard_by_the_shared_sheet_too() {
    let clock = Arc::new(ManualClock::at(millis_at("2026-09-29T14:05Z", 20)));
    let kernel = host(clock);
    the_market(&kernel);
    let mut drawn = Drawn::default();
    draw_all(&kernel, &mut drawn);
    let listener = kernel
        .listen(ListenSpec::new().prefix(PREFIX), &Capability::root())
        .expect("root may listen");

    // Alice overrides A2 in her scenario. The shared sheet does not change...
    let alice = spreadsheet::scenario("alice", Arc::default()).expect("a scenario");
    let write = request(Verb::Sink, &format!("{PREFIX}input:A2"))
        .with_arg("content", ArgRef::Inline(b"12".to_vec()));
    block_on(kernel.issue_in(write, &Capability::root(), alice)).expect("her override");

    // ...but a thread is a name, not a name in a space (ledger item 581): the shared views
    // that read A2 are stale too, and are drawn again to show the same values.
    let batch = listener.drain();
    let cut: Vec<&str> = batch.events.iter().map(|e| e.thread.as_str()).collect();
    assert_eq!(cut, ["urn:iki:tutorial:sheet:input:A2"]);
    assert_eq!(drawn.stale(&batch), [view("A2"), view("A3")]);
    for cell in ["A2", "A3"] {
        let before = source_as(&kernel, &Capability::root(), &view(cell)).unwrap();
        draw(&kernel, &mut drawn, &view(cell));
        assert_eq!(
            source_as(&kernel, &Capability::root(), &view(cell)).unwrap(),
            before
        );
    }
}

Polling hides this, because a poll of an unchanged value is a cache lookup either way. Push turns it into traffic: a redraw sent to every reader of the shared sheet, for a change none of them can see. A hundred people with scenarios would each send the shared sheet’s readers a redraw per edit. The fix is still the kernel’s, a thread that knows which corridor answered the read it hangs from, and the page cannot make it. It can only show it, and on the sheet below you can watch it happen.

A listener hears only what its reads rest on

The page’s cells run as root, and a root listener hears every cut under its prefix. Any other authority hears less: a cut of a thread only when some answer computed under that same capability rests on it, and a list of invalidated answers from its own reads and nobody else’s. It learns only names it was already given.

narrowed — capability: urn:cap:kernel:listen
listening for cuts under urn:iki:tutorial:sheet:, under this capability: a cut there only when a read made under it rests on it
[uncacheable]
1015
[computed]
reset to identity — capability: root (full authority)
102
[uncacheable]
hello
[uncacheable]
narrowed — capability: urn:cap:kernel:listen
heard 1 cut
cut urn:iki:tutorial:sheet:feed:acme
  invalidated urn:iki:tutorial:sheet:cell:A1
  invalidated urn:iki:tutorial:sheet:cell:A3
  invalidated urn:iki:tutorial:sheet:feed:acme
[uncacheable]
reset to identity — capability: root (full authority)

The session read A3; somebody else moved the price and typed into B6. The session heard the price, which its A3 rests on, and not B6, which it never read. This is the shape a server wants: one listener per session, registered under the session’s own capability, so that it hears about exactly what that session was sent.

The page, pushing

The sheet below is Part V’s, drawn a different way. Each cell is a view of its own, and each says, in its markup, when it wants to be drawn: when the page loads, when the page says it is stale, and every two seconds while polling is on. Here is the first row of the template:

<tr><th scope="row">1</th><td hx-get="iki/tutorial/sheet/view/cell/A1" hx-trigger="load, stale, every 2s" data-poll></td><td hx-get="iki/tutorial/sheet/view/cell/B1" hx-trigger="load, stale, every 2s" data-poll></td><td hx-get="iki/tutorial/sheet/view/cell/C1" hx-trigger="load, stale, every 2s" data-poll></td><td hx-get="iki/tutorial/sheet/view/cell/D1" hx-trigger="load, stale, every 2s" data-poll></td></tr>

The markup changed from “ask every two seconds” to “listen for stale”, as Part V said it would (the event is stale, per cell, rather than Part V’s grid, because each cell is now its own view), and nothing in the sheet’s code changed with it. What the page adds is small. Its wasm module (the page’s host, crates/book-wasm) exports four things for it: a draw that remembers what the answer carried, a wait on a listener, and the deadline and the expired views for the timer:

/// A read of `view` in the root's chain, as [`issue_async`] makes it, for a sheet that LISTENS
/// (`data-push`, part VII): the page also remembers what the answer carried ([`Page::drew`]),
/// so that a cut can be mapped to the views it makes stale, and an expiry can be timed.
#[wasm_bindgen(js_name = drawAsync)]
pub fn draw_async(view: String) -> js_sys::Promise {
    let page = PAGE.with(Rc::clone);
    let was_cached = page.is_cached(None, Verb::Source, &view);
    let issued = page.issue(None, Verb::Source, &view);
    wasm_bindgen_futures::future_to_promise(async move {
        let answer = issued.await;
        if let Ok(representation) = &answer {
            page.drew(&view, representation);
        }
        Ok(JsValue::from_str(&reply_to_json(answer, was_cached)))
    })
}

/// Wait for the next cuts the listener `name` hears (registered by a `Sink` to
/// `urn:iki:tutorial:push:listener:{name}`), and resolve to what they mean for the page:
/// `{ "cuts": [thread…], "dropped": n, "stale": [view…] }`. `stale` is every view the page drew
/// whose answer carried a thread that was cut, or every view it drew when cuts were dropped.
/// Resolves to `{ "error": … }` when nothing is listening under that name.
///
/// The kernel never calls the page: a cut appends to the listener's queue and wakes this
/// future, and the page decides what to do, on its own turn of the event loop.
#[wasm_bindgen(js_name = pushWaitAsync)]
pub fn push_wait_async(name: String) -> js_sys::Promise {
    let page = PAGE.with(Rc::clone);
    let listener = page.listening.listener(&name);
    wasm_bindgen_futures::future_to_promise(async move {
        let Some(listener) = listener else {
            let error = format!("nothing is listening as `{name}`");
            return Ok(JsValue::from_str(
                &serde_json::json!({ "error": error }).to_string(),
            ));
        };
        let batch = listener.wait().await;
        Ok(JsValue::from_str(&batch_to_json(
            &batch,
            &page.stale(&batch),
        )))
    })
}

/// The earliest deadline among the views the page drew, in milliseconds since 1970, or `-1`
/// when none has one: when to set the page's one timer.
#[wasm_bindgen(js_name = pushDeadline)]
pub fn push_deadline() -> f64 {
    PAGE.with(|page| page.deadline().map_or(-1.0, |at| at as f64))
}

/// The views the page drew whose deadline has passed, as a JSON array: what the timer draws
/// again when it fires.
#[wasm_bindgen(js_name = pushExpired)]
pub fn push_expired() -> String {
    PAGE.with(|page| serde_json::json!(page.expired()).to_string())
}

And the page’s side, in js/sheet.js: register, wait, find the cells that show the stale views, trigger stale on each, and wait again; and keep one timer at the earliest deadline:

        // A sheet that listens: its listener's name on this page, and the prefix it listens
        // under (every name the sheet has).
        var PREFIX = "urn:iki:tutorial:sheet:";
        var LISTENER = "urn:iki:tutorial:push:listener:";

        // The cells of `host` that show `views` (names like urn:iki:tutorial:sheet:view:cell:A1),
        // found by the path each cell asks for.
        function cellsShowing(host, views) {
            var found = [];
            views.forEach(function (view) {
                var path = view.replace(/^urn:/, "").split(":").join("/");
                host.querySelectorAll('td[hx-get="' + path + '"]').forEach(function (td) {
                    found.push(td);
                });
            });
            return found;
        }

        function cellName(td) {
            return td.getAttribute("hx-get").split("/").pop();
        }

        // Draw `cells` again, and say why: htmx asks for each one (its `stale` trigger), and the
        // shim below lets the request through because the page asked for it.
        function drawAgain(host, state, cells, why) {
            state.marked.forEach(function (td) {
                td.classList.remove("sheet-pushed");
            });
            state.marked = cells;
            cells.forEach(function (td) {
                state.asked.set(td, why);
                td.classList.add("sheet-pushed");
                htmx.trigger(td, "stale");
            });
            state.redrew += cells.length;
        }

        // Wait for the next cuts, draw the cells they make stale, and wait again, for as long
        // as push is on and this is still the listener it started.
        function listen(host, state, generation) {
            kernel.pushWaitAsync(state.listener).then(function (json) {
                if (!state.push || generation !== state.generation) {
                    return;
                }
                var batch = JSON.parse(json);
                if (batch.error) {
                    stopPush(host, state, "Push stopped: " + batch.error + ".");
                    return;
                }
                state.heard += batch.cuts.length;
                state.dropped += batch.dropped;
                var cells = cellsShowing(host, batch.stale);
                drawAgain(host, state, cells, "push");
                var cut = batch.cuts.map(function (thread) {
                    return thread.replace(PREFIX, "");
                }).join(", ");
                state.last = batch.dropped > 0
                    ? "The page fell behind: " + batch.dropped + " cuts were dropped, so it drew " +
                        "every cell again."
                    : "Last: " + (batch.cuts.length === 1 ? "a cut of " : "cuts of ") + cut +
                        (cells.length ? ", which drew " + cells.map(cellName).join(", ") + " again."
                            : ", which no cell on the grid rests on.");
                showPush(state);
                listen(host, state, generation);
            });
        }

        // One timer, at the earliest deadline among the cells drawn: an expiry is not a cut,
        // so nobody announces it, and the page has to know when to look.
        function schedule(host, state) {
            clearTimeout(state.timer);
            state.timer = null;
            state.deadline = state.push ? kernel.pushDeadline() : -1;
            if (state.deadline >= 0) {
                state.timer = setTimeout(function () {
                    state.timer = null;
                    var cells = cellsShowing(host, JSON.parse(kernel.pushExpired()));
                    if (cells.length === 0) {
                        schedule(host, state);
                        return;
                    }
                    state.expired += cells.length;
                    drawAgain(host, state, cells, "expired");
                    state.last = "Last: the deadline " + clockText(state.deadline) +
                        " passed, which drew " + cells.map(cellName).join(", ") + " again.";
                    showPush(state);
                }, Math.max(0, state.deadline - Date.now()) + 5);
            }
            showPush(state);
        }

        function startPush(host, state) {
            kernel.issueWithArgsAsync("", "sink", LISTENER + state.listener,
                JSON.stringify([["content", PREFIX]])).then(function (json) {
                var reply = JSON.parse(json);
                if (reply.kind !== "output") {
                    stopPush(host, state, "Push was refused: " + reply.text);
                    return;
                }
                state.push = true;
                state.generation += 1;
                state.pushToggle.setAttribute("aria-pressed", "true");
                listen(host, state, state.generation);
                schedule(host, state);
            });
        }

        function stopPush(host, state, why) {
            var was = state.push;
            state.push = false;
            state.generation += 1;
            state.pushToggle.setAttribute("aria-pressed", "false");
            state.last = why || "";
            schedule(host, state);
            if (was) {
                kernel.issueAsync("", "delete", LISTENER + state.listener);
            }
        }

The kernel never calls the page. A cut runs inside the write that caused it, so it only appends to the listener’s queue and wakes the page’s wait, and the page draws on its own turn of the event loop. The queue is bounded, and a cut that finds it full is counted as dropped. A page that fell behind does not know what it missed, so it draws every cell again, and says so:

#[test]
fn a_full_queue_counts_a_drop_and_the_host_draws_everything_again() {
    let clock = Arc::new(ManualClock::at(millis_at("2026-09-29T14:05Z", 20)));
    let kernel = host(clock);
    the_market(&kernel);
    let mut drawn = Drawn::default();
    draw_all(&kernel, &mut drawn);
    let listener = kernel
        .listen(
            ListenSpec::new().prefix(PREFIX).capacity(1),
            &Capability::root(),
        )
        .expect("root may listen");
    type_into(&kernel, "D5", "1");
    type_into(&kernel, "D6", "2");
    let batch = listener.drain();
    assert_eq!((batch.events.len(), batch.dropped), (1, 1));
    assert_eq!(drawn.stale(&batch).len(), 24);
    assert!(push::render(&batch).ends_with(
        "dropped 1: the queue was full, so nothing after the last cut above is known\n"
    ));
}

Three toggles, all off to start. Run the market writes the feed every three seconds, as in Part V. Push registers the page’s own listener and draws what it is told to. Poll asks every cell again every two seconds, which is Part V’s way, kept here to compare. Put =FEED(acme) in a cell and something that reads it in another (the cells above did, if you ran them), =NOW() in a third, then run the market with push on. A cell the page drew again because it was told is outlined until the next one is. With push and polling both off, the grid is the copy the page drew when it loaded, and an edit from the formula bar changes nothing on it: a listening sheet never draws itself again because the page made the write. Whoever writes, the listener hears it, or nobody does.

The sheet appears here when the in-page kernel has loaded.

With push on, the cells above may answer differently from their expected output. The page draws a stale view as soon as it hears the cut, so a value a cell expects to find not cached has often been computed again already. That is the other half of a golden-thread listener, which NetKernel also had: recompute before the first reader, so the next reader is served from the cache.

Polling and push, compared

Neither is free, and the costs fall in different places.

Polling costs a request per view per interval, whether or not anything changed. The sheet above polls twenty-four views every two seconds: 720 requests a minute, nearly all of them a cache lookup (over HTTP, a 304 Not Modified and no body). It sees a change up to one interval late. It needs no state anywhere but the cache, and it degrades gently: a missed poll is made up by the next.

Push costs a listener, a memory of what was drawn, and a timer, and nothing at all while nothing changes. It sees a change on the next turn of the event loop. But the costs it does have are the kind that grow:

  • State per reader. A listener (a bounded queue) and a record of every view sent, with its threads and its deadline. The page keeps one. A server keeps one per session.
  • Fan-out. A cut of a name a thousand readers drew is a thousand redraws, at once. A poll spreads the same load over the interval.
  • Redraws that change nothing. A cut is by name, not by content, so a write of the value a feed already holds makes its readers stale anyway, and each is drawn again, the same. Stopping that needs the recomputed answer compared with the old one before anyone is told: early cutoff, which the kernel does not do (Part III). Ledger item 581’s over-reach, above, is another source of the same.
  • Two triggers. A cut is announced and an expiry is not, so push is a listener and a timer. A poll covers both without knowing either exists.
  • Recovery. A dropped cut means “draw everything”, and a server’s page that loses its connection must do the same when it reconnects.

So polling is the right first answer for a few readers of values that change slowly and cache well, and push is the answer for many readers of values that change often, or when a second of latency matters. They also combine: push as the fast path, and a slow poll as the net under it.

What a server needs

This page is its own host, so push is a function call. A sheet served over HTTP needs the same four pieces, each a little further apart:

  • One listener per session, registered under that session’s capability, so that it hears only about names that session read.
  • The session’s memory of what it sent: for each view, the threads and the deadline its answer carried. Server state, held as long as the session is.
  • A channel the server can write to. Server-sent events suit it, and htmx can listen to one: the cell’s trigger becomes an event on that channel instead of a DOM event, and nothing else in the markup changes.
  • A timer per session, at the earliest deadline among the views it sent, and a full redraw when cuts were dropped or the channel reconnects.

A server also has to forget a session when it ends, and what the session was served goes with it. ikigai-core 0.1.109 makes that one cut. Threaded::session(id, space) is a corridor named, and threaded, urn:iki:space:session:{id}: every answer served through it hangs from that one thread, so ending the session is cutting it. The session’s answers are its own cache partition, so another session’s are never touched. Here it is over Part I’s space, with two sessions reading the same name:

#![allow(unused)]
fn main() {
extern crate hello_camel;
extern crate ikigai_core;
extern crate futures;
use std::sync::Arc;
use futures::executor::block_on;
use ikigai_core::{ArgRef, Capability, Iri, Request, Scope, Space, Threaded, Verb};

let kernel = hello_camel::kernel();
let root = Capability::root();
let part_one: Arc<dyn Space> = Arc::new(hello_camel::space());
let session = |id: &str| {
    let corridor: Arc<dyn Space> = Arc::new(Threaded::session(id, Arc::clone(&part_one)).unwrap());
    Scope::empty().with(corridor)
};
let read = || {
    Request::new(Verb::Source, Iri::parse("urn:iki:tutorial:camel-case").unwrap())
        .with_arg("in", ArgRef::Inline(b"resource oriented computing".to_vec()))
};

block_on(kernel.issue_in(read(), &root, session("alice"))).unwrap();
block_on(kernel.issue_in(read(), &root, session("bob"))).unwrap();

// Alice's session ends: one cut, and only what that session was served goes.
kernel.cut("urn:iki:space:session:alice");
assert!(!kernel.is_cached_in(&read(), &root, &session("alice")));
assert!(kernel.is_cached_in(&read(), &root, &session("bob")));
}

Nothing else needs freeing: the corridor is dropped with the last request that carried it. The same combinator, one thread for everything a space served, is what makes a space that changes while the kernel runs safe to cache through (A space that changes while it runs, earlier in the book).

What was built

the setthe namewhat it is
a cut listenerurn:iki:tutorial:push:listener:{name}code: a registration held by name over the kernel’s own queue, behind urn:cap:kernel:listen
what a read carriesurn:iki:tutorial:push:carriescode: one read, and what its answer carries, in words
the views drawnthe page’s memory (push::Drawn)code: per view, the threads and the deadline its answer carried; the stale views for a cut, and the earliest deadline
the listening pageurn:iki:tutorial:sheet:template:pusha template: each cell a view that listens for stale

The kernel already had the hard parts: the dependency graph, recorded as every answer was computed and handed out on the answer, and a queue that tells a host when a thread is cut. Push needed a place to keep what the page drew, a rule to map a cut onto it, and a timer for the one kind of change nobody announces. The sheet’s code did not change, beyond one template, and its values do not know whether anybody is looking.

Your first script

This section is about code you can publish into a running host, at a name, so that other people can run it without a release. Before the detail, four small steps, one idea each, and one command each. Nothing here needs more than the chapters before this one.

The scripts are SPARQL queries, over a shelf of books. Each step is a program in this repository that prints every request it makes, the way the REPL would write it, and what came back. In the first two steps you are the host, so you may do anything; the third step is where that changes.

1. Hello, script

The smallest script there is, hello.rq:

SELECT ("Hello, world!" AS ?greeting)
FROM <urn:iki:tutorial:empty>
WHERE {}

It answers one row, and its second line is the first lesson of this section: a script names the data it reads, even a hello world. FROM <urn:iki:tutorial:empty> says which graph this query reads. Nothing in it is used, and the graph is empty, but the line is not decoration: the host works out from what a script names the authority its publisher and its runners need (here, the right to read that one graph), and it runs the query over exactly those graphs and no others. A query that names none is refused when it is published, and the refusal says to name a graph with FROM. So even the smallest script says what it reads, and the rest of this section leans on that.

And the same greeting with a parameter, hello-who.rq: the comment line says it takes a who, a string, which is world unless the caller says otherwise.

# @param who xsd:string default world
SELECT (CONCAT("Hello, ", ?who, "!") AS ?greeting)
FROM <urn:iki:tutorial:empty>
WHERE {}

The program publishes the first at the name urn:script:hello and reads its result; then publishes the second at the same name, and reads it twice:

cargo run -p script-host -- hello
you> sink urn:script:hello language=sparql   (hello.rq, piped)
urn:script:hello:version:sha256:…

you> source urn:script:hello:result as=text/csv
greeting
"Hello, world!"

you> sink urn:script:hello language=sparql   (hello-who.rq, piped)
urn:script:hello:version:sha256:…

you> source urn:script:hello:result as=text/csv
greeting
"Hello, world!"

you> source urn:script:hello:result as=text/csv who=Brian
greeting
"Hello, Brian!"

Publishing is a sink of the text at the script’s name. Running it is a source of …:result: an ordinary read, which is why as= picks the format, as it does for any resource. The parameter is an argument of that read, who=Brian, like any endpoint’s argument. Publishing again at the same name made a new version, and the name now runs the new one.

2. A script over data

The host’s store holds a shelf of three books:

PREFIX dcterms: <http://purl.org/dc/terms/>
INSERT DATA {
  GRAPH <urn:iki:tutorial:shelf> {
    <urn:iki:tutorial:book:1> dcterms:title "Moby-Dick" .
    <urn:iki:tutorial:book:2> dcterms:title "Middlemarch" .
    <urn:iki:tutorial:book:3> dcterms:title "Walden" .
  }
}

and titles.rq lists the titles on it:

PREFIX dcterms: <http://purl.org/dc/terms/>
SELECT ?title WHERE {
  GRAPH <urn:iki:tutorial:shelf> { ?book dcterms:title ?title }
} ORDER BY ?title

The program publishes it, reads it twice, puts a fourth book on the shelf (fourth.ru, the same kind of INSERT DATA with one book in it), and reads it again:

cargo run -p script-host -- shelf
you> sink urn:script:titles language=sparql   (titles.rq, piped)
urn:script:titles:version:sha256:…

you> source urn:script:titles:result as=text/csv
title
Middlemarch
Moby-Dick
Walden
(computed)

you> source urn:script:titles:result as=text/csv
title
Middlemarch
Moby-Dick
Walden
(from the cache: nothing ran)

you> sink urn:iki:store:graph-update graph=urn:iki:tutorial:shelf   (fourth.ru, piped)
updated <urn:iki:tutorial:shelf>: +1 -0 quads

you> source urn:script:titles:result as=text/csv
title
Frankenstein
Middlemarch
Moby-Dick
Walden
(computed)

The lines in parentheses are the program asking the kernel, before each read, whether it could answer without running anything. The second read ran nothing: the answer was remembered. The third read ran again, and has the fourth book in it. Nobody told the script that the shelf had changed. It read the shelf through the kernel, so the kernel knew what the answer depended on, and writing the shelf made the remembered answer stale.

3. Who may run it

Now two people, and the host is no longer one of them. Alice may publish titles and read the shelf. Bob may read the shelf too, but nobody has said he may run titles:

cargo run -p script-host -- grant
alice> sink urn:script:titles language=sparql   (titles.rq, piped)
urn:script:titles:version:sha256:…

bob> source urn:script:titles:result as=text/csv
error: denied: capability does not grant `urn:cap:script:run:titles` (declared by `urn:script:titles:result`)

host> (bob's grant gains one line: urn:cap:script:run:titles)

bob> source urn:script:titles:result as=text/csv
title
Middlemarch
Moby-Dick
Walden

Bob is refused, and the refusal names exactly what he lacks: the grant to run this one script. The host adds that one line to what Bob holds, and he gets the answer. Publishing, running and reading a script’s text are separate grants, so Alice, who published it, could not run it either.

4. Ask in a sentence

A language model can write the query for you. Here the model is a stand-in that answers this sentence with the same query every time, so the page prints the same thing on every machine. (A sentence becomes a draft shows how to put a real one behind the same door.) The program asks how many books are on the shelf, then publishes what came back, and runs it:

cargo run -p sentence-draft -- ask
you> sink urn:nl:sparql ask="How many books are on the shelf?" focus=urn:iki:tutorial:shelf save=book-count
urn:script:book-count:version:sha256:… (a DRAFT of urn:script:book-count: review it, then publish it with state=published)

PREFIX dcterms: <http://purl.org/dc/terms/>
SELECT (COUNT(?book) AS ?books) WHERE {
  GRAPH <urn:iki:tutorial:shelf> { ?book dcterms:title ?title }
}

preview (LIMIT 5):
?books
"3"^^<http://www.w3.org/2001/XMLSchema#integer>

you> source urn:script:book-count | sink urn:script:book-count language=sparql state=published
urn:script:book-count:version:sha256:…

you> source urn:script:book-count:result as=text/csv
books
3

The model’s query came back as a draft: saved at the name, visible only to the person who asked, and run by nobody. Before anyone saw it, it was checked (does it parse, and does it read only what you may read?) and run once as a preview, which already says 3. Publishing it is a person’s act: the draft’s own text, sunk again with state=published. After that it is a script like hello and titles.

Next

That is the whole idea: code at a name, run as a read, remembered until what it read changes, run only by someone who may, and written by a model only as a draft a person publishes. The next three chapters do the same things on a different shape of data, a handful of work items, in more depth: Scripts are resources names every part of a script, Who a script runs as says exactly whose authority a run holds, and A sentence becomes a draft shows how little the model is allowed to do.

Scripts are resources

Spreadsheet, II made a formula code as a resource: compiled by a transreptor, cached like any other answer, and compiled again exactly when its text changed, because the compiled form hung from the input’s golden thread and nothing else. That code still lived inside the sheet. Somebody wrote the formula language in Rust, and a cell could only say what that language could.

This section takes the next step: code that anybody the host trusts can publish into a running host, at a name, without a release, and that everybody the host lets in can run. It is the pattern NetKernel called a scripted endpoint, and the claim of this section is the same as the claim of the spreadsheet’s: a script is not a feature beside the resources. It IS one, urn:script:{name}, with a family of names around it, and every door a host has (the REPL, HTTP, an agent’s tool list, a timer) reaches it the way it reaches anything else. Running a script is a read or a write, so the cache, the golden threads and the capabilities already apply to it, and none of them is written again.

Your first script showed each of these once, on a shelf of books. Three chapters take them in depth, on a handful of work items. This one publishes a query and runs it. Who a script runs as asks under whose authority it runs, which is the question a published piece of code always raises. A sentence becomes a draft lets a language model write one, and shows how little the model is allowed to do.

The names

The host library is ikigai-script. These are the names it binds that this chapter uses:

the nameverbswhat it is
urn:script:{name}Source, Sink, Deletethe script: fetch it WITHOUT running it, publish it, retire it
urn:script:{name}:version:{digest}Sourceone immutable version, named by its content
urn:script:{name}:resultSourcerun it as a READ: its answer
urn:script:{name}:runsSinkrun it for its EFFECTS: answers the run’s name
urn:script:{name}:run:{id}Sourceone recorded run

Two of these hold state, and the rest are compositions. The script (its head, and every version it has had) is an atom: a Sink writes it and nothing else does. A run is an atom: …:runs writes one and nothing writes it again. A result holds nothing: it is the script’s text, run through the store, under somebody’s authority.

A script may be written in three languages: Lisp, a plan (a composition of requests written as data, not the transreption route of the same name), or SPARQL. This section uses SPARQL only, and not just because the book’s data is a graph: a query’s authority can be read off its text, and the next chapter depends on that. ikigai-script’s own advice, when assembling anything, is “a query, then a plan, then Lisp”, for the same reason.

A host of your own, again

Binding, and a host of your own composed a kernel from spaces somebody else wrote. This is the same move. crates/script-host writes no endpoint at all: it configures the script host, puts it in front of an in-memory store, and that is the host.

/// The host's spaces, in resolution order: the script host, configured, in front of the store
/// it runs SPARQL against. `ceiling` answers "the most this script may touch" for each script by
/// name, and `on_change` is told about every publish and retire.
pub fn root(ceiling: CeilingPolicy, on_change: Option<ChangeHook>) -> Vec<Arc<dyn Space>> {
    let mut config = SpaceConfig::new(Arc::new(MemoryBackend::new()), ceiling)
        // SPARQL scripts, run through the store's graph-scoped doors at `urn:iki:store:`.
        .sparql(SparqlDoor::store());
    if let Some(hook) = on_change {
        config = config.on_change(hook);
    }
    let store = DurableStore::in_memory().expect("an in-memory store");
    vec![
        Arc::new(ikigai_script::space(config)) as Arc<dyn Space>,
        Arc::new(ikigai_store::space(store)) as Arc<dyn Space>,
    ]
}

The configuration is the three things only a host can decide, and the library refuses to guess any of them:

  • Where scripts live. A Backend. MemoryBackend here, because a book’s kernel keeps nothing past the process; DirBackend keeps them as plain files under a directory the host chooses.
  • The most each script may touch. A ceiling policy, asked for by script name on every run. This chapter’s is Ceiling::unbounded() for every script, and the next chapter replaces it.
  • Which store a query runs against. SparqlDoor::store() names the store’s graph-scoped doors under urn:iki:store:. Without a door, language=sparql is refused.

The kernel around it has one more thing to wire:

/// A kernel over [`root`] and then `beside` (other spaces the host mounts), with the store
/// seeded. A published query is its own entry in the catalog, and the kernel caches
/// descriptions under `urn:kernel:bindings`, which only the host can cut: so the change hook
/// reaches back to the kernel it is part of, through a cell filled once the kernel exists.
pub fn kernel_beside(ceiling: CeilingPolicy, beside: Vec<Arc<dyn Space>>) -> Arc<Kernel> {
    kernel_over(ceiling, beside, Some(SEED))
}

/// [`kernel_beside`], with the store seeded by `seed` (a SPARQL update), or left empty.
pub fn kernel_over(
    ceiling: CeilingPolicy,
    beside: Vec<Arc<dyn Space>>,
    seed: Option<&str>,
) -> Arc<Kernel> {
    let cell: Arc<OnceLock<Weak<Kernel>>> = Arc::new(OnceLock::new());
    let reach = Arc::clone(&cell);
    let hook: ChangeHook = Arc::new(move |_name: &str| {
        if let Some(kernel) = reach.get().and_then(Weak::upgrade) {
            kernel.cut(BINDINGS_THREAD);
        }
    });
    let mut spaces = root(ceiling, Some(hook));
    spaces.extend(beside);
    let kernel = Arc::new(
        Kernel::with_meta_renderer(
            Arc::new(Fallback::new(spaces)),
            Arc::new(ikigai_vocab::TurtleRenderer),
        )
        .with_clock(Arc::new(FixedClock::at(BOOK_TIME))),
    );
    let _ = cell.set(Arc::downgrade(&kernel));
    if let Some(seed) = seed {
        issue(
            &kernel,
            &Capability::root(),
            Verb::Sink,
            "urn:iki:store:update",
            &[("content", seed)],
        )
        .expect("seeding the store");
    }
    kernel
}

A published query becomes its own entry in the host’s catalog, with its parameters as real arguments, and the kernel caches every description under urn:kernel:bindings. The library holds no kernel, so it cannot cut that thread itself; it tells the host through on_change, and the host, which holds both, makes the cut. A kernel that does not exist yet cannot be handed to the space it is built from, so the hook reaches it through a cell filled once it does.

The fourth decision is who is asking, and it is not configuration. A host’s door (the HTTP door, the socket, the REPL) mints the caller’s identity into the capability the request runs under, and the script host reads it from there:

/// Alice, as the host's door would mint her: a capability naming her as its principal,
/// holding what she may do with these two scripts and the one graph they touch.
pub fn alice() -> Capability {
    Capability::scoped([
        "urn:cap:script:write:open-items",
        "urn:cap:script:read:open-items",
        "urn:cap:script:run:open-items",
        "urn:cap:script:write:close-item",
        "urn:cap:script:run:close-item",
        READ_ITEMS,
        WRITE_ITEMS,
    ])
    .with_principal("urn:iki:tutorial:person:alice")
    .expect("a principal IRI")
}

with_principal puts urn:iki:tutorial:person:alice into the capability as urn:cap:principal:…. It is never an argument, so no caller can name itself, and narrowing a capability never adds or changes one.

The data is four work items, in the vocabulary gonk’s work ledger keeps them in, in one named graph, urn:iki:tutorial:items; and two salaries in a second graph, which the next chapter keeps out of reach:

/// Four items and two salaries, written straight to the store: the host's own data, not a
/// script's.
pub const SEED: &str = r#"PREFIX ledger: <https://ikigai-rs.dev/ns/ledger#>
PREFIX dcterms: <http://purl.org/dc/terms/>
INSERT DATA {
  GRAPH <urn:iki:tutorial:items> {
    <urn:iki:tutorial:item:1> a ledger:Item ; ledger:number 1 ; ledger:priority 1 ;
        ledger:status ledger:open ; dcterms:title "Write the README" .
    <urn:iki:tutorial:item:2> a ledger:Item ; ledger:number 2 ; ledger:priority 0 ;
        ledger:status ledger:open ; dcterms:title "Fix the flaky test" .
    <urn:iki:tutorial:item:3> a ledger:Item ; ledger:number 3 ; ledger:priority 2 ;
        ledger:status ledger:closed ; dcterms:title "Rename the crate" .
    <urn:iki:tutorial:item:4> a ledger:Item ; ledger:number 4 ; ledger:priority 3 ;
        ledger:status ledger:open ; dcterms:title "Answer the issue" .
  }
  GRAPH <urn:iki:tutorial:payroll> {
    <urn:iki:tutorial:person:alice> <urn:iki:tutorial:salary> 100 .
    <urn:iki:tutorial:person:bob> <urn:iki:tutorial:salary> 90 .
  }
}"#;

Publish a query, and fetch it without running it

The query lists the open items at or above a priority, where 0 is the highest:

/// A query, published as a script: the open items at or above a priority (0 is highest).
pub const OPEN_ITEMS: &str = r#"# The open items at or above a priority, by number.
@param priority xsd:integer default 3 -- the lowest priority to list (0 is highest)
PREFIX ledger: <https://ikigai-rs.dev/ns/ledger#>
PREFIX dcterms: <http://purl.org/dc/terms/>
SELECT ?number ?title WHERE {
  GRAPH <urn:iki:tutorial:items> {
    ?item ledger:status ledger:open ; ledger:priority ?p ;
          ledger:number ?number ; dcterms:title ?title .
    FILTER(?p <= ?priority)
  }
} ORDER BY ?number"#;

The # @param line, in the comment block before the first token, declares a parameter: the query variable it binds, its type, and a default. It is the only place a parameter can be declared, and it becomes an argument of the script’s own …:result, so it shows up in the catalog, in an agent’s tool list and in the Emacs aliases like any endpoint’s argument. A value is bound into the PARSED query as an RDF term, never spliced into the text, so a value that tries to close a string or open a second pattern arrives as one literal.

Publishing is a Sink on the script’s name. The program prints each request the way the REPL would write it, and runs the whole chapter every time; the number picks the section it prints:

cargo run -p script-host -- 1
alice> sink urn:script:open-items language=sparql   (the query, piped)
urn:script:open-items:version:sha256:e1977365…

alice> source urn:script:open-items
# The open items at or above a priority, by number.
# @param priority xsd:integer default 3 -- the lowest priority to list (0 is highest)
…   (11 lines, as published)

The answer to a publish is the name of the version it made, and the version is named by its content: sha256: and the hex digest of its language, its declared authority and its text. The same text published twice is the same version; one more byte is another.

A Source of the script’s own name answers its text, exactly as it went in. Fetching a script and running it are different requests on different names, and they need different grants: reading a script’s source is urn:cap:script:read:{name}, running it is urn:cap:script:run:{name}. Source code is not the same sensitivity as what it computes, and the two are never one grant.

Running is a read

urn:script:open-items:result is the query’s answer. Asked for twice, then with a parameter:

cargo run -p script-host -- 2
alice> source urn:script:open-items:result as=text/tab-separated-values
?number	?title
1	"Write the README"
2	"Fix the flaky test"
4	"Answer the issue"
[computed]

alice> source urn:script:open-items:result as=text/tab-separated-values
?number	?title
1	"Write the README"
2	"Fix the flaky test"
4	"Answer the issue"
[cached]

alice> source urn:script:open-items:result as=text/tab-separated-values priority=1
?number	?title
1	"Write the README"
2	"Fix the flaky test"
[computed]

host> dependents urn:iki:store:graph-update
dependents of urn:iki:store:graph-update  (cut 0 times)
  4 live cached entries hang from it — a cut makes them stale, and the next read of each recomputes it
  urn:iki:store:graph-select    text/tab-separated-values       59 B  4 threads   root
  urn:iki:store:graph-select    text/tab-separated-values       80 B  4 threads   root
  urn:script:open-items:result  text/tab-separated-values       59 B  8 threads   root
  urn:script:open-items:result  text/tab-separated-values       80 B  8 threads   root

[computed] and [cached] are the program asking the kernel, before each read, whether it would answer from its cache (Kernel::is_cached, with the same request and the same capability). The second read ran nothing. The third is another request, because a parameter is part of what is asked, so it is another answer and another cache entry.

The last request is the question Golden threads in practice asked before a cut: what hangs from this thread? The host asks it of urn:iki:store:graph-update, the thread the store cuts on every write to a named graph, and four answers hang from it: the store’s two answers, and the script’s two, which were computed from them. Nobody wrote “recompute the result when the items change”. The result read the store through the kernel, so the kernel recorded that it did, and the store’s answers carry the store’s threads. A script’s result is cacheable exactly as far as what it read was.

Running is a write

An update runs for its effects, so it is not a read at all: its …:result is refused, naming …:runs. This one closes an item by number:

/// An update, published as a script: close one item, by number.
pub const CLOSE_ITEM: &str = r#"# Close one item.
@param number xsd:integer -- the item to close
PREFIX ledger: <https://ikigai-rs.dev/ns/ledger#>
DELETE { GRAPH <urn:iki:tutorial:items> { ?item ledger:status ledger:open } }
INSERT { GRAPH <urn:iki:tutorial:items> { ?item ledger:status ledger:closed } }
WHERE  { GRAPH <urn:iki:tutorial:items> { ?item ledger:number ?number ; ledger:status ledger:open } }"#;
cargo run -p script-host -- 3
alice> sink urn:script:close-item language=sparql   (the update, piped)
urn:script:close-item:version:sha256:f4d4d8e1…

alice> sink urn:script:close-item:runs number=2
urn:script:close-item:run:1

alice> source urn:script:close-item:run:1
urn:script:close-item:run:1
  script:     urn:script:close-item
  version:    sha256:f4d4d8e1…
  principal:  urn:iki:tutorial:person:alice
  started:    2026-10-11T09:00:00.000Z
  ended:      2026-10-11T09:00:00.000Z
  outcome:    ok
  capability: urn:cap:store:read:graph:urn:iki:tutorial:items urn:cap:store:write:graph:urn:iki:tutorial:items
  result:
updated <urn:iki:tutorial:items>: +1 -1 quads

alice> source urn:script:open-items:result as=text/tab-separated-values
?number	?title
1	"Write the README"
4	"Answer the issue"
[computed]

A Sink on …:runs runs the script and answers the name of the run, and the run is an atom of its own: run:1, then run:2, never rewritten. Its record says who ran it (the principal the caller’s capability named, never an argument), which version, when it started and ended, how it ended, and exactly what authority it ran under. A failed run is recorded too, with its error’s type, so “what did this script do last night, and as whom?” is a read.

The run’s capability is not Alice’s. It is the two grants the update’s text derives (write the graph it writes, and read it, because it has a WHERE), and nothing else Alice holds. Where that comes from is the next chapter.

The last read is the payoff of the one before it. The update wrote the items graph, the store cut its graph-write thread, and the cached result hung from it: so the next read recomputed, and item 2 is gone. The clock in these records is fixed (FixedClock), so the program prints the same instants on every machine.

Versions are content

cargo run -p script-host -- 4
alice> sink urn:script:open-items language=sparql   (the same query, ORDER BY DESC(?number))
urn:script:open-items:version:sha256:6a7fcb92…

alice> source urn:script:open-items:result as=text/tab-separated-values
?number	?title
4	"Answer the issue"
1	"Write the README"
[computed]

alice> source urn:script:open-items:version:sha256:e1977365…
…
} ORDER BY ?number

Republishing moved the head to a new version, and cut the script’s own thread, so the result was computed again from the new text. The first version is still there, by its digest, and still says what it said: a version is never edited, only superseded. There is no “roll back” in this version of the library. Publishing the old text again is the same thing, because it makes the same digest.

What was built

the setthe namewhat it is
the hostscript_host::kernel_withcomposition: the script host, configured, in front of the store; a hook that cuts the bindings thread
a scripturn:script:{name}an atom, the library’s: a head and every version, written only by a Sink here
a versionurn:script:{name}:version:{digest}an atom, never rewritten, named by its content
a resulturn:script:{name}:resultcomposition: the head’s text, run through the store’s doors, cached as far as what it read
a runurn:script:{name}:run:{id}an atom, written once by a Sink on …:runs
the dataurn:iki:tutorial:itemsa named graph in the store, written by the host and by close-item

This crate wrote no endpoint. Everything it does is configuration of two libraries and a kernel, which is the point: publishing code into a running host did not need a feature beside the resources, because the resources already had everything it needed.

Who a script runs as

A published script is code one person wrote and another person runs, on a host neither of them operates. So every run has to answer a question an ordinary endpoint never raises: whose authority is this? The runner’s, because they asked? The publisher’s, because they wrote it? The host’s, because it is running? Each answer alone is a hole. The runner’s alone lets a publisher plant code that does, with a powerful runner’s grants, what the publisher could never do. The publisher’s alone lets anyone who can run a script borrow its author’s authority. The host’s alone is root.

ikigai-script takes all three, and keeps the least. A run holds the RUNNER’s capability, narrowed to

{ declared } ∩ { what the publisher held at publish } ∩ { the host's ceiling for this script }
  + every exclusion the publisher or the ceiling carries

and never more, because the only operation the library uses to make a run’s authority is Invocation::issue_attenuated, and attenuation can only narrow. A run cannot hold anything its runner does not, so the runner’s own capability is the fourth term, implicit in all of it.

For a SPARQL script the first term is not written by anybody. It is derived from the text: each graph a query names is urn:cap:store:read:graph:<iri>, and the one graph an update writes is urn:cap:store:write:graph:<iri>. So the author cannot understate what a query needs, and cannot overstate it either. What those grants mean at the store itself, and why a query naming one graph its caller was not granted is refused rather than answered over the rest, is A query’s authority is its dataset.

This chapter runs every case it describes. The host is the previous chapter’s, with the same items, under a ceiling policy of its own:

cargo run -p script-authority

The program runs all five sections every time; each block below prints one of them.

The people

/// Publishes. Holds the items graph (read and write), and writes the `open-items` and
/// `graphs` scripts and every script in the `team` namespace except `team-payroll`.
pub fn alice() -> Capability {
    person(
        "alice",
        &[
            "urn:cap:script:write:open-items",
            "urn:cap:script:write:graphs",
            "urn:cap:script:write:team-*",
            "urn:cap:script:write:-team-payroll",
            "urn:cap:script:read:open-items",
            READ_ITEMS,
            WRITE_ITEMS,
        ],
    )
}

/// Runs. Holds MORE data than Alice (the payroll graph too), and may run every script in this
/// chapter and every one in the `team` namespace.
pub fn bob() -> Capability {
    person(
        "bob",
        &[
            "urn:cap:script:run:open-items",
            "urn:cap:script:run:graphs",
            "urn:cap:script:run:every-graph",
            "urn:cap:script:run:orphan",
            "urn:cap:script:run:team-*",
            READ_ITEMS,
            READ_PAYROLL,
        ],
    )
}

/// Holds less: she may write `salaries` and run `open-items`, and read no graph at all.
pub fn carol() -> Capability {
    person(
        "carol",
        &[
            "urn:cap:script:write:salaries",
            "urn:cap:script:run:open-items",
        ],
    )
}

Alice writes scripts and reads only the items. Bob runs them, and reads more than Alice does: the payroll graph too. Carol reads nothing. All three are capabilities a host’s door would mint, each naming its person as the principal.

The host’s ceilings

The ceiling is the host’s word on each script, and it is written where the host’s configuration lives, one file per script under <config home>/script-authority/{name}: one urn:cap: scope per line, # comments, a trailing * for a family of grants, a lone * for no ceiling at all. The crate keeps those files in memory, where the program can edit one, and hands the script host a policy that reads them:

/// The host's ceiling files, by script name, as they would sit under
/// `<config home>/script-authority/`. One `urn:cap:` scope per line, `#` comments, a
/// trailing `*` for a family, a lone `*` for no ceiling.
pub const CEILINGS: &[(&str, &str)] = &[
    (
        "open-items",
        "# the items, and nothing else\nurn:cap:store:read:graph:urn:iki:tutorial:items\n",
    ),
    (
        "graphs",
        "# any graph the publisher and the runner both hold\nurn:cap:store:read:graph:*\n",
    ),
    ("every-graph", "urn:cap:store:read:graph:*\n"),
    ("team-open", "*\n"),
    (
        "team-a-open",
        "urn:cap:store:read:graph:urn:iki:tutorial:items\n",
    ),
];

/// The host's ceiling files, held where the host can edit them (a stand-in for the directory).
#[derive(Clone)]
pub struct Ceilings(Arc<Mutex<BTreeMap<String, String>>>);

impl Ceilings {
    /// The files in [`CEILINGS`].
    pub fn new() -> Ceilings {
        let files = CEILINGS
            .iter()
            .map(|(name, text)| (name.to_string(), text.to_string()))
            .collect();
        Ceilings(Arc::new(Mutex::new(files)))
    }

    /// Replace one script's file. ⚠ The library cannot see this happen: the host must also cut
    /// `urn:script:{name}:authority`, or a result cached under the old ceiling is served on.
    pub fn set(&self, name: &str, text: &str) {
        self.0
            .lock()
            .expect("the ceiling files")
            .insert(name.to_string(), text.to_string());
    }

    /// The policy the host hands the script space: the script's file, parsed, and **nothing**
    /// for a script the host has no file for, so a script nobody configured can run nothing at
    /// all. Asked on every run, so a changed file takes effect at the next one that runs.
    pub fn policy(&self) -> CeilingPolicy {
        let files = Arc::clone(&self.0);
        Arc::new(move |name: &str| {
            files
                .lock()
                .expect("the ceiling files")
                .get(name)
                .and_then(|text| Ceiling::parse(text).ok())
                .unwrap_or_else(Ceiling::nothing)
        })
    }
}

impl Default for Ceilings {
    fn default() -> Self {
        Ceilings::new()
    }
}

A script the host has no file for gets Ceiling::nothing(): it may touch nothing, so it cannot run. That is a choice the host makes in this closure, and the library’s own example makes it too. Failing open would mean any script somebody managed to publish ran with whatever its publisher and its runner happened to hold.

A publish is checked against its publisher

cargo run -p script-authority -- 1
carol> sink urn:script:salaries language=sparql   (a query over the payroll graph)
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:tutorial:payroll — declare less, or publish under a capability that holds it

alice> sink urn:script:open-items language=sparql requires=urn:cap:store:read:graph:urn:iki:tutorial:payroll
error: invalid argument `requires`: a SPARQL script's authority is derived from the graphs its text names, not declared, and this one derives urn:cap:store:read:graph:urn:iki:tutorial:items; the `requires` given says urn:cap:store:read:graph:urn:iki:tutorial:payroll. Omit `requires`

alice> sink urn:script:open-items language=sparql
urn:script:open-items:version:sha256:e1977365…

Carol may write a script called salaries, and the query names a graph she cannot read. Storing it anyway and narrowing at run time would store a script whose declared authority says one thing and whose runs get another, and the action manifold would offer it on the strength of the first. So the publish is refused, naming what she does not hold. No elevation starts here: a script never holds, even on paper, more than the person who published it.

Alice’s first attempt declares a graph her query does not read. For Lisp, requires= is how a script says what it needs. For SPARQL the text already says it, so a requires that disagrees is refused rather than believed. What is stored with the version is the derived set, intersected with what Alice held when she published: her grant, taken as a snapshot, so revoking something from her later does not reach back into a version she already published, and granting her something later does not widen it.

A run is checked against its runner

cargo run -p script-authority -- 2
alice> source urn:script:open-items:result as=text/tab-separated-values
error: denied: capability does not grant `urn:cap:script:run:open-*` (declared by `urn:script:open-items:result`; any grant under `urn:cap:script:run:open-` satisfies it)

carol> source urn:script:open-items:result as=text/tab-separated-values
error: denied: capability does not grant `urn:cap:store:read:graph:urn:iki:tutorial:items` (declared by `urn:script:open-items:result`)

bob> source urn:script:open-items:result as=text/tab-separated-values
?number	?title
1	"Write the README"
2	"Fix the flaky test"
4	"Answer the issue"

Alice published open-items and may not run it. Publishing and running are separate grants, and she holds no run grant at all. The scope the refusal names is worth a second look: not urn:cap:script:run:open-items but urn:cap:script:run:open-*. A published query is its own endpoint, and the kernel checks its declared grant before the endpoint runs, from a description that cannot depend on who asks. So a name with a hyphen in it declares its top-level namespace as the gate (open-items is in the namespace open), and the exact grant is checked inside. The refusal is the kernel’s own, so it can name only that family, and since ikigai-core 0.1.97 it says what passes the floor: any grant under the prefix, which only gets the run as far as the exact check inside. The entry’s own contract, what a meta of urn:script:open-items:result answers, says which exact grant runs it and why the floor is the family. Here that reads oddly, because nobody meant open as a namespace; the last section is where namespaces are meant.

Carol holds the run grant, and the run is refused anyway: the query reads the items, and Carol cannot. The kernel’s floor refused it before the script host ran anything, from the same description, because a published query’s own entry declares the graph grants its text derives. A run is the runner’s capability, narrowed; Carol’s has nothing in it to narrow to.

Bob holds the run grant and the items, and gets the answer.

A run never holds more than its publisher held

This query names no graph. GRAPH ?g ranges over whatever graphs the run may read, so it counts the triples in each, which makes it a probe of exactly that:

/// How many triples each graph a run can see holds. `GRAPH ?g` fixes no graph in the text, so
/// the script declares the FAMILY `urn:cap:store:read:graph:*`, and each run reads the graphs
/// its own narrowed capability holds: never more.
pub const GRAPHS: &str = r#"# How many triples each graph this run may read holds.
SELECT ?g (COUNT(*) AS ?triples) WHERE { GRAPH ?g { ?s ?p ?o } }
GROUP BY ?g ORDER BY ?g"#;
cargo run -p script-authority -- 3
alice> sink urn:script:graphs language=sparql   (GRAPH ?g: every graph the run may read)
urn:script:graphs:version:sha256:646ac869…

bob> source urn:script:graphs:result as=text/tab-separated-values
?g	?triples
<urn:iki:tutorial:items>	20

bob> sink urn:script:graphs:runs   (the same query, run for the record)
urn:script:graphs:run:1

bob> source urn:script:graphs:run:1
urn:script:graphs:run:1
  script:     urn:script:graphs
  version:    sha256:646ac869…
  principal:  urn:iki:tutorial:person:bob
  started:    2026-10-11T09:00:00.000Z
  ended:      2026-10-11T09:00:00.000Z
  outcome:    ok
  capability: urn:cap:script:write:-team-payroll urn:cap:store:read:graph:urn:iki:tutorial:items
  result:
{"head":{"vars":["g","triples"]},"results":{"bindings":[{"g":{"type":"uri","value":"urn:iki:tutorial:items"},"triples":{"type":"literal","value":"20","datatype":"http://www.w3.org/2001/XMLSchema#integer"}}]}}

Bob can read the payroll graph. Alice cannot, and Alice published this. So a run of it, by Bob, sees one graph. A query reads the caller’s readable union and never more: the graph grants the runner holds, that the publisher held, and that the ceiling allows, computed again on every run. Bob cannot borrow Alice’s authority, and Alice’s script cannot borrow Bob’s.

A query can run through …:runs too, when a run should be recorded, and the record shows exactly what the run held. Two scopes, and one of them is not Bob’s: urn:cap:script:write:-team-payroll is an exclusion Alice carries (a grant with a - taking something back out), and it rides along on every run of every script she publishes, whoever runs it. Exclusions travel because a publisher who could not touch something must not be able to publish code that can. This one has nothing to do with counting triples, and it is carried anyway, because the library cannot know which of a publisher’s exclusions matter to a script, and keeping one that does not costs nothing.

Nor more than the host’s ceiling allows

The same query, published by the host itself. Root holds every graph, so the publisher’s half no longer narrows anything, and the ceiling is all that is left between Bob and the payroll:

cargo run -p script-authority -- 4
host> sink urn:script:every-graph language=sparql   (the same query, published by the host)
urn:script:every-graph:version:sha256:646ac869…

bob> source urn:script:every-graph:result as=text/tab-separated-values
?g	?triples
<urn:iki:tutorial:items>	20
<urn:iki:tutorial:payroll>	2

host> (writes script-authority/every-graph: the items graph alone)

bob> source urn:script:every-graph:result as=text/tab-separated-values
?g	?triples
<urn:iki:tutorial:items>	20
<urn:iki:tutorial:payroll>	2

host> sink urn:kernel:cut thread=urn:script:every-graph:authority
cut urn:script:every-graph:authority

bob> source urn:script:every-graph:result as=text/tab-separated-values
?g	?triples
<urn:iki:tutorial:items>	20

host> sink urn:script:orphan language=sparql   (no ceiling file names it)
urn:script:orphan:version:sha256:646ac869…

bob> source urn:script:orphan:result as=text/tab-separated-values
error: denied: urn:script:orphan reads the caller's readable graphs, and this run may read none: it needs `urn:cap:store:read:graph:` grants the publisher held, the host allows and the runner holds

The ceiling file for every-graph starts as the family urn:cap:store:read:graph:*, so Bob’s run reads both graphs. Then the host narrows the file to the items alone:

    say(
        "host",
        "(writes script-authority/every-graph: the items graph alone)",
    );
    ceilings.set(
        "every-graph",
        "urn:cap:store:read:graph:urn:iki:tutorial:items\n",
    );

And Bob still sees the payroll. The ceiling is asked on every run, but nothing ran: the answer was cached, under Bob’s capability, from before the edit. The library cannot see a file change, and the kernel cannot know that a closure’s answer moved. Every result hangs from a thread named for exactly this, urn:script:{name}:authority, and cutting it is the host’s half of the contract:

    ask(
        &kernel,
        "host",
        &host,
        Verb::Sink,
        "urn:kernel:cut",
        &[("thread", "urn:script:every-graph:authority")],
        "sink urn:kernel:cut thread=urn:script:every-graph:authority",
    );

A host that reads its ceilings from files puts a watcher on that directory and makes the same cut when a file changes; this one, which edits the file itself, makes it by hand. After the cut, the next read runs, the policy answers the new ceiling, and the payroll is gone.

orphan has no file at all, so the policy answers Ceiling::nothing(), and the run is refused before the store is asked.

A namespace grant, and an exclusion that wins over it

Granting one script at a time does not scale to a team. A grant can name a namespace instead: urn:cap:script:{act}:{namespace}-* covers every script whose name begins {namespace}-. Alice holds urn:cap:script:write:team-*, and the exclusion urn:cap:script:write:-team-payroll:

cargo run -p script-authority -- 5
alice> sink urn:script:team-open language=sparql
urn:script:team-open:version:sha256:e1977365…

alice> sink urn:script:team-a-open language=sparql
urn:script:team-a-open:version:sha256:e1977365…

alice> sink urn:script:teammate language=sparql
error: denied: publishing a script needs `urn:cap:script:write:teammate`, which this capability does not hold. A script grant names one script (`urn:cap:script:{act}:{name}`) or a namespace of them (`urn:cap:script:{act}:{namespace}-*`), and an exclusion (`…:-{name}`) takes one back out

alice> sink urn:script:team-payroll language=sparql
error: denied: publishing a script needs `urn:cap:script:write:team-payroll`, which this capability does not hold. A script grant names one script (`urn:cap:script:{act}:{name}`) or a namespace of them (`urn:cap:script:{act}:{namespace}-*`), and an exclusion (`…:-{name}`) takes one back out

bob> source urn:script:team-a-open:result as=text/tab-separated-values priority=1
?number	?title
1	"Write the README"
2	"Fix the flaky test"

team-open and team-a-open are in the namespace (namespaces nest: team-a is one inside team). teammate is not: the wildcard comes only after a -, so team-* is not a prefix match on the letters. team-payroll is in it, and the exclusion wins, over a namespace grant and over an exact one alike.

The act stays spelled out in every grant (write, run, read, delete), so a namespace grant to run never publishes, and the wildcard comes only at the end, because the kernel has no infix wildcard to put anywhere else. There is no “every script” grant below root at all: urn:cap:script:write:* is spelled exactly like the family the script door declares (“holds some write grant”), and a string that meant “everything” in one reading and “something” in the other is the confusion a grant must not carry, so it grants nothing.

Bob’s urn:cap:script:run:team-* runs team-a-open, and its ceiling file allows the items, which Alice held when she published it.

The rule, once more

the termwho decideswhenwhat this chapter showed
declaredthe text, for SPARQLat publisha requires that disagrees is refused
the publisher’s grantthe publisher’s capabilityat publish, kept as a snapshotCarol cannot publish what she cannot read; Bob’s run of Alice’s query sees Alice’s graphs
the ceilingthe host, per scripton every run (cut the authority thread when it changes)the payroll leaves only after the cut; no file, no run
the runner’s capabilitythe door that minted iton every runAlice cannot run what she wrote; Carol’s run has nothing to narrow to
exclusionsthe publisher and the ceilingtravel with every runAlice’s -team-payroll is on Bob’s run of graphs

The script host wrote none of this as a second permission system. Every refusal above is either the kernel’s floor, checking what a description declares, or Capability::attenuate, which keeps a grant only when the runner holds it. The library’s part is to compute the request it hands to attenuation, and to refuse a publish that would make that request a lie.

A sentence becomes a draft

The chapters before this one published code a person wrote, and Your first script showed a stand-in model drafting one in passing. This one lets a language model write it, and the interesting part is not the model. It is how little the model is allowed to do.

ikigai-nl turns a sentence into a SPARQL query with the model as a drafter, never an actor. Every step is a resource read or write under the CALLER’s capability, never more: what the model is told is what the caller could read for themselves, what it writes is checked mechanically before anybody sees it, and the most its text can ever become is a DRAFT script, visible to nobody but the person who asked, until that person reads it and publishes it. The model runs nothing, saves nothing under its own name, and holds no grant at all.

The steps, each a resource:

the stepthe namewhat it does
groundurn:nl:groundingwhat the caller may do and see, as data: actions, vocabulary, each readable graph’s shape, worked examples
drafturn:nl:prompt:sparql, then the host’s urn:llm:askthe ask and the grounding, filled into a prompt that is itself a resource, sent to the model door
checkurn:nl:sparql:checkparse, derive the authority, check every predicate and class, and a dry run
repairurn:nl:prompt:sparql-repaira refused draft, or one whose preview is empty, goes back with what the check found in it, up to a bound
saveurn:script:{name} with state=drafta Sink under the caller, so the script host checks it once more

urn:nl:sparql runs the five in order. Every one is also a name a person can ask directly, which is how this chapter shows them.

The host, and a model that is not one

crates/sentence-draft mounts ikigai-nl on the host from Scripts are resources (the script host and the store, with the same items), beside the vocabulary the grounding cites and a model door:

/// The drafting host: the previous chapters' script host and store, then `ikigai-nl`, the
/// vocabulary its grounding cites, and a model door. The grounding's worked examples are named
/// rather than read from the live catalog, so a grounding stays cached until what it read
/// changes.
pub fn kernel(model: &Model) -> Arc<Kernel> {
    kernel_with(model, Vec::new())
}

/// [`kernel`], with `yours` mounted after the script host and the store and in front of
/// `ikigai-nl`, the vocabulary and the model: for the exercises, a space of the reader's own
/// binds names the drafting host does not (or answers `urn:llm:ask` before the model does).
pub fn kernel_with(model: &Model, yours: Vec<Arc<dyn Space>>) -> Arc<Kernel> {
    let nl = SpaceConfig::new().examples(Examples::Named(vec!["open-items".to_string()]));
    let mut beside = yours;
    beside.extend([
        Arc::new(ikigai_nl::space(nl)) as Arc<dyn Space>,
        Arc::new(ikigai_vocab::space()) as Arc<dyn Space>,
        model.space(),
    ]);
    script_host::kernel_beside(same_for_all(Ceiling::unbounded()), beside)
}

The model door, in every gate this book runs, is a stub: an endpoint at urn:llm:ask that speaks ikigai-llm’s answer format and answers each ask with a canned draft per attempt. It is the same shape as the stub ikigai-nl’s own tests use, and for the same reason: what is under test is the funnel around the model, which is deterministic, and never the model, which is not. Nothing in a gate reaches a network. The stub declares the same grant the real door does (urn:cap:net:*, “holds some network grant”), so a caller who could not reach a model cannot reach this one either. Its drafts are written down where a test can read them:

/// The stub's first draft for [`ASK_TITLES`]: `ledger:state` is not a predicate the items
/// graph uses (it says `ledger:status`), and the check says so.
pub const TITLES_FIRST: &str = "PREFIX ledger: <https://ikigai-rs.dev/ns/ledger#>
PREFIX dcterms: <http://purl.org/dc/terms/>
SELECT ?title WHERE {
  GRAPH <urn:iki:tutorial:items> { ?item ledger:state ledger:open ; dcterms:title ?title }
} ORDER BY ?title";

/// Its repair, after the model is shown what was refused.
pub const TITLES_REPAIRED: &str = "PREFIX ledger: <https://ikigai-rs.dev/ns/ledger#>
PREFIX dcterms: <http://purl.org/dc/terms/>
SELECT ?title WHERE {
  GRAPH <urn:iki:tutorial:items> { ?item ledger:status ledger:open ; dcterms:title ?title }
} ORDER BY ?title";

/// What a 70B model drafted for [`ASK_P2`] in the live demo, with the demo's graph renamed to
/// this book's: a string `\"open\"` where the status is the IRI `ledger:open`, `FROM` paired with
/// `GRAPH`, and the priority bound named `limit`.
pub const P2_DRAFT: &str = "# @param limit xsd:integer optional default 2
SELECT ?number ?title
FROM <urn:iki:tutorial:items>
WHERE {
  GRAPH <urn:iki:tutorial:items> {
    ?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";

/// The query a person writes after reading [`P2_DRAFT`]: the status as the IRI it is, one
/// `GRAPH` and no `FROM`, and the bound written into the text.
pub const P2_CORRECTED: &str = "PREFIX ledger: <https://ikigai-rs.dev/ns/ledger#>
PREFIX dcterms: <http://purl.org/dc/terms/>
SELECT ?number ?title
WHERE {
  GRAPH <urn:iki:tutorial:items> {
    ?item a ledger:Item ;
          ledger:status ledger:open ;
          ledger:priority ?priority ;
          ledger:number ?number ;
          dcterms:title ?title .
    FILTER (?priority <= 2)
  }
}
ORDER BY ?number";

The people are the previous chapter’s, holding what drafting needs:

/// Drafts. Reads the items graph and the example, may reach the model (which, like every
/// call the drafter makes, runs under HER capability), and may write, read and run the one
/// script name she drafts into.
pub fn alice() -> Capability {
    person(
        "alice",
        &[
            READ_ITEMS,
            "urn:cap:script:read:open-items",
            "urn:cap:net:localhost:11434",
            "urn:cap:script:write:open-titles",
            "urn:cap:script:read:open-titles",
            "urn:cap:script:write:open-p2",
            "urn:cap:script:read:open-p2",
            "urn:cap:script:run:open-p2",
        ],
    )
}

/// Holds the same read grant on `open-p2` as Alice, and the payroll graph, but drafted
/// nothing.
pub fn bob() -> Capability {
    person(
        "bob",
        &[READ_ITEMS, READ_PAYROLL, "urn:cap:script:read:open-p2"],
    )
}

/// Holds nothing but the network: may ask, can see nothing.
pub fn carol() -> Capability {
    person("carol", &["urn:cap:net:localhost:11434"])
}

What the model may know is what the caller may see

cargo run -p sentence-draft -- 1
alice> source urn:nl:grounding as=application/json   (summarized)
actions     27 of 27
vocabulary  252 of 252
graphs      1 of 1
  urn:iki:tutorial:items  20 triples, 5 predicates
    dcterms:title    literal xsd:string
    rdf:type         iri
    ledger:number    literal xsd:integer
    ledger:priority  literal xsd:integer
    ledger:status    iri: ledger:open ledger:closed
examples    1 of 1
  open-items (sparql, sha256:e1977365…)

carol> source urn:nl:grounding as=application/json   (summarized)
actions     8 of 8
vocabulary  252 of 252
graphs      0 of 0
examples    0 of 0

The grounding is a composition: it holds no state, and every part of it is a read through the kernel under the caller’s capability. The actions are the caller’s own manifold (urn:kernel:actions, the tool list an agent would get), with each action’s arguments. The vocabulary is the ik: terms. The graphs are the store’s own listing of what the caller may read, each summarized by a few stored queries (its classes and predicates, with counts, and a few sample triples). Each predicate also says what KIND of value it takes, an IRI or a literal of some datatype, and, when its values are a small closed set, what they are: ledger:status is the IRI ledger:open or ledger:closed, never the string "open". That line exists because of the draft in section 4. The examples are published scripts the caller may read: here the host names one, open-items from Scripts are resources, and the grounding shows it to Alice and not to Carol, by asking the script host’s own rule.

So Carol’s grounding offers eight actions and no data at all, and the payroll graph is not in Alice’s, because she cannot read it. (It would be in Bob’s, because he can.) A graph the caller cannot read contributes nothing, not even its name, and a model cannot leak what it was never shown. (The actions are in the grounding for a drafter that writes calls. One that writes a query reads only the vocabulary, the graphs and the examples, with parts=: a query is not an action call, and on a host with thousands of actions they are most of the grounding’s size.)

The grounding is cached per capability, and hangs from every thread its parts hang from, so a write to a graph it summarized, a change of bindings, or a republished example recomputes it. Its parts each carry a sha256 identity, and the grounding is named by the digest of its parts, so a draft can record exactly which grounding it was drafted from.

The check

urn:nl:sparql:check is the mechanical half, and it is a resource of its own so that a person editing a draft can ask the same question the drafter asks:

cargo run -p sentence-draft -- 2
alice> source urn:nl:sparql:check focus=urn:iki:tutorial:items   (a query naming ledger:state, piped)
invalid select over <urn:iki:tutorial:items>
requires: urn:cap:store:read:graph:urn:iki:tutorial:items
error: 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

alice> source urn:nl:sparql:check   (a query over the payroll graph, piped)
invalid select over <urn:iki:tutorial:payroll>
requires: urn:cap:store:read:graph:urn:iki:tutorial:payroll
error: the query reads <urn:iki:tutorial:payroll>, which is not a graph this caller may read: read only the graphs the grounding lists
warning: the predicate <urn:iki:tutorial:salary> is used by none of the graphs this query reads and is not in the vocabulary, as far as the grounding shows (it lists the first predicates of a graph, not all)

alice> source urn:nl:sparql:check   (the same query over a graph that does not exist, piped)
invalid select over <urn:iki:tutorial:nowhere>
requires: urn:cap:store:read:graph:urn:iki:tutorial:nowhere
error: the query reads <urn:iki:tutorial:nowhere>, which is not a graph this caller may read: read only the graphs the grounding lists
warning: the predicate <urn:iki:tutorial:salary> is used by none of the graphs this query reads and is not in the vocabulary, as far as the grounding shows (it lists the first predicates of a graph, not all)

The check parses the draft with the script host’s own analysis, so it reads the form, the parameters and the dataset exactly as the Sink that saves it will. An update is refused: a draft reads. It derives the authority the script host would derive, and compares it with the caller’s. It checks every predicate and every class the query names against the graphs it reads and the vocabulary. And it runs the query, with its parameters’ defaults bound, under a small LIMIT, through the store’s graph-scoped door: the dry run, whose rows are the preview.

The second and third answers are the same answer. A graph the caller cannot read is refused in the same words as a graph that does not exist, so neither the caller nor the model learns which it was. The warning about salary is the same in both for the same reason.

Every finding is a value in the answer, never a refusal, so the check caches like any read: the spreadsheet’s rule that a refusal is never cached, applied to a checker.

A sentence, drafted, checked, repaired and saved

    ask(
        &kernel,
        "alice",
        &alice,
        Verb::Sink,
        "urn:nl:sparql",
        &[
            ("ask", ASK_TITLES),
            ("focus", ITEMS),
            ("save", "open-titles"),
        ],
        &format!(
            "sink urn:nl:sparql ask=\"{ASK_TITLES}\" focus=urn:iki:tutorial:items save=open-titles"
        ),
    );
cargo run -p sentence-draft -- 3
alice> sink urn:nl:sparql ask="the titles of the open items" focus=urn:iki:tutorial:items save=open-titles
urn:script:open-titles:version:sha256:… (a DRAFT of urn:script:open-titles: review it, then publish it with state=published)

PREFIX ledger: <https://ikigai-rs.dev/ns/ledger#>
PREFIX dcterms: <http://purl.org/dc/terms/>
SELECT ?title WHERE {
  GRAPH <urn:iki:tutorial:items> { ?item ledger:status ledger:open ; dcterms:title ?title }
} ORDER BY ?title

preview (LIMIT 5):
?title
"Answer the issue"
"Fix the flaky test"
"Write the README"

(the model was asked 2 times)

focus= names the graph the ask is about, as it does for the grounding and the check. (Before ikigai-nl 0.3.0 the drafter alone called it graph=, which 0.4.0 still accepts as a deprecated alias.) The stub’s first draft said ledger:state, the check refused it, and the drafter asked again: the same prompt, followed by urn:nl:prompt:sparql-repair filled with the previous draft and exactly what the check found in it, nothing else. The second draft passed, and was saved. The bound on attempts is 3 by default, and drafting also stops the first time a model answers with a draft it already gave, because a model repeating itself is not converging. Three is a cost bound, each attempt one model call; nobody has measured where repair stops paying.

Saving is a Sink on urn:script:open-titles with state=draft, under Alice’s capability. That is the last validation, and the one that matters most: the script host analyzes the text again and refuses a draft whose derived authority Alice does not hold. A caller who may not write the name gets the valid draft answered, unsaved. A draft that never validates is answered as failed, with every attempt and what was wrong with it, and no query. The saved text opens with its provenance as comment lines (the ask, the grounding’s identity, the prompts’ digests, every attempt and what the checks found), so it is in the version’s digest, and the same graph is the answer’s as=text/turtle face.

A draft the check passed, and should not have

This one is not a hypothetical. It is what a 70-billion-parameter model drafted for a real ask on gonk’s work ledger on 2026-10-11, with the graph renamed to this book’s and its spacing tidied, and the stub answers it word for word:

cargo run -p sentence-draft -- 4
alice> sink urn:nl:sparql ask="the number and title of every open item with priority 2 or higher (0 is highest), ordered by number" focus=urn:iki:tutorial:items save=open-p2
urn:script:open-p2:version:sha256:… (a DRAFT of urn:script: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:tutorial:items>
WHERE {
  GRAPH <urn:iki:tutorial:items> {
    ?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:tutorial:items> { ?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

(the model was asked 2 times)

Every check passed: it parses, it reads a graph Alice may read, every predicate and class it names exists there. What is wrong with it is not a fact about names. It compares the status to the string "open", and the status in this graph (and in gonk’s) is the IRI ledger:open; a string is never equal to an IRI, so nothing matches.

The preview says so. An empty preview is not an error, because an empty answer is sometimes the right one (“which items are overdue?” on a good day), so the check cannot refuse it. It is a warning, with its evidence: the check runs each triple pattern of the query alone, in its graph, and names the ones that match nothing on their own. Here that is exactly one pattern, the string "open". And the drafter treats a valid draft with an empty preview as a reason to repair: it sent the model the draft and that warning, the way it sends a refused draft and its errors in section 3. The stub answers this ask with the live demo’s draft whatever it is told, so its second answer was its first again, and drafting stopped there, because a model repeating itself is not converging. A real model, told that ledger:status takes the IRI ledger:open (the grounding now says so) and shown the one pattern that matched nothing, has what it needs to fix it.

When drafting stops with the preview still empty, the draft is kept, and marked: the first line of the answer says the preview is EMPTY and to review it, the JSON face says emptyPreview, and in its provenance the attempt it derives from carries the warning. Nothing is saved silently. The mark also says what stopped drafting: here “drafting stopped: the model repeated attempt 1”, because there were two attempts, not the bound of 3, and the second gave the first again. That second attempt is recorded as a repeat, not as a new draft: it is not checked again, so it stays invalid with one error naming the attempt it repeats, its JSON carries repeatOf: 1, and in the provenance it is prov:alternateOf attempt 1. Had all three attempts been tried, the mark would say “no attempt within the bound of 3 did better”. (Before ikigai-nl 0.4.0 it said that either way.)

Two smaller things are wrong too. It names the priority bound limit, a parameter nobody asked for whose name reads like a row count. And it pairs FROM <g> with GRAPH <g> { … }, which in standard SPARQL finds nothing on its own: FROM sets only the default graph, so a GRAPH block has no named graphs to match. On this host that pairing happens to be harmless, because the script host runs a query over one set of graphs that serves as both the default graph and the named ones; the crate’s tests (crates/sentence-draft/tests/claims.rs) pin that the string alone empties this preview, and that the check names that pattern and no other. Run against an ordinary SPARQL endpoint, it would be wrong twice.

So the mechanical half caught what it could, said why, and asked again. What it cannot do is decide that an empty answer is wrong; a person reading the marked draft still does that.

A draft is nobody’s script yet

cargo run -p sentence-draft -- 5
bob> source urn:script:open-p2
error: not found: no script is published at urn:script:open-p2

host> source urn:script:open-p2:result
error: conflict: urn:script:open-p2 is a draft; publish it (`state=published`) to run it

alice> source urn:script:open-p2:result
error: conflict: urn:script:open-p2 is a draft; publish it (`state=published`) to run it

Bob holds the read grant for open-p2, and to him the draft does not exist: NotFound, in the same words as a name nobody ever wrote. Denied would tell him “somebody has a draft here”, which is the thing privacy hides. A draft is visible only to its author, the principal the writer’s capability named, until it is published.

Nobody can run a draft, not even the host: its …:result is refused for its state, and says what to do about it. Alice, its author, is told the same thing in the same words: it is a draft, and publishing it is how to run it. A draft has no contract of its own until it is published (a script’s own catalog entry is made from its published text), so it wears the generic one, which declares only the run grant; the language’s own grant (the Lisp evaluator’s, for a Lisp script) joins it when a script of that language is published. The state is reported before any run grant is checked, because anyone who can see the draft may know it is one.

(Before ikigai-script 0.3.3 the generic contract declared the Lisp evaluator’s grant, so Alice, who holds no Lisp grant, was refused for that by the kernel’s floor before the script host could say “draft”: a true refusal, for a reason that had nothing to do with a SPARQL query. The host’s answer also pointed at urn:script:eval, which runs only Lisp. Both are fixed; the hint is now offered only for a language that door takes.)

A person corrects it, checks it, and publishes it

cargo run -p sentence-draft -- 6
alice> source urn:nl:sparql:check   (the corrected query, piped)
valid select over <urn:iki:tutorial:items>
requires: urn:cap:store:read:graph:urn:iki:tutorial:items
preview (LIMIT 5):
?number	?title
"1"^^<http://www.w3.org/2001/XMLSchema#integer>	"Write the README"
"2"^^<http://www.w3.org/2001/XMLSchema#integer>	"Fix the flaky test"

alice> sink urn:script:open-p2 language=sparql state=published   (the corrected query, piped)
urn:script:open-p2:version:sha256:16c96a61…

alice> source urn:script:open-p2:result as=text/tab-separated-values
?number	?title
1	"Write the README"
2	"Fix the flaky test"

bob> source urn:script:open-p2
…
ORDER BY ?number

Alice writes the status as the IRI it is, drops the FROM, writes the bound into the text, and asks the same check the drafter asked. Then she publishes, with her own Sink, and it is a script like any other: Bob can read it, a runner can run it under the previous chapter’s rule, and the draft’s history is kept with it. The publish is the act, and a person made it.

Against a real model

A reader with Ollama running can put a real model behind the same door. The program swaps the stub for ikigai-llm’s urn:llm:ask, which reaches the local server through an HTTP seam the host fills:

    let model = match args.iter().position(|a| a == "--ollama") {
        Some(at) => Model::Ollama(args.get(at + 1).cloned().unwrap_or("llama3.1".into())),
        None => Model::stub(),
    };
    let kernel = kernel(&model);
cargo run -p sentence-draft -- 3 --ollama llama3.1

Every step is the same resource, under the same capability. Alice holds urn:cap:net:localhost:11434, the port-aware grant ikigai-llm checks before it reaches the server, and a caller holding no network grant could not draft at all. What changes is what comes back: the number of attempts, whether the first draft passes, and the query itself, none of which a book can promise. Section 4 is the one to run more than once: a model shown the warning and the value kinds may repair the status on its second attempt, or may not. A model that gets the status right is answering this ask, not every ask, and the mark on an empty preview is still the signal to look for.

What was built

the setthe namewhat it is
the drafting hostsentence_draft::kernelcomposition: the host from Scripts are resources, ikigai-nl, the vocabulary, a model door
the stub modelurn:llm:ask (in every gate)code: canned drafts per attempt, the prompts it was sent, no network
a real modelurn:llm:ask (with --ollama)composition: ikigai-llm over a blocking HTTP client
a groundingurn:nl:groundingcomposition, ikigai-nl’s: the caller’s own view, cached per capability
a checkurn:nl:sparql:checkcomposition, ikigai-nl’s: findings as values, so it caches
a drafturn:script:{name} with state=draftan atom, the script host’s, written under the caller and private to them

The one piece of code is the stub, and it is code for the reason the clock in “Time is a resource” was: it stands in for something outside the kernel that a gate cannot run. Everything else is configuration. The model’s place in all of it is one door, called under the caller’s capability, whose answer is text that is parsed, checked, and saved as something a person has to read.

Exercises: code and language

Four, and all four are written in your crate — crates/your-endpoints/tests/code.rs — against the code this section quotes, crates/script-host, crates/script-authority and crates/sentence-draft, which you use and do not edit. The file already holds the scaffolding: publish (a SPARQL script, under a capability), result (an answer, and whether it came from the cache), person (a capability naming its principal, as a door would mint it), stub_model (a stub with drafts of your own), and one test proving all of it works. The loop is:

cargo test -p your-endpoints --test code

1. Publish a query, and find the thread it hangs from

Publish a query called closed-items that lists the CLOSED items, by number and title. Read its result twice. Then run close-item from Scripts are resources for item 4, and read it again.

  • File — crates/your-endpoints/tests/code.rs; start from script_host::kernel().
  • Run — cargo test -p your-endpoints --test code
  • Right when — the first read is computed and does not list “Answer the issue”; the second is answered from the cache; after the run, the read is computed again and lists it.
  • Read again — Scripts are resources, “Running is a read” and “Running is a write”.
Hint

Alice holds grants for open-items and close-item, not for your script, so the first thing to write is a person who may publish and run closed-items. Which grants? The publish needs urn:cap:script:write:closed-items, and is refused unless your person holds what the text derives (urn:cap:store:read:graph:urn:iki:tutorial:items). Running close-item needs its run grant and both graph grants the UPDATE derives, because a run is the runner’s capability, narrowed. close-item must be published first; the host (Capability::root()) can do that.

You did not tell the result to recompute when an item closes, and you cannot: there is nowhere to say it. If the third read comes back [cached], look at whether your query really reads the graph the update writes.


2. Narrow a ceiling, and find the read that should have changed

On script_authority::kernel(&ceilings), publish GRAPHS as every-graph under the host, and have Bob read it. Then change every-graph’s ceiling file to the payroll graph alone, and read again, and again after the cut the chapter makes. Then mint Dave, who holds urn:cap:script:run:team-* and the payroll graph, and have him run Alice’s team-open (publish it as Alice first, as the chapter does).

  • File — crates/your-endpoints/tests/code.rs; Ceilings::new() and set are script_authority’s.
  • Run — cargo test -p your-endpoints --test code
  • Right when — before the cut Bob still sees the items graph; after kernel.cut("urn:script:every-graph:authority") he sees the payroll and only the payroll; and Dave’s run is refused with Error::Denied.
  • Read again — Who a script runs as, “Nor more than the host’s ceiling allows”.
Hint

The assertion that the read did NOT change after the edit is the one worth writing on purpose. It is the trap the chapter showed, and a test that asserts only the after-the-cut answer would pass on a host that forgot the cut, so long as nothing else had been cached.

Dave holds the namespace grant to run team-open, so the run gate lets him in. What refuses him is the graph: team-open is OPEN_ITEMS, which reads the items, and Dave’s capability has only the payroll in it. Narrowing can only keep what is already there.


3. Script a model whose draft never validates

Script a stub whose only draft, for the ask “delete everything”, is an UPDATE: DELETE WHERE { GRAPH <urn:iki:tutorial:items> { ?s ?p ?o } }. Draft with it, as a person who may write the script wipe and holds both item grants and a network grant, with save=wipe.

  • File — crates/your-endpoints/tests/code.rs; stub_model gives you the model and the stub’s record of its prompts. sentence_draft::kernel(&model) is the host.
  • Run — cargo test -p your-endpoints --test code
  • Right when — the draft’s status (read its JSON face into ikigai_nl::Draft) is Status::Failed; there are exactly two attempts and the stub was asked twice; and Exists urn:script:wipe answers false.
  • Read again — A sentence becomes a draft, “The check” and “A sentence, drafted, checked, repaired and saved”.
Hint

Your person holds the WRITE grant on the items graph, so this is not the capability check refusing the draft: the check refuses any UPDATE, because a draft reads. Read both attempts’ errors. The second one is not about the UPDATE at all. Why did drafting stop at two when the bound is three?

Nothing was saved, and nothing ran. A model that answers with a destructive query on every attempt has produced, at the end, an answer saying so.


4. Add the style note ledger item 1242 left out, as a composition

The live demo’s draft pairs FROM <g> with GRAPH <g> { … }. On this host that is harmless (the script host runs a query over one set of graphs that serves as both its default and its named graphs), so ledger item 1242 dropped it as a check, and urn:nl:sparql:check says nothing about it. Against an ordinary SPARQL endpoint it finds nothing. Bind an endpoint of your own at urn:iki:tutorial:yours:checked that takes content, asks urn:nl:sparql:check for its JSON face, and answers that check with one more warning when the query names a graph both in a FROM and in a GRAPH. Mount it with sentence_draft::kernel_with, and ask it about the live demo’s draft and about the corrected query.

  • File — crates/your-endpoints/tests/code.rs; sentence_draft::{P2_DRAFT, P2_CORRECTED} are the two queries, and ikigai_nl::Check is the type of the JSON face (its graphs are the graphs the text names).
  • Run — cargo test -p your-endpoints --test code
  • Right when — P2_DRAFT comes back with your warning beside the check’s own (its preview is empty), and P2_CORRECTED comes back with neither.
  • Read again — A sentence becomes a draft, “A draft the check passed, and should not have”; and Binding, and a host of your own.
Hint

AsyncFnEndpoint is the shape: it issues a sub-request through inv.issue(…), under the caller’s capability, so your endpoint can never show anybody a preview they could not have asked for. Give it a Description with content as its input, or nothing can route an argument to it by name. For each graph in the check’s graphs, look for FROM <g> and GRAPH <g> in the text; a text search is enough for this exercise, though a parser would not be fooled by a comment.

This is the resource-first version of a check, and it is worth asking what it does not do. It warns a person who asks; the drafter never sees it, because the drafter asks urn:nl:sparql:check by name, and your endpoint is a different name. That is the difference between this note and the empty preview: ikigai-nl 0.3.0 made the empty preview a reason to repair by changing the check and the drafter, which is where a finding has to live for a model to be told about it.

Streams, I: bytes are a representation

Every answer in this book so far has been text: a title, a CSV table, Turtle, an SVG picture. That is a habit of the examples, not a rule of the kernel. A representation is two things, a media type and bytes, and nothing requires the bytes to be text. This part of the book is about bytes: how they travel, how big they get, and what a representation can and cannot do with them. This first page needs only what the kernel already has, and its cells run in this page.

Two fields

ikigai-core’s Representation holds a repr_type, whose media_type says what the bytes are (text/csv, image/svg+xml, application/gzip), and bytes, a Vec<u8>. It also carries an expiry and its golden threads, which is how the cache treats it, but those say how long the answer is good for, not what it is. The kernel does not look inside bytes. It caches them, passes them through a pipe, and hands them to the next endpoint. Whether they are UTF-8 matters only to whoever reads them.

So binary is ordinary. A gzip stream, a PNG or a PDF is a representation exactly as the shop’s stock is, and resolving one is the same Source.

Bytes that are not text

The page’s kernel links ikigai-compress, which binds gzip and zlib as resources: urn:compress:gzip compresses whatever is piped into it and answers application/gzip, and urn:decompress:gzip reads it back. The shop’s stock from Transreption is the input, first as it is and then compressed:

item,shelf,count
flour,A,12
sugar,A,0
rice,B,30
lentils,B,7
[computed]
error: output is not UTF-8 text (83 bytes) — pipe it into a `sink` to store the bytes

The second line resolved. The refusal is the cell’s, not the kernel’s: a cell, like the REPL, prints text, and 83 bytes of gzip are not text, so it names how many there are instead of printing them. The compressed table is also larger than the 60 bytes it came from. gzip’s header and trailer cost 18 bytes, and a table this small has too little repetition to win them back.

Those 83 bytes were computed, and cached, like any other answer. Pipe them on to urn:decompress:gzip and the table comes back. A gzip stream records no media type for what it holds, so the line says what it is with as=:

item,shelf,count
flour,A,12
sugar,A,0
rice,B,30
lentils,B,7
[2 cached · 1 computed]

Two of the three stages were cached: the stock, and the gzip stream the cell above could not print. That is only possible because ikigai-compress writes the same bytes for the same input every time. gzip’s header has a timestamp and an operating-system byte, and the crate fixes both, so a cached stream is the stream a second compression would have made. Without as= the bytes come back labeled application/octet-stream, which means “bytes, of a type nobody stated”. The label is the caller’s claim: as= says what the bytes are, and nothing checks it.

Transrepted by type

Transreption described a transreptor as an endpoint that declares what it converts between, so the kernel can plan a route through it without anyone naming it. The four gzip and zlib endpoints declare exactly that: urn:decompress:gzip converts application/gzip to the payload’s types, losslessly. The Rust below runs in mdbook test. It compresses the same table natively, looks at the bytes, and asks the kernel for a route out of application/gzip:

#![allow(unused)]
fn main() {
extern crate futures;
extern crate ikigai_compress;
extern crate ikigai_core;
use std::sync::Arc;

use futures::executor::block_on;
use ikigai_core::{ArgRef, Capability, Iri, Kernel, Request, Verb};

let kernel = Kernel::new(Arc::new(ikigai_compress::space()));
let root = Capability::root();
let stock = "item,shelf,count\nflour,A,12\nsugar,A,0\nrice,B,30\nlentils,B,7\n";
let call = |iri: &str, args: &[(&str, &[u8])]| {
    let mut request = Request::new(Verb::Source, Iri::parse(iri).unwrap());
    for (name, value) in args {
        request = request.with_arg(*name, ArgRef::Inline(value.to_vec()));
    }
    block_on(kernel.issue(request, &root))
};

// Compressed: a media type, and 83 bytes that start with gzip's magic number.
let gz = call("urn:compress:gzip", &[("content", stock.as_bytes())]).unwrap();
assert_eq!(gz.repr_type.media_type, "application/gzip");
assert_eq!(gz.bytes.len(), 83);
assert_eq!(gz.bytes[..2], [0x1f, 0x8b]);
assert!(String::from_utf8(gz.bytes.clone()).is_err());

// The same input, the same bytes: what makes the stream safe to cache.
let again = call("urn:compress:gzip", &[("content", stock.as_bytes())]).unwrap();
assert_eq!(again.bytes, gz.bytes);

// Read back: the bytes are the table, and the label is whatever the caller says.
let back = call("urn:decompress:gzip", &[("content", &gz.bytes)]).unwrap();
assert_eq!(back.repr_type.media_type, "application/octet-stream");
assert_eq!(back.bytes, stock.as_bytes());
let csv = call("urn:decompress:gzip", &[("content", &gz.bytes), ("as", b"text/csv")]).unwrap();
assert_eq!(csv.repr_type.media_type, "text/csv");

// And a route out of gzip that nobody named: the planner found it by its declaration.
let plan = kernel.select_transreptor("application/gzip", "text/csv").unwrap();
assert_eq!(plan.len(), 1);
assert_eq!(plan[0].endpoint, "urn:decompress:gzip");
assert!(plan[0].lossless);
}

The bound refuses

A few hundred bytes of gzip can expand to gigabytes, and a decompressor is handed bytes from somewhere else. So urn:decompress:gzip takes max-bytes, 64 MiB by default, which a caller may lower, or raise to at most 1 GiB. Past the bound, it refuses. It does not return the first max-bytes of the payload, because a truncated CSV table, or a truncated Turtle document, is often still well formed, and nothing downstream could tell it from the real one. The table is 60 bytes, so a bound of 59 refuses, and 60 is enough:

error: invalid argument `content`: decompressed output exceeds the 59-byte limit set by `max-bytes`; refusing rather than returning a truncated representation (raise `max-bytes`, up to 1073741824)
       `content`: the gzip stream to expand — arbitrary bytes, not necessarily UTF-8; filled from the pipe
item,shelf,count
flour,A,12
sugar,A,0
rice,B,30
lentils,B,7
[2 cached · 1 computed]

The refusal names the limit, and the bound that refused is the one the line set. The decompressor stops reading one byte past max-bytes, so refusing a bomb costs one byte over the limit, not the whole expansion. The same listing natively:

#![allow(unused)]
fn main() {
extern crate futures;
extern crate ikigai_compress;
extern crate ikigai_core;
use std::sync::Arc;

use futures::executor::block_on;
use ikigai_core::{ArgRef, Capability, Error, Iri, Kernel, Request, Verb};

let kernel = Kernel::new(Arc::new(ikigai_compress::space()));
let root = Capability::root();
let stock = "item,shelf,count\nflour,A,12\nsugar,A,0\nrice,B,30\nlentils,B,7\n";
let issue = |request: Request| block_on(kernel.issue(request, &root));
let gzip = Request::new(Verb::Source, Iri::parse("urn:compress:gzip").unwrap())
    .with_arg("content", ArgRef::Inline(stock.as_bytes().to_vec()));
let gz = issue(gzip).unwrap().bytes;
let gunzip = |max: &str| {
    Request::new(Verb::Source, Iri::parse("urn:decompress:gzip").unwrap())
        .with_arg("content", ArgRef::Inline(gz.clone()))
        .with_arg("max-bytes", ArgRef::Inline(max.as_bytes().to_vec()))
};

let refused = issue(gunzip("59")).unwrap_err();
assert!(matches!(refused, Error::InvalidArgument { .. }));
assert!(refused.to_string().contains("exceeds the 59-byte limit"));
assert_eq!(issue(gunzip("60")).unwrap().bytes, stock.as_bytes());
}

What a representation cannot do yet

Everything above worked because every representation on this page is small. Look at what the two fields cannot say:

  • It is one buffer. bytes is a Vec<u8>: the whole answer, in memory, before anyone can read the first byte. The decompressor has to hold all of its output before it answers, which is one reason its bound exists.
  • It has no beginning before its end. The first line of the table is ready as soon as it is decompressed, but nothing can be handed on until the last line is. A pipe of three stages holds three whole buffers.
  • The bound is the only limit. Anything bigger than memory, or bigger than a sensible max-bytes, cannot be a representation at all, and a stream with no end, such as a log that is still being written, never finishes being one.

That is where this part goes next: a representation whose bytes arrive in pieces, so an answer can start before it ends and a large one never has to be held whole (ledger #2228). The kernel now has its first half, and the next page reads it: a body that can be read again, a file as one, and a range of a resource that reads only the range.

Streams, II: a body you can read again

The first streams page ended on what a representation could not do: its bytes were one buffer, held whole before anyone read the first byte. The kernel now has the start of an answer, and this page covers the part of it that can be read more than once: a body, which may be a buffer or something that opens a fresh reader each time it is asked, and a byte range, which reads part of a resource without reading the rest. The ranges run in this page’s kernel. Files do not, because the page has no file system, so the listings that read a file run in mdbook test.

Three shapes

ikigai-core’s Body is what a Representation<Body> carries where a Representation carries a Vec<u8>. It has three shapes, and an endpoint answers with the weakest one it can honor:

  • Body::Bytes: a buffer, as before, behind a shared handle, so a cache hit hands it out without copying it.
  • Body::Replay: something that opens a fresh reader from the first byte every time it is asked. A file is one, and so is an object in a store. It may also open a reader at an offset, and it may know its length before anyone reads it.
  • Body::Once: a one-shot stream, such as a generator, a live source or an upload in flight. It can be read once.

Two questions are kept apart. The expiry says how long the bytes are true, and the body’s shape says whether they can be read again. The kernel caches a Bytes or a Replay answer exactly as it caches bytes, and never caches a Once answer: what it keeps has to be readable by the next caller, and a one-shot stream has been read by the first.

An endpoint written before bodies existed still answers a Vec<u8>, and the kernel hands it on as Body::Bytes. An endpoint that has a body to offer implements invoke_body beside invoke, and a caller that wants the body rather than a buffer calls Kernel::issue_body. Kernel::issue, which every page so far has used, collects whatever body comes back into one buffer, under the run’s byte budget.

A body of your own

Replay is a trait with one required method, open, and three optional ones: open_at, for a reader that starts at an offset; len_hint, the length when it is known before reading; and validator, the content id of exactly these bytes when the producer knows it. The listing below answers the shop’s stock as a Replay that counts how often it is opened, and runs in mdbook test:

#![allow(unused)]
fn main() {
extern crate async_trait;
extern crate futures;
extern crate ikigai_core;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;

use async_trait::async_trait;
use futures::executor::block_on;
use futures::StreamExt;
use ikigai_core::{
    Body, ByteStream, Bytes, Capability, Endpoint, EndpointSpace, Exact, Invocation, Iri, Kernel,
    Replay, ReprType, Representation, Request, Result, Verb,
};

const STOCK: &[u8] = b"item,shelf,count\nflour,A,12\nsugar,A,0\nrice,B,30\nlentils,B,7\n";

/// The stock, read again on every open, counting the opens.
struct Shelf(Arc<AtomicUsize>);

impl Replay for Shelf {
    fn open(&self) -> ByteStream {
        self.0.fetch_add(1, Ordering::SeqCst);
        // Two chunks, as a file read in pieces would yield them.
        let (head, rest) = STOCK.split_at(17);
        let chunks = [head, rest].map(|chunk| Ok(Bytes::from_static(chunk)));
        Box::pin(futures::stream::iter(chunks))
    }

    fn len_hint(&self) -> Option<u64> {
        Some(STOCK.len() as u64)
    }
}

struct Stock(Arc<AtomicUsize>);

#[async_trait]
impl Endpoint for Stock {
    async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
        let body = self.invoke_body(inv).await?;
        body.collect_within(inv.memory_budget().expect("the kernel mints one")).await
    }

    async fn invoke_body(&self, _inv: &Invocation<'_>) -> Result<Representation<Body>> {
        let body = Body::replay(Shelf(self.0.clone()));
        Ok(Representation::from_body(ReprType::new("text/csv"), body).cacheable())
    }
}

let opens = Arc::new(AtomicUsize::new(0));
let space = EndpointSpace::new().bind(Exact::new("urn:shop:stock"), Stock(opens.clone()));
let kernel = Kernel::new(Arc::new(space));
let root = Capability::root();
let read = || Request::new(Verb::Source, Iri::parse("urn:shop:stock").unwrap());

// The body API hands the replay over unread: nothing has opened it yet.
let answer = block_on(kernel.issue_body(read(), &root)).unwrap();
assert!(matches!(answer.bytes, Body::Replay(_)));
assert_eq!(answer.bytes.len_hint(), Some(60));
assert_eq!(opens.load(Ordering::SeqCst), 0);

// Every open is a fresh reader from the first byte, so it can be read twice.
let Body::Replay(replay) = answer.bytes else { unreachable!() };
let read_all = |stream: ByteStream| {
    block_on(stream.map(|chunk| chunk.unwrap()).collect::<Vec<_>>()).concat()
};
assert_eq!(read_all(replay.open()), STOCK);
assert_eq!(read_all(replay.open()), STOCK);
assert_eq!(opens.load(Ordering::SeqCst), 2);

// It was cached as a replay: the second request is a hit that opens nothing new.
assert!(kernel.is_cached(&read(), &root));
let again = block_on(kernel.issue_body(read(), &root)).unwrap();
assert!(matches!(again.bytes, Body::Replay(_)));
assert_eq!(opens.load(Ordering::SeqCst), 2);

// The bytes API collects it, one more open.
assert_eq!(block_on(kernel.issue(read(), &root)).unwrap().bytes, STOCK);
assert_eq!(opens.load(Ordering::SeqCst), 3);
}

The kernel did not read the body to cache it. It stored the replay, as it would have stored a buffer, and the cached answer opens a fresh reader for whoever asks. Nothing in the kernel holds the 60 bytes unless a caller collects them.

A one-shot body is the other case. The same endpoint answering Body::once over the same two chunks is never cached:

#![allow(unused)]
fn main() {
extern crate async_trait;
extern crate futures;
extern crate ikigai_core;
use std::sync::Arc;

use async_trait::async_trait;
use futures::executor::block_on;
use ikigai_core::{
    Body, Bytes, Capability, Endpoint, EndpointSpace, Exact, Invocation, Iri, Kernel, ReprType,
    Representation, Request, Result, Verb,
};

struct Live;

#[async_trait]
impl Endpoint for Live {
    async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
        let body = self.invoke_body(inv).await?;
        body.collect_within(inv.memory_budget().expect("the kernel mints one")).await
    }

    async fn invoke_body(&self, _inv: &Invocation<'_>) -> Result<Representation<Body>> {
        let chunks = [&b"item,shelf,count\n"[..], b"flour,A,12\n"]
            .map(|chunk| Ok(Bytes::from_static(chunk)));
        let body = Body::once(futures::stream::iter(chunks));
        Ok(Representation::from_body(ReprType::new("text/csv"), body).cacheable())
    }
}

let kernel = Kernel::new(Arc::new(EndpointSpace::new().bind(Exact::new("urn:shop:live"), Live)));
let root = Capability::root();
let read = || Request::new(Verb::Source, Iri::parse("urn:shop:live").unwrap());

let answer = block_on(kernel.issue_body(read(), &root)).unwrap();
assert!(answer.bytes.is_one_shot());
// Marked cacheable, and still not cached: a one-shot body cannot be read by the next caller.
assert!(!kernel.is_cached(&read(), &root));
}

A range of a resource

A body that can open a reader at an offset can be read in part. The kernel answers urn:stream:range itself, as it answers urn:kernel:*, so no module can bind it and every resource can be asked for one: of names the resource, start is the first byte’s offset, and end, which may be left out, is the offset just past the last. The stock’s header is 17 bytes and its first row is 11, so its first row is bytes 17 to 28. The second line asks again:

flour,A,12
[computed]
flour,A,12
[cached]

The range read the stock as a sub-request, under the caller’s capability, and kept its media type. It also hangs from the stock’s threads, so a write to the stock cuts every cached range of it. Bytes 38 to 48 are rice’s row. The second line below writes the stock without its empty shelf, as Transreption did, and the third asks for the same bytes again:

rice,B,30
[computed]
ok
[2 computed · 1 uncacheable]
0
lentils
[computed]

The write cut the cached range, so the third line was computed, from the new table. Its answer is not rice’s row, though, and not a whole row at all. Sugar’s row is gone, and urn:json:to-csv wrote the table with CRLF line ends, as Transreption noted, so every line left is one byte longer than it was. Bytes 38 to 48 are now the last digit of rice’s count, the \r\n after it, and the first seven bytes of lentils’ row. A range is offsets into bytes. It knows nothing about rows, lines or characters, and an offset taken from one version of a resource says nothing about the next.

The table is 54 bytes now. A range that starts at or past its end is refused, which is HTTP’s 416. One that ends past it reads to the end, so the last row, which starts at byte 41, is start=41 with any end beyond 54. And one that ends before it starts is refused before anything is read:

error: invalid argument `start`: the range starts at 54, past the end of `urn:iki:tutorial:shop:stock` (54 bytes)
       `start`: the first byte's offset
lentils,B,7
[computed]
error: invalid argument `end`: the range ends before it starts (20..10)
       `end`: the offset just past the last byte (exclusive)

The stock in this page is a buffer, so slicing it copies nothing and reads nothing. A Replay is read from start through open_at, and only as far as end: a range of a large file reads the range, not the file. A body that cannot open at an offset, a one-shot stream or a replay whose open_at answers None, is refused as Unsupported. urn:stream:range does not collect a whole stream in order to slice it, because that is the cost it exists to avoid.

A file is a seekable body

On a native host, ikigai-fs’s Source answers a file as a Replay. Each reader opens a fresh descriptor through the same jailed walk the bytes path takes, reads chunks of at most 64 KiB, and can start at any offset, and the replay knows the file’s length from the moment it was opened. A file read this way is not limited to the 16 MiB its bytes path holds; whoever collects it is bounded by their run’s byte budget instead.

The listing writes the stock to a file in a fresh directory, mounts the directory with cacheable_space, and reads it the three ways:

#![allow(unused)]
fn main() {
extern crate futures;
extern crate ikigai_core;
extern crate ikigai_fs;
use std::sync::Arc;

use futures::executor::block_on;
use futures::StreamExt;
use ikigai_core::{ArgRef, Body, Capability, Error, Iri, Kernel, Request, Verb};

let dir = std::env::temp_dir().join(format!("ikigai-book-streams-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let stock = "item,shelf,count\nflour,A,12\nsugar,A,0\nrice,B,30\nlentils,B,7\n";
std::fs::write(dir.join("stock.csv"), stock).unwrap();

let kernel = Kernel::new(Arc::new(ikigai_fs::cacheable_space(&dir)));
let root = Capability::root();
let file = || Request::new(Verb::Source, Iri::parse("urn:file:stock.csv").unwrap());
let range = |start: &str, end: &str| {
    Request::new(Verb::Source, Iri::parse("urn:stream:range").unwrap())
        .with_arg("of", ArgRef::Reference(Iri::parse("urn:file:stock.csv").unwrap()))
        .with_arg("start", ArgRef::Inline(start.as_bytes().to_vec()))
        .with_arg("end", ArgRef::Inline(end.as_bytes().to_vec()))
};
let read_all = |stream: ikigai_core::ByteStream| {
    block_on(stream.collect::<Vec<_>>())
        .into_iter()
        .collect::<Result<Vec<_>, Error>>()
        .map(|chunks| chunks.concat())
};

// A file is a replay: its length known before a byte is read, read from the start as
// often as asked, and from any offset. ikigai-fs has no type for `.csv`, so a file of
// UTF-8 is `text/plain`.
let answer = block_on(kernel.issue_body(file(), &root)).unwrap();
assert_eq!(answer.repr_type.media_type, "text/plain");
let Body::Replay(replay) = answer.bytes else { panic!("a file is a replay") };
assert_eq!(replay.len_hint(), Some(60));
assert_eq!(read_all(replay.open()).unwrap(), stock.as_bytes());
assert_eq!(read_all(replay.open()).unwrap(), stock.as_bytes());
assert_eq!(read_all(replay.open_at(48).unwrap()).unwrap(), b"lentils,B,7\n");

// A range of the file, read from its offset, and cached.
assert_eq!(block_on(kernel.issue(range("17", "28"), &root)).unwrap().bytes, b"flour,A,12\n");
assert!(kernel.is_cached(&range("17", "28"), &root));

// A write through the kernel cuts the file's thread, and every range that hangs from it.
let without_sugar = "item,shelf,count\nflour,A,12\nrice,B,30\nlentils,B,7\n";
let sink = Request::new(Verb::Sink, Iri::parse("urn:file:stock.csv").unwrap())
    .with_arg("content", ArgRef::Inline(without_sugar.as_bytes().to_vec()));
block_on(kernel.issue(sink, &root)).unwrap();
assert!(!kernel.is_cached(&range("17", "28"), &root));
assert_eq!(block_on(kernel.issue(range("28", "38"), &root)).unwrap().bytes, b"rice,B,30\n");

// A file changed in place behind a reader's back: the reader refuses, never a torn body.
let held = block_on(kernel.issue_body(file(), &root)).unwrap();
let Body::Replay(held) = held.bytes else { panic!("a file is a replay") };
std::fs::write(dir.join("stock.csv"), "item,shelf,count\n").unwrap();
assert!(matches!(read_all(held.open()), Err(Error::Conflict(_))));

std::fs::remove_dir_all(&dir).unwrap();
}

The last four lines are the promise a Replay makes. It records the file’s version when it is opened, its size, inode and times, and checks it when each reader opens and again at the end. A file written in place while a reader is open ends that reader with Conflict (“changed while read”), never with a short body or one that is half old and half new. A Sink through the kernel does not do that to anyone: it writes a staging file and renames it over the old one, so a reader already open keeps the old file and reads it whole, and the cut sends every later lookup to the new one.

What a body still cannot do

Everything on this page could be read again. A buffer can, a file can, and a range of either is a buffer. What is left is the third shape, the body that can be read once: a response still arriving, a generator, an upload. The kernel already refuses to cache one, and urn:stream:range already refuses to slice one. The next page reads one: collected under the run’s byte budget, decoded as text as it streams, and refused past the bound.

Streams, III: a body read once, and its bound

The second streams page ended on the body it could not do much with: Body::Once, a one-shot stream, which the kernel would not cache and urn:stream:range would not slice. This page is about that body. It covers what a one-shot stream is and why it is never cached, how one is collected into a buffer under the run’s byte budget, how one is decoded as text without being collected, and the refusal past the bound. The decoding runs in this page’s kernel. The page has no one-shot source of its own, so the listings that make one run in mdbook test.

A stream that can be read once

A one-shot body is a stream of chunks that ends when its producer stops: a generator, a response still arriving, an upload in flight. Each chunk is handed to whoever is reading and then it is gone. Nothing in the stream can start it again from the first byte, so the kernel never stores one: the next caller would find it already read. The endpoint runs again for each request instead, and a trace’s uncached note gives the reason as one-shot body.

The listing below answers the shop’s stocktake, counted as it is read, as a one-shot body of one chunk per row. It counts how often the count is taken, and runs in mdbook test:

#![allow(unused)]
fn main() {
extern crate async_trait;
extern crate futures;
extern crate ikigai_core;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};

use async_trait::async_trait;
use futures::executor::block_on;
use futures::StreamExt;
use ikigai_core::{
    Body, Bytes, Capability, Endpoint, EndpointSpace, Exact, Invocation, Iri, Kernel, ReprType,
    Representation, Request, Result, TraceEvent, Tracer, Verb, UNCACHED_NOTE,
};

const ROWS: [&str; 5] = [
    "item,shelf,count\n",
    "flour,A,12\n",
    "sugar,A,0\n",
    "rice,B,30\n",
    "lentils,B,7\n",
];

/// The stocktake: one chunk per row as it is counted, counting the counts.
struct Count(Arc<AtomicUsize>);

#[async_trait]
impl Endpoint for Count {
    async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
        let body = self.invoke_body(inv).await?;
        body.collect_within(inv.memory_budget().expect("the kernel mints one")).await
    }

    async fn invoke_body(&self, _inv: &Invocation<'_>) -> Result<Representation<Body>> {
        self.0.fetch_add(1, Ordering::SeqCst);
        let rows = ROWS.map(|row| Ok(Bytes::from_static(row.as_bytes())));
        let body = Body::once(futures::stream::iter(rows));
        Ok(Representation::from_body(ReprType::new("text/csv"), body).cacheable())
    }
}

let counts = Arc::new(AtomicUsize::new(0));
let space = EndpointSpace::new().bind(Exact::new("urn:shop:count"), Count(counts.clone()));
let kernel = Kernel::new(Arc::new(space));
let root = Capability::root();
let read = || Request::new(Verb::Source, Iri::parse("urn:shop:count").unwrap());

// The body API hands the stream over unread, with no length: nobody knows it yet.
let answer = block_on(kernel.issue_body(read(), &root)).unwrap();
assert!(answer.bytes.is_one_shot());
assert_eq!(answer.bytes.len_hint(), None);

// Reading it takes the chunks one at a time, five rows, and then it is spent.
let chunks: Vec<Bytes> = block_on(answer.bytes.into_stream().map(|c| c.unwrap()).collect());
assert_eq!(chunks.len(), 5);
assert_eq!(chunks.concat(), ROWS.concat().as_bytes());

// Marked cacheable and still never cached, so every request takes the count again.
assert!(!kernel.is_cached(&read(), &root));
let again = block_on(kernel.issue(read(), &root)).unwrap();
assert_eq!(again.bytes, ROWS.concat().as_bytes());
assert_eq!(counts.load(Ordering::SeqCst), 2);

// A trace of a third request says why it was not kept.
struct Notes(Mutex<Vec<TraceEvent>>);
impl Tracer for Notes {
    fn record(&self, event: TraceEvent) {
        self.0.lock().unwrap().push(event);
    }
}
let notes = Arc::new(Notes(Mutex::new(Vec::new())));
block_on(kernel.issue_traced(read(), &root, notes.clone())).unwrap();
let events = notes.0.lock().unwrap();
let event = events.iter().find(|e| e.target == "urn:shop:count").unwrap();
let why = event.notes.iter().find(|(k, _)| k == UNCACHED_NOTE).map(|(_, v)| v.as_str());
assert_eq!(why, Some("one-shot body"));
}

The second request used Kernel::issue, the bytes API every page before this part used. It collected the five chunks into one buffer and handed back a Representation as before. It is still not cached: the bytes were collected for this caller, after the kernel had decided not to keep a stream it could not read twice. The third request was traced, and the trace’s note for the count says one-shot body.

Collecting under the run’s byte budget

Collecting is where a stream turns back into a buffer, and a buffer costs memory. A stream does not say how long it is, so the kernel cannot check the size before it reads. It checks as it reads instead. Every request a caller issues runs with a byte budget, one pool for the request and every sub-request below it, and each chunk is drawn from the pool before it is held. The first chunk the pool cannot cover ends the collect with OverBudget. The caller does not get the rows that fit, and what the collect drew is given back.

The pool’s size comes from the caller’s capability, the way the retry budget’s does:

  • a host sets a base and a ceiling with Kernel::with_stream_memory(base, ceiling), both 64 MiB unless it says otherwise;
  • a capability with no grant gets the base, and root gets the ceiling;
  • a grant urn:cap:stream:memory:<bytes> (cap_stream_memory(bytes)) raises its holder’s budget, never above the ceiling and never below the base. Attenuating a capability can only remove the grant, so it can only lower the budget.

The listing below runs the same stocktake on a host with a base of 32 bytes and a ceiling of 1 KiB. The count is 60 bytes. A caller with no grant is refused, a caller granted 64 bytes is not, and root is not:

#![allow(unused)]
fn main() {
extern crate async_trait;
extern crate futures;
extern crate ikigai_core;
use std::sync::Arc;
use async_trait::async_trait;
use ikigai_core::{
    Body, Bytes, Endpoint, EndpointSpace, Exact, Invocation, ReprType, Representation, Result,
};
const ROWS: [&str; 5] = [
    "item,shelf,count\n",
    "flour,A,12\n",
    "sugar,A,0\n",
    "rice,B,30\n",
    "lentils,B,7\n",
];
struct Count;
#[async_trait]
impl Endpoint for Count {
    async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
        let body = self.invoke_body(inv).await?;
        body.collect_within(inv.memory_budget().expect("the kernel mints one")).await
    }
    async fn invoke_body(&self, _inv: &Invocation<'_>) -> Result<Representation<Body>> {
        let rows = ROWS.map(|row| Ok(Bytes::from_static(row.as_bytes())));
        let body = Body::once(futures::stream::iter(rows));
        Ok(Representation::from_body(ReprType::new("text/csv"), body).cacheable())
    }
}
use futures::executor::block_on;
use ikigai_core::{
    cap_stream_memory, Capability, Error, FailureClass, Iri, Kernel, MemoryLimits, Request, Verb,
};

let space = EndpointSpace::new().bind(Exact::new("urn:shop:count"), Count);
let kernel = Kernel::new(Arc::new(space)).with_stream_memory(32, 1024);
let read = || Request::new(Verb::Source, Iri::parse("urn:shop:count").unwrap());

// No grant: the base, 32 bytes. The third row does not fit, and the answer is the refusal.
let plain = Capability::scoped(["urn:cap:read"]);
let refused = block_on(kernel.issue(read(), &plain)).unwrap_err();
assert!(matches!(refused, Error::OverBudget { .. }));
assert_eq!(refused.class(), FailureClass::OverBudget);
assert!(refused.to_string().contains("4 byte(s) left, at least 38"));

// A grant of 64 bytes covers the 60, and root gets the ceiling.
let granted = Capability::scoped([cap_stream_memory(64)]);
assert_eq!(block_on(kernel.issue(read(), &granted)).unwrap().bytes.len(), 60);
assert_eq!(block_on(kernel.issue(read(), &Capability::root())).unwrap().bytes.len(), 60);

// The rule the kernel applies: a grant above the ceiling is clamped to it, so asking for
// more than the host allows is not a way past it.
let limits = MemoryLimits::new(32, 1024);
assert_eq!(limits.for_capability(&plain), 32);
assert_eq!(limits.for_capability(&Capability::scoped([cap_stream_memory(u64::MAX)])), 1024);
}

The refusal says over budget, that 4 bytes were left and at least 38 were needed, and that a urn:cap:stream:memory:<bytes> grant raises the budget up to the host’s ceiling. The header and flour’s row are 28 bytes, so four of the 32 were left when sugar’s 10-byte row arrived. The collect refused at the first chunk that did not fit, read nothing after it, and did not hand back the 28 bytes it had. OverBudget is a failure class of its own, and nothing in the kernel retries it: the same request under the same budget fails the same way. Only a caller with a larger grant, making a new request, gets a different answer.

The budget belongs to the run, not to one collect. Two endpoints in the same request that each collect a stream draw from the same pool, so a request cannot get past its budget by splitting its reads. A body that is already a buffer, such as the shop’s stock in this book’s page kernel, draws nothing: it is in memory already, and refusing it would free nothing.

Decoding a body as text

urn:stream:text is the kernel’s text decoder. It reads the body given as content, checks that it is UTF-8, and answers it as text/plain;charset=utf-8. The charset comes from the charset argument if there is one, else from the body’s own media type, else it is UTF-8. UTF-8 is the only charset it decodes. Any other is refused by name rather than guessed at.

The shop’s stock is UTF-8, so the first line below answers it as text and the second is a cache hit. The third gzips it first, as the first streams page did, and gzip’s bytes are not text. The fourth asks for a charset the decoder does not have:

item,shelf,count
flour,A,12
sugar,A,0
rice,B,30
lentils,B,7
[computed]
item,shelf,count
flour,A,12
sugar,A,0
rice,B,30
lentils,B,7
[cached]
error: invalid argument `content`: the body is not valid UTF-8 (at byte 1)
       `content`: the body to decode
error: unsupported: urn:stream:text decodes UTF-8 only; the body's charset is `iso-8859-1`

The gzip refusal names the offset of the first byte that is not UTF-8. A gzip stream starts with the bytes 1F 8B. The first is a control character, which UTF-8 allows. The second can only continue a character, never start one, so the decoder stops at byte 1. A body that is not valid text is refused whole. The decoder does not drop the bad bytes or replace them with �, because the text it would answer is not what the body says.

A buffer is checked whole. A stream is decoded incrementally: each chunk is checked as it arrives and passed on, and the stream is never collected. A UTF-8 character can be split across two chunks, and the decoder holds the first part of it, at most three bytes, until the next chunk completes it. Whatever is invalid ends the stream with an error, after the text before it was passed on. So a reader of a decoded stream has the same duty as a reader of any stream: a stream that ends with an error is a failure, and the chunks before it are not the whole.

The listing below decodes a count that sells jalapeños, with the ñ split across two chunks, and then a count whose ñ was written in Latin-1, as the one byte F1, which UTF-8 never uses alone:

#![allow(unused)]
fn main() {
extern crate async_trait;
extern crate futures;
extern crate ikigai_core;
use std::sync::Arc;

use async_trait::async_trait;
use futures::executor::block_on;
use futures::StreamExt;
use ikigai_core::{
    ArgRef, Body, Bytes, Capability, Endpoint, EndpointSpace, Error, Exact, Invocation, Iri,
    Kernel, ReprType, Representation, Request, Verb,
};

/// A one-shot `text/csv` body over these chunks.
struct Chunks(&'static [&'static [u8]]);

#[async_trait]
impl Endpoint for Chunks {
    async fn invoke(&self, inv: &Invocation<'_>) -> ikigai_core::Result<Representation> {
        let body = self.invoke_body(inv).await?;
        body.collect_within(inv.memory_budget().expect("the kernel mints one")).await
    }

    async fn invoke_body(&self, _: &Invocation<'_>) -> ikigai_core::Result<Representation<Body>> {
        let chunks = self.0.iter().map(|chunk| Ok(Bytes::from_static(chunk)));
        let body = Body::once(futures::stream::iter(chunks.collect::<Vec<_>>()));
        Ok(Representation::from_body(ReprType::new("text/csv"), body))
    }
}

// "jalapeños" in UTF-8 is `jalape` C3 B1 `os`: the first chunk ends after C3.
let split = Chunks(&[b"item,shelf,count\njalape\xC3", b"\xB1os,C,4\n"]);
// Latin-1 writes the same letter as the one byte F1.
let latin1 = Chunks(&[b"item,shelf,count\n", b"jalape\xF1os,C,4\n"]);
let kernel = Kernel::new(Arc::new(
    EndpointSpace::new()
        .bind(Exact::new("urn:shop:jalapenos"), split)
        .bind(Exact::new("urn:shop:latin1"), latin1),
));
let root = Capability::root();
let text = |of: &str| {
    Request::new(Verb::Source, Iri::parse("urn:stream:text").unwrap())
        .with_arg("content", ArgRef::Reference(Iri::parse(of).unwrap()))
};

// Decoded as a stream: still one-shot, never collected, and the split letter arrives whole.
let decoded = block_on(kernel.issue_body(text("urn:shop:jalapenos"), &root)).unwrap();
assert_eq!(decoded.repr_type.media_type, "text/plain");
assert!(decoded.bytes.is_one_shot());
let chunks: Vec<Bytes> = block_on(decoded.bytes.into_stream().map(|c| c.unwrap()).collect());
assert_eq!(chunks.len(), 2);
assert!(chunks[0].ends_with(b"jalape"));
assert!(chunks[1].starts_with("ñ".as_bytes()));

// The Latin-1 count: the header passes, then the stream ends with the refusal, not a short text.
let decoded = block_on(kernel.issue_body(text("urn:shop:latin1"), &root)).unwrap();
let read: Vec<Result<Bytes, Error>> = block_on(decoded.bytes.into_stream().collect());
assert_eq!(read.len(), 2);
assert_eq!(read[0].as_ref().unwrap(), &b"item,shelf,count\n"[..]);
let refused = read[1].as_ref().unwrap_err();
assert_eq!(
    refused.to_string(),
    "invalid argument `content`: the body is not valid UTF-8 (at byte 23)"
);

// Collected through the bytes API, the same refusal is the whole answer.
assert!(matches!(
    block_on(kernel.issue(text("urn:shop:latin1"), &root)),
    Err(Error::InvalidArgument { .. })
));
}

The refusal names byte 23, counted from the start of the body: the header’s 17 bytes, then jalape, then the F1. The header had already been passed on when the refusal arrived, which is why a reader must not take what came before an error as the whole.

What a one-shot body still cannot do

A one-shot body can now be collected, under a bound that refuses rather than truncates, and decoded as it streams. It still cannot be read again, so it still cannot be cached, sliced with urn:stream:range, or handed to two readers. The kernel’s answer to that is urn:stream:spill, which writes a one-shot body through to the disk as it arrives, under a disk budget of its own, and answers a body that can be read again. That, and serving a range of a resource over HTTP, are what a later page in this part takes up (ledger #2228).

One wire, three languages

Almost everything before this part was Rust (Tic-tac-toe, VI borrowed these faces ahead of time), and the model never depended on it. A resolution is a request and a representation crossing a boundary, and the boundary is a wire protocol with a version number: ikigai-ipc speaks it over a Unix socket in Rust, and so do two other implementations of the same codec — ikigai-python (standard library, and one dependency: blake3, for the digests in a served door’s contract IRIs) and ikigai-deno (TypeScript, with one dependency, @std/crypto, for the same digests, and zod only for its optional ./zod entry point). Each is both halves at once: a client that drives a running kernel, and a servable peer that a Rust host mounts as a space.

That pairing is called L0 of the polyglot ladder, and the name is honest about what it is. A Python or TypeScript process can resolve any name a kernel binds, be described, be cached and traced by that kernel, and be refused by its capabilities. What it cannot do is what a Rust endpoint does with inv.source(..) — reach back into the kernel from inside its own invocation. An L0 peer is a leaf: it answers, it does not compose. There is no back-channel yet, and the two tracks below say so where it bites rather than pretending the ladder is taller than it is.

The two tracks

Both tracks mirror Part I chapter for chapter, in a language with no kernel in it:

Part Iin Python and TypeScript
Resolutionconnect() and source(): a name resolved over the wire, a representation back
Hello, resourcean endpoint as a decorated function, served on a socket
Why an endpoint describes itselfthe signature is the contract: ArgSpecs derived from annotations, describe() as data
Binding, and a host of your ownikigai --mount urn:py:=<socket> — a mount is a binding, and the host owns the name
What resolution buys youis_cached, source_traced: the kernel’s cache and trace, seen from outside
capabilitiesa scoped connect(), and a typed DeniedError
Multi-verb endpointsa family: one URI template, a contract per verb (Source, Sink, Delete, Exists), and which door answers a name

Then a notebook: the catalog as a graph, in rdflib, with one SPARQL query. And for a whole application served this way, you have already seen the same game in Python and TypeScript: a store in each language under a Rust host, and an app in each language that serves the host’s board.

What you need

A running kernel to talk to — ikigai serve <socket>, from the CLI the front door installed — and one of the two client packages. Neither is published to PyPI or JSR yet; both install from a checkout (pip install <path>, or a Deno import by path), and the listings in this part say so where it matters. The listings themselves live in this repository under examples/, modeled on the sibling repositories’ own examples (read-only for this book) so that the book builds from a clone of this repository alone. The client listings were run against ikigai-cli 0.1.18 as they were written (one refusal line shown in the Python track carries a later kernel’s wording, and says so). The steps that start a peer and mount it are replayed against the book’s pinned ikigai-cli on every change to this repository, by scripts/polyglot-tracks.sh, and each track says which those are.

⚠ Names under urn:py: and urn:ts: in this part are served by your peer process and resolve only while it runs and is mounted. They are not in the CLI, and the book’s URN gate declares them as such rather than claiming otherwise.

The Python track

Part I, chapter for chapter, from a language with no kernel in it. Every listing is in examples/python/. The blocks that start a peer and mount it (Binding and the family below) are replayed against the book’s pinned ikigai-cli on every change to this repository, with ikigai-python at its main, by scripts/polyglot-tracks.sh: the peers are started from these very lines, and what each ikigai command prints, cache verdicts included, is compared with the page. The client listings and the describe card were run against a served ikigai-cli 0.1.18 as they were written and are not replayed (the refusal text shown in the capabilities section is a later kernel’s: see the note there).

If the ttt-host from Tic-tac-toe, VI or its apps are still running, they can stay: this kernel is on a socket of its own.

pip install rdflib /path/to/ikigai-python     # a checkout; not on PyPI yet
ikigai serve /tmp/ikbook-kernel.sock &         # a kernel to talk to
until [ -S /tmp/ikbook-kernel.sock ]; do sleep 0.2; done   # wait for its socket

The pip install brings ikigai-python’s one dependency with it: blake3, for the digests in the contract IRIs a served door publishes.

Resolution: a name, over the wire

    # A name, resolved. `connect` speaks the wire protocol over the kernel's socket; the
    # answer is a representation — bytes with a type — exactly what a Rust caller gets.
    k = ikigai.connect(path)
    rep = k.source("urn:iki:fn:toUpper", **{"in": "resource oriented computing"})
    print(rep.text)            # RESOURCE ORIENTED COMPUTING
    print(rep.media_type)      # text/plain;charset=utf-8
    print(rep.cache_status.name)   # how the kernel's cache answered: MISS, HIT, UNCACHEABLE

connect speaks the wire protocol — a versioned hello each way, then framed requests — and source is the verb. The answer is a representation: text, a media type, and how the kernel’s cache answered. Nothing about it says the endpoint was Rust, or in another process, which is Resolution’s point made from the other side.

Hello, resource: a decorated function

# The decorator is the description, and the signature is the contract. `who: str`
# becomes a required input of class xsd:string; the `Annotated` text becomes its
# summary; `cacheable=True` is the same claim `.cacheable()` makes in Rust — a pure
# function of its declared inputs. Whether a kernel that mounts this peer caches the
# answers is the kernel's call, not this process's, and the chapter shows what
# ikigai-cli does with it.
@endpoint("urn:py:hello", summary="Greet someone", cacheable=True)
def hello(who: Annotated[str, "the name to greet"]) -> str:
    return f"Hello, {who}!"


# A second one, so `list` has something to show and a pipe has something to feed: a list in,
# one item per line, and each line shouted (the TypeScript track's `shout` does the same).
@endpoint("urn:py:shout", summary="Shout each line of a list", cacheable=True)
def shout(text: Annotated[str, "the lines to shout, one per line"]) -> str:
    return "\n".join(line.upper() + "!" for line in text.split("\n"))
if __name__ == "__main__":
    path = sys.argv[1] if len(sys.argv) > 1 else "/tmp/py-hello.sock"
    print(f"serving urn:py:hello and urn:py:shout on {path}", file=sys.stderr)
    serve([hello, shout], path)  # blocks; speaks the wire protocol

Compare Hello, resource: a function, a description, a binding. Here the decorator is the description and the socket path is the binding — this process is a space, and serve speaks the same protocol a Rust host speaks to any other peer.

Why an endpoint describes itself: the signature is the contract

In Rust you wrote the ArgSpecs by hand. Here they are derived from the signature: who: str is a required input of class xsd:string, the Annotated text is its summary, a default would make it optional, Literal[...] would be one_of. The description a Rust host sees is exactly the one it would see from a Rust endpoint (the peer it asks is started in Binding, below; start it first to run this):

$ ikigai --plain --mount urn:py:=/tmp/py-hello.sock -c 'describe urn:py:hello text/plain'
hello —
Greet someone
verbs: Source, Meta
  input who [argument]: the name to greet
outputs: text/plain;charset=utf-8

[computed]

And from the client side, the same card as data — the JSON face of Meta, parsed:

    # Self-description, as data. `describe` is the JSON face of Meta, parsed: the
    # ArgSpecs an endpoint declared, which is what an agent's tool definition is made of.
    card = k.describe("urn:iki:fn:toUpper")
    print(card["id"], [arg["name"] for arg in card["inputs"]])   # toUpper ['in']
    # ...and the same card as a graph, which the notebook chapter queries.
    turtle = k.meta("urn:iki:fn:toUpper", as_="text/turtle").text
    print(turtle.splitlines()[0])                                  # @prefix ik: <https://ikigai-rs.dev/ns#> .

Binding: a mount is a binding

Start the peer, and wait for its socket before you mount it (the rm -f clears a socket an earlier run left behind, which would end the wait before this peer is listening):

rm -f /tmp/py-hello.sock
python3 examples/python/hello.py /tmp/py-hello.sock &
until [ -S /tmp/py-hello.sock ]; do sleep 0.2; done
$ ikigai --plain --mount urn:py:=/tmp/py-hello.sock -c 'source urn:py:hello who=Ada'
Hello, Ada!
[uncacheable]

The Python process bound nothing under urn:py:; the host did, with --mount, exactly as Binding, and a host of your own said a host decides the name. list shows where the name is served from, after every name the host binds itself and before its names layer, which ikigai-cli 0.2.6 binds last:

$ ikigai --plain --mount urn:py:=/tmp/py-hello.sock -c list
…
urn:py:hello                        → hello   [/tmp/py-hello.sock]
urn:py:shout                        → shout   [/tmp/py-hello.sock]
urn:iki:host:names                  → host-names
urn:iki:space:dynamic:names:status  → dynamic-space-status

hello declares cacheable=True, and the answer above still says [uncacheable]. Read it twice and ask the host why:

$ ikigai --plain --mount urn:py:=/tmp/py-hello.sock -c 'source urn:py:hello who=Ada' -c 'source urn:py:hello who=Ada' -c 'source urn:kernel:uncached'
Hello, Ada!
[uncacheable]
Hello, Ada!
[uncacheable]
uncached (why a computed read was not cached; the last 64, most recent first)
  urn:py:hello      ×2  declared  [root]

[uncacheable]
— batch: 3 commands · 3 uncacheable

ikigai-cli 0.2.6, the version this page is replayed against, caches nothing a mounted peer answers: both reads reached the Python process, and urn:kernel:uncached gives declared as the reason. That is the mount’s rule, on purpose: a kernel that mounts a peer stores no copy of what the peer answers, because a change on the peer’s side (a file it watches, a write that reaches it by another route) never crosses back to the mounting kernel, so a stored copy could be served stale for as long as the host runs. The declared reason is the mount’s own declaration, not the peer’s. So cacheable=True on a served peer describes its answers; through --mount, the host does not keep them.

What resolution buys you: the kernel’s cache and trace, from outside

    # Cached once: the probe says not yet; a resolution; the probe says served now.
    print(k.is_cached("urn:iki:fn:toUpper", **{"in": "a b"}))     # False
    k.source("urn:iki:fn:toUpper", **{"in": "a b"})
    print(k.is_cached("urn:iki:fn:toUpper", **{"in": "a b"}))     # True
    # Traced: the kernel records its own events and ships them back over the wire.
    rep, events = k.source_traced("urn:iki:fn:toUpper", **{"in": "a b"})
    for event in events:
        print(event.target, "cache_hit" if event.cache_hit else "computed")

The cache is the kernel’s: what these two listings probe is a name the served kernel binds itself, and its answer is cached there, for every client of that kernel. cacheable=True on a decorated function only marks the representation. Served straight from Python nothing is cached, and mounted through ikigai-cli 0.2.6 nothing is either, as Binding showed. The trace events come back over the wire and are the same TraceEvents a Rust tracer receives.

Capabilities: a scoped connect

    # Capabilities: connect under a narrowed authority and the kernel enforces it — the
    # served kernel clamps what you carry to what the channel is entitled to, and a
    # write to the file workspace under a read-only scope is refused, typed.
    narrow = ikigai.connect(path, capability=Capability.scoped(["urn:cap:kernel:inspect"]))
    try:
        narrow.sink("urn:file:notes.txt", "nope")
    except ikigai.DeniedError as denied:
        print("denied:", denied.message)
    narrow.close()
denied: capability does not grant `urn:cap:fs:write:*` (declared by `urn:file:notes.txt`; any grant under `urn:cap:fs:write:` satisfies it)

That refusal line is ikigai-core 0.1.97’s wording (its “any grant under … satisfies it” clause); the 0.1.18 host these listings were first run against said only that the capability did not grant the name. The typed refusal is the same either way.

The client carried a narrowed capability; the served kernel clamped it to what the channel is entitled to and enforced it. The refusal crossed the wire typed — DeniedError, not a string to parse — which is what lets a REST face over this client answer 403 rather than 500 (Who is asking, from a client).

Families: one door, many names, four verbs

Every name so far was one resource. Most interesting resources come in families, such as a note for every name or a cell for every square, and a Rust endpoint binds a family with a UriTemplate. In Python a family is family(pattern), and each verb is a decorated function whose parameters include the template’s variables:

# One door for every `urn:py:note:{name}`: `{name}` is a Level 1 variable, matched the
# way ikigai_core::UriTemplate matches it, and it reaches each handler as a parameter.
note = family("urn:py:note:{name}", id="py-note", summary="A note, by name")

Name = Annotated[str, "the note's name"]


@note.source(cacheable=True, summary="the note's text; NotFound if there is none")
def read(name: Name) -> str:
    with LOCK:
        if name not in NOTES:
            raise NotFoundError(f"no note called {name}")
        return NOTES[name]


@note.sink(summary="write the note")
def write(name: Name, content: Annotated[str, "the note's text"]) -> str:
    with LOCK:
        NOTES[name] = content
    return f"wrote {name}"


@note.delete(summary="forget the note")
def forget(name: Name) -> str:
    with LOCK:
        NOTES.pop(name, None)
    return f"forgot {name}"


# No `@note.exists`: Exists defaults to "would Source succeed", so a NotFoundError is
# `false` — cacheable exactly when Source is.

{name} is a Level 1 variable, and it matches the way ikigai_core::UriTemplate matches, line for line. A variable followed by a literal captures up to the leftmost occurrence of that literal, a trailing variable takes the rest, and every capture is non-empty. The host and the peer have to agree on which names a pattern covers, because the host reads the peer’s catalog to decide what to forward, so the face mirrors core’s rule and does not approximate it. The catalog lists the template, so list shows one family, not every note anyone has written.

Each verb gets its own contract, which is core’s per-verb ActionSpec form: a Source that is cacheable and answers NotFound for a missing note, a Sink whose body arrives as content, and a Delete. Exists is not written at all. It defaults to “would Source succeed”, so a NotFound answers false.

rm -f /tmp/py-notes.sock
python3 examples/python/notes.py /tmp/py-notes.sock &
until [ -S /tmp/py-notes.sock ]; do sleep 0.2; done
$ ikigai --plain --mount urn:py:=/tmp/py-notes.sock -c 'sink urn:py:note:milk two liters' \
  -c 'source urn:py:note:milk' -c 'source urn:py:note:milk' \
  -c 'sink urn:py:note:milk three liters' -c 'source urn:py:note:milk' \
  -c 'source urn:py:note:index' -c 'delete urn:py:note:milk' -c 'source urn:py:note:milk'
wrote milk
[uncacheable]
two liters
[uncacheable]
two liters
[uncacheable]
wrote milk
[uncacheable]
three liters
[uncacheable]
milk
[uncacheable]
forgot milk
[uncacheable]
error: not found: no note called milk
— batch: 8 commands · 7 uncacheable

Read the verdicts. The note’s Source declares itself cacheable, and, as in Binding, ikigai-cli 0.2.6 caches nothing a mounted peer answers, so every read here reached the peer. The peer keeps no cache of its own either, and that is why the read after the second write saw the new text. The writes and the delete are uncacheable whatever the host does, because a Sink or Delete answer always is, and the index is uncacheable on purpose: it reads every note, and a write is about only the note it wrote. Exists, from a client (the block above ended by deleting milk, so sink it again first):

k = ikigai.connect("/tmp/py-notes.sock")   # the peer itself; Exists is a read
print(k.exists("urn:py:note:milk").text)    # true, after `sink urn:py:note:milk two liters`
print(k.exists("urn:py:note:bread").text)   # false

Which door answers

Two rules, and the second one is the reason the first can be trusted.

The first declared door that matches wins, as in core’s EndpointSpace. The family would match urn:py:note:index too, with {name} capturing index. The index works because it is declared first:

# An exact name, declared FIRST: the family below would match `urn:py:note:index` too
# (its `{name}` would capture `index`), and the first declared door that matches wins.
# Not cacheable: it reads every note, and a write cuts only the note it wrote.
@endpoint("urn:py:note:index", summary="The notes there are, one name per line")
def index() -> str:
    with LOCK:
        return "\n".join(sorted(NOTES))

Every door has two forms, and every door’s declared form is tried before any door’s stripped form. --mount urn:py:= is an alias mount: the host rewrites urn:py:note:milk to urn:note:milk before forwarding it, the way Preferential resolution described. A peer cannot know the prefix it was mounted at, only that it was aliased, so it answers each door at its declared pattern and at that pattern with the first segment stripped: urn:py:note:{name} and urn:note:{name}. If the forms were tried door by door, one door’s stripped form could shadow the next door’s declared name. Trying every declared form first means a stripped form can only answer a name that no door declares. A connection whose hello says it comes from an alias mount is sending stripped names, so for that connection the order reverses, and stripped forms go first. Because only the first segment can be stripped, a family mounted deeper, at urn:py:note: for example, needs --override, which forwards names unchanged.

A family is how a store in another language holds a game: the sixth part of the tic-tac-toe arc, Tic-tac-toe, VI, which you have already read, serves the game’s stored cell as one, from Python, under a Rust host that keeps everything else.

What an L0 peer cannot do

camel-title in What resolution buys you resolved title from inside its own invocation, and inherited its golden thread. A Python endpoint cannot: there is no inv.source on this side of the wire, no back-channel from a served peer into the kernel that is invoking it. A Python endpoint is a leaf — it takes arguments and returns bytes — and composition happens in the kernel, above it. That is L0, stated plainly; the ladder’s next rung is the module protocol’s host callback, which Part II showed and which no polyglot peer speaks yet.

The TypeScript track

The same mirror of Part I, in Deno. Every listing is in examples/deno/. ikigai-deno is not on JSR yet, so the listings import it by path, from a sibling checkout of it beside this repository’s; edit the import if yours is elsewhere. ikigai-deno’s own code imports @std/crypto (for the BLAKE3 digests in its contract IRIs) by that bare name, which its own deno.json maps for it and a script importing it by path never sees. So every deno run here passes --config examples/deno/deno.json, a three-line import map beside the listings that maps @std/crypto to JSR. A first run fetches it from JSR before it starts.

The blocks that start a peer and mount it (Binding and the family below) are replayed against the book’s pinned ikigai-cli on every change to this repository, with ikigai-deno at its main, by scripts/polyglot-tracks.sh: the peers are started from these very lines, and what each ikigai command prints, cache verdicts included, is compared with the page. The client listings were run against a served ikigai-cli 0.1.18 as they were written and are not replayed.

If the kernel from the Python track is still serving /tmp/ikbook-kernel.sock, skip the first two lines. The second one waits for the kernel’s socket, because a client started at once can find none yet.

ikigai serve /tmp/ikbook-kernel.sock &
until [ -S /tmp/ikbook-kernel.sock ]; do sleep 0.2; done
deno run -A --config examples/deno/deno.json examples/deno/client.ts /tmp/ikbook-kernel.sock

Resolution: a name, over the wire

// A name, resolved. `connect` speaks the wire protocol over the kernel's socket; the
// answer is a representation — bytes with a type — exactly what a Rust caller gets.
const k = await connect(path);
const rep = await k.source("urn:iki:fn:toUpper", { in: "resource oriented computing" });
console.log(rep.text); // RESOURCE ORIENTED COMPUTING
console.log(rep.mediaType); // text/plain;charset=utf-8
console.log(CacheStatus[rep.cacheStatus]); // how the kernel's cache answered: Miss, Hit, Uncacheable

connect returns a client whose methods are the five verbs; source is a Promise of a representation — text, media type, cache verdict. The cache verdict is worth a look on your first run: if another client already resolved that request against the same kernel, it says Hit before you have done anything. The cache belongs to the kernel, not to the connection.

Hello, resource: an endpoint with explicit args

// The description is explicit here — `args:` is the ArgSpec list, stated the way the
// Rust chapter stated it — and the handler receives the named arguments. (ikigai-deno's
// `./zod` entry point derives the same list from a schema; this file stays
// zero-dependency on purpose.) `cacheable: true` is the same claim `.cacheable()` makes
// in Rust; whether a kernel that mounts this peer caches the answers is the kernel's
// call, and the chapter shows what ikigai-cli does with it.
export const hello = endpoint("urn:ts:hello", {
  summary: "Greet someone",
  args: [{
    name: "who",
    required: true,
    summary: "the name to greet",
    class: XSD_STRING,
  }],
  cacheable: true,
}, ({ who }) => `Hello, ${who}!`);

// A list in, a list out: one item per line, each shouted, so a pipe that feeds it a list
// and reverses what comes back shows every stage's mark on every line.
export const shout = endpoint("urn:ts:shout", {
  summary: "Shout each line of a list",
  args: [{
    name: "text",
    required: true,
    summary: "the lines to shout, one per line",
    class: XSD_STRING,
  }],
  cacheable: true,
}, ({ text }) =>
  String(text).split("\n").map((line) => `${line.toUpperCase()}!`).join("\n"));
if (import.meta.main) {
  const path = Deno.args[0] ?? "/tmp/ts-hello.sock";
  console.error(`serving urn:ts:hello and urn:ts:shout on ${path}`);
  const server = new Server([hello, shout], path);
  Deno.addSignalListener("SIGINT", () => server.shutdown());
  await server.serve(); // blocks; speaks the wire protocol
}

Explicit args: here — the ArgSpec list stated the way the Rust chapter stated it, and zero-dependency. ikigai-deno’s ./zod entry point derives the same list from a schema instead (z.object({ who: z.string().describe(…) })), and that is the closer analog to “the signature is the contract”; the sibling repository’s examples/endpoints.ts shows it. Either way, a Rust host sees one description.

Why an endpoint describes itself: its description, as JSON or Turtle

// Self-description, as data: the JSON face of Meta, parsed — the ArgSpecs an endpoint
// declared, which is what an agent's tool definition is made of.
const card = await k.describe("urn:iki:fn:toUpper");
console.log(card?.id, (card?.inputs as { name: string }[]).map((a) => a.name)); // toUpper [ "in" ]
// ...and the same card as a graph.
const turtle = await k.meta("urn:iki:fn:toUpper", "text/turtle");
console.log(turtle.text.split("\n")[0]); // @prefix ik: <https://ikigai-rs.dev/ns#> .

describe is the JSON face of Meta, parsed; meta(iri, "text/turtle") is the graph. The same two faces the Rust chapters used, reached from a runtime that has never parsed Turtle.

Binding: a mount is a binding

Start the peer, and wait for its socket before you mount it:

rm -f /tmp/ts-hello.sock
deno run -A --config examples/deno/deno.json examples/deno/hello.ts /tmp/ts-hello.sock &
until [ -S /tmp/ts-hello.sock ]; do sleep 0.2; done

The wait is not decoration. Deno fetches @std/crypto from JSR on a first run before the peer binds its socket, and an ikigai --mount started at once fails with No such file or directory. The rm -f clears a socket an earlier run left behind, which would otherwise end the wait before this peer is listening.

$ ikigai --plain --mount urn:ts:=/tmp/ts-hello.sock -c 'source urn:ts:hello who=Ada'
Hello, Ada!
[uncacheable]
$ ikigai --plain --mount urn:ts:=/tmp/ts-hello.sock -c 'source urn:demo:split "resource,oriented,computing" | urn:ts:shout | urn:iki:fn:reverseList' -c 'source urn:kernel:uncached'
COMPUTING!
ORIENTED!
RESOURCE!
[1 computed · 2 uncacheable]
uncached (why a computed read was not cached; the last 64, most recent first)
  urn:iki:fn:reverseList      ×1  upstream  [root]
  urn:ts:shout                ×1  declared  [root]

[uncacheable]
— batch: 2 commands · 1 computed · 3 uncacheable

The pipe is the part to notice: Rust into TypeScript into Rust, in the host’s grammar, with none of the three knowing the others’ language. urn:demo:split (Rust) turns the commas into a list of three lines. urn:ts:shout (TypeScript) takes that list and shouts each line. urn:iki:fn:reverseList (Rust) reverses the order of the lines, so computing comes first. Each stage leaves a mark you can see: the list’s three items, the capitals and ! on every line, and the reversed order. So the answer shows that both crossings happened, and in that order. The host owns the topology; the peers own their answers.

Now read the verdicts. Both endpoints declare cacheable: true, and the peer marks its answers that way on the wire. ikigai-cli 0.2.6, the version this page is replayed against, caches neither: every read through --mount is [uncacheable], and urn:kernel:uncached gives declared as the reason. Only split, a Rust endpoint in the host, was computed and kept. reverseList is uncacheable too, for a different reason, upstream: it read an answer that could not be cached. That is the mount’s rule, on purpose: a kernel that mounts a peer stores no copy of what the peer answers, because a change on the peer’s side (a file it watches, a write that reaches it by another route) never crosses back to the mounting kernel, so a stored copy could be served stale for as long as the host runs. The declared reason is the mount’s own declaration, not the peer’s. So cacheable: true on a served peer describes its answers; through --mount, the host does not keep them.

What resolution buys you: the kernel’s cache and trace, from outside

// Cached once: the probe says not yet; a resolution; the probe says served now.
console.log(await k.isCached("urn:iki:fn:toUpper", { in: "a b" })); // false
await k.source("urn:iki:fn:toUpper", { in: "a b" });
console.log(await k.isCached("urn:iki:fn:toUpper", { in: "a b" })); // true
// Traced: the kernel records its own events and ships them back over the wire.
const [, events] = await k.sourceTraced("urn:iki:fn:toUpper", { in: "a b" });
for (const event of events) {
  console.log(event.target, event.cacheHit ? "cache_hit" : "computed");
}

Capabilities: a scoped connect

// Capabilities: connect under a narrowed authority and the kernel enforces it — a write
// to the file workspace under an inspect-only scope is refused, and the refusal arrives
// typed, as `DeniedError`, not as a string to parse.
const narrow = await connect(path, {
  capability: Capability.scoped(["urn:cap:kernel:inspect"]),
});
try {
  await narrow.sink("urn:file:notes.txt", "nope");
} catch (err) {
  if (err instanceof DeniedError) console.log("denied:", err.message);
  else throw err;
}
narrow.close();

instanceof DeniedError — the taxonomy crossed the wire, which is wire protocol v7’s whole point and what examples/http_status.ts in the sibling repository turns into a 403.

Families: one door, many names, four verbs

Every name so far was one resource. Most interesting resources come in families, such as a note for every name or a cell for every square, and a Rust endpoint binds a family with a UriTemplate. In TypeScript a family is family(pattern, options), with one chained call per verb. The template’s variables are declared as bindings and arrive in the handler’s arguments by name:

// One door for every `urn:ts:note:{name}`: `{name}` is a Level 1 variable, matched the
// way ikigai_core::UriTemplate matches it, and it reaches each handler by name.
export const note = family("urn:ts:note:{name}", {
  id: "ts-note",
  summary: "A note, by name",
  bindings: { name: { summary: "the note's name", class: XSD_STRING } },
})
  .source({
    summary: "the note's text; NotFound if there is none",
    cacheable: true,
  }, ({ name }) => {
    const text = notes.get(String(name));
    if (text === undefined) throw new NotFoundError(`no note called ${name}`);
    return text;
  })
  .sink({ summary: "write the note" }, ({ name, content }) => {
    notes.set(String(name), String(content));
    return `wrote ${name}`;
  })
  .delete({ summary: "forget the note" }, ({ name }) => {
    notes.delete(String(name));
    return `forgot ${name}`;
  });
// No `.exists(...)`: Exists defaults to "would Source succeed", so a NotFoundError is
// `false` — cacheable exactly when Source is.

{name} is a Level 1 variable, and it matches the way ikigai_core::UriTemplate matches, line for line. A variable followed by a literal captures up to the leftmost occurrence of that literal, a trailing variable takes the rest, and every capture is non-empty. The host and the peer have to agree on which names a pattern covers, because the host reads the peer’s catalog to decide what to forward, so the face mirrors core’s rule and does not approximate it. The catalog lists the template, so list shows one family, not every note anyone has written.

Each verb gets its own contract, which is core’s per-verb ActionSpec form: a Source that is cacheable and answers NotFound for a missing note, a Sink whose body arrives as content, and a Delete. Exists is not written at all. It defaults to “would Source succeed”, so a NotFound answers false.

rm -f /tmp/ts-notes.sock
deno run -A --config examples/deno/deno.json examples/deno/notes.ts /tmp/ts-notes.sock &
until [ -S /tmp/ts-notes.sock ]; do sleep 0.2; done
$ ikigai --plain --mount urn:ts:=/tmp/ts-notes.sock -c 'sink urn:ts:note:milk two liters' \
  -c 'source urn:ts:note:milk' -c 'source urn:ts:note:milk' \
  -c 'sink urn:ts:note:milk three liters' -c 'source urn:ts:note:milk' \
  -c 'source urn:ts:note:index' -c 'delete urn:ts:note:milk' -c 'source urn:ts:note:milk'
wrote milk
[uncacheable]
two liters
[uncacheable]
two liters
[uncacheable]
wrote milk
[uncacheable]
three liters
[uncacheable]
milk
[uncacheable]
forgot milk
[uncacheable]
error: not found: no note called milk
— batch: 8 commands · 7 uncacheable

Read the verdicts. The note’s Source declares itself cacheable, and, as in Binding, ikigai-cli 0.2.6 caches nothing a mounted peer answers, so every read here reached the peer. The peer keeps no cache of its own either, and that is why the read after the second write saw the new text. The writes and the delete are uncacheable whatever the host does, because a Sink or Delete answer always is, and the index is uncacheable on purpose: it reads every note, and a write is about only the note it wrote. Exists, from a client:

const k = await connect("/tmp/ts-notes.sock"); // the peer itself; Exists is a read
console.log((await k.exists("urn:ts:note:milk")).text); // true, after `sink urn:ts:note:milk two liters`
console.log((await k.exists("urn:ts:note:bread")).text); // false

Which door answers

Two rules, and the second one is the reason the first can be trusted.

The first declared door that matches wins, as in core’s EndpointSpace. The family would match urn:ts:note:index too, with {name} capturing index. The index works because it is declared first:

// An exact name, declared FIRST: the family below would match `urn:ts:note:index` too
// (its `{name}` would capture `index`), and the first declared door that matches wins.
// Not cacheable: it reads every note, and a write cuts only the note it wrote.
export const index = endpoint("urn:ts:note:index", {
  summary: "The notes there are, one name per line",
}, () => [...notes.keys()].sort().join("\n"));

Every door has two forms, and every door’s declared form is tried before any door’s stripped form. --mount urn:ts:= is an alias mount: the host rewrites urn:ts:note:milk to urn:note:milk before forwarding it, the way Preferential resolution described. A peer cannot know the prefix it was mounted at, only that it was aliased, so it answers each door at its declared pattern and at that pattern with the first segment stripped: urn:ts:note:{name} and urn:note:{name}. If the forms were tried door by door, one door’s stripped form could shadow the next door’s declared name. Trying every declared form first means a stripped form can only answer a name that no door declares. A connection whose hello says it comes from an alias mount is sending stripped names, so for that connection the order reverses, and stripped forms go first. The Python face follows the same rule, so a family behaves the same whichever language serves it. Because only the first segment can be stripped, a family mounted deeper, at urn:ts:note: for example, needs --override, which forwards names unchanged.

A family is how a store in another language holds a game: the sixth part of the tic-tac-toe arc, Tic-tac-toe, VI, which you have already read, serves the game’s stored cell as one, from TypeScript, under a Rust host that keeps everything else.

What an L0 peer cannot do

The same limit as the Python track, and it is a property of the wire, not of the language: a served peer has no back-channel into the kernel invoking it, so a TypeScript endpoint cannot source another resource mid-invocation and cannot inherit a golden thread. It is a leaf. Composition — pipes, maps, compose — happens in the host above it, as the pipe above showed.

The notebook: the catalog as a graph

Four cells, one story: the catalog is a list, then a card, then a graph, then a query. examples/python/catalog.ipynb is the notebook; examples/python/catalog.py is the same cells as a script, which is what was run to produce the output below (against a served ikigai-cli 0.1.18 — the counts are that host’s, and yours will differ).

Both of the first two lines are the Python track’s; skip them if that kernel is still serving /tmp/ikbook-kernel.sock (the TypeScript track used the same one).

pip install rdflib /path/to/ikigai-python
ikigai serve /tmp/ikbook-kernel.sock &
python3 examples/python/catalog.py /tmp/ikbook-kernel.sock

1. The catalog as a list

    # 1. The catalog as a list: every name the kernel binds, with the endpoint behind it
    #    and — for a mounted peer — where it is served from.
    entries = k.entries()
    print(len(entries), "bound names; the first three:")
    for entry in entries[:3]:
        print(" ", entry.pattern, "→", entry.endpoint)
261 bound names; the first three:
  urn:agent:select → agent-select
  urn:client:issue → client-issue
  urn:cms:bookmarks → bookmarks

entries() is what list prints at the shell, as data: pattern, endpoint id, and — for a mounted peer — where it is served from. A client discovers what it can reach instead of being told.

2. One card, as data

    # 2. One endpoint's card, as data — the JSON Meta face, parsed.
    card = k.describe("urn:iki:fn:toUpper")
    print(card["title"], "—", card["summary"])
    for arg in card["inputs"]:
        print("  input", arg["name"], "required" if arg.get("required", True) else "optional")
Upper-case — Upper-cases the UTF-8 text supplied in the `in` argument.
  input in required

describe is the JSON face of Meta, parsed. This is the dictionary an agent’s tool definition is projected from — Why an endpoint describes itself, reached by dict lookup.

3. The whole catalog, as a graph

    # 3. The whole catalog as a graph. `urn:kernel:catalog` answers as Turtle, and rdflib
    #    parses it; from here on it is RDF and the vocabulary is the schema.
    turtle = k.source("urn:kernel:catalog").text
    g = rdflib.Graph()
    g.parse(data=turtle, format="turtle")
    print(len(g), "triples in the catalog")
3289 triples in the catalog

urn:kernel:catalog answers as Turtle, rdflib parses it, and from here on it is RDF: the vocabulary at https://ikigai-rs.dev/ns# is the schema, every endpoint and every input is a node with a stable IRI, and a graph library’s whole toolkit applies.

4. One query

    # 4. One SPARQL query over it: which endpoints take a string? The same question the
    #    graph-face chapter asked with `urn:sparql:select`, asked here by a client.
    query = """
        PREFIX ik: <https://ikigai-rs.dev/ns#>
        SELECT DISTINCT ?id WHERE {
          ?endpoint a ik:Endpoint ; ik:id ?id ; (ik:input | ik:action/ik:input) ?input .
          ?input ik:class <http://www.w3.org/2001/XMLSchema#string> .
        } ORDER BY ?id
    """
    for row in g.query(query):
        print(" ", row.id)
  decrypt
  encrypt
  eval
  rdf-from-sexpr
  sexpr-from-rdf
  sexpr-to-rdf
  sign
  sparql-from-sexpr
  tz-convert
  tz-now
  verify

The same question The graph face asked with urn:sparql:select — which endpoints take a string? — asked here by a client with its own query engine, over a catalog it fetched by name. Two things to notice in the answer. toUpper is not in it — and that is a fact about the host this was run against, not about toUpper: ikigai-fn 0.2.0, the version ikigai-cli 0.1.18 linked, declared no class on its in input, and an undeclared class is invisible to the question, which is the argument for declaring one. ikigai-fn 0.2.2 declares it, so against ikigai-cli 0.1.26 or later the same query lists toUpper — and many more, because a dozen crates declared their classes in the meantime. Run it and compare; the difference is the argument, measured. And the property path (ik:input | ik:action/ik:input) is the one the graph-face chapter needed too: a multi-verb endpoint’s inputs hang off its action nodes.

⚠ The catalog you get is the catalog of the host you connected to. The CLI’s is a few hundred endpoints from a dozen crates; the tutorial host’s is twelve. Both are the same shape, and a query written against one runs against the other — but the answer is a fact about a host, not about ikigai.

Where to go next

Three reference pages close the book: a glossary of the nine terms Resolution introduced and three later chapters added, a map of every repository in the organization, and how to contribute a module of your own. The short list below is the handful this book leaned on.

The gonk Book

gonk is the work-ledger and repository-browser host built from the pieces this book teaches: a durable store, the work ledger, the repository browser and ikigai’s own transports, composed into one server. Its book walks through each way to use it on its own, starting with a ledger of your own that runs in the page: The gonk Book.

Building ikigai UIs

A view is a resource too. The third book builds a kernel inspector, the cache, the golden threads and the arrangement this book printed as text, turned into pages that follow the kernel’s cuts while you watch: Building ikigai UIs.

The ikigai Architecture

How the pieces fit: one ledger item carried through the capability spine, the door, the overlay chain, the economics of golden threads and caching, and the audit axis, with the patterns that confine shown to be the patterns that serve. Read it after this book’s chapters on dynamic spaces, which the overlay chain is built from: The ikigai Architecture.

The library lists all four books, with who each is for, what to read first and where to start.

The repositories

  • ikigai-core — the kernel, Description/ArgSpec, capabilities, golden threads
  • ikigai-fn — the smallest complete example of a space, and the one this book chains onto
  • ikigai-cli — a real host: transports, the engine grammar, MCP projection
  • ikigai-xslt — one crate showing both the linked and the loadable shape side by side
  • ikigai-gonk — the linkage-gated server from Part III, whose Cargo.toml is its manifest (it took over from the retired ikigai-dev-server)
  • ikigai-emacs — the client whose command surface is generated from the manifold
  • ikigai-llm — one front grammar over pluggable backends, each still addressable on its own

A closing note on style

You will notice the comments in this codebase are unusually long, and that they argue rather than describe. That is deliberate: the code says what it does, so a comment that repeats it earns nothing. What a comment is for is the constraint the code cannot state — why this is not cached, why this lock is dropped before that call, what broke the last time somebody did the obvious thing.

Where a comment states the shape of something that leaves the process, it wants a test in the same edit — because a test is the only comment the compiler reads. That rule is why every example in this book is compiled.

Glossary

The nine terms of Resolution and three the chapters after it needed, one paragraph each, with the chapter where you do the thing rather than read about it.

Resource. Anything with a name — a URI, in this system a urn:*. A resource is not an object and not a function: it is a name a kernel can resolve, and what you get back depends on which verb you asked with. urn:iki:tutorial:camel-case is one; so is urn:kernel:catalog. You define one in Hello, resource and give it a name in Binding, and a host of your own.

Verb. One of five: Source (read), Sink (write), Exists, Delete, Meta (describe yourself). The same five for every resource, so there is no method vocabulary to learn — and Meta is the one that makes the system legible to a machine. An endpoint answering more than one declares a contract per verb, in Multi-verb endpoints.

Representation. What a resolution returns: bytes plus a media type. Not a language-specific value, because the answer may have crossed a process, a machine or a language. Declaring the type is what lets a transreptor find a route from what you produced to what somebody asked for. You return one in Hello, resource and ask for a different one in What resolution buys you.

Transreptor. An endpoint that converts one representation into another, and — the part that matters — declares which conversions it performs, so the kernel can select it when a request asks for a type the resource does not produce itself. A resource does not have a format; it has whatever the kernel can reach. You write one in Transreption.

Golden thread. The dependency a cached answer hangs from — declared by the endpoint (.depends_on(..)), conventionally named after the resource whose state it tracks. A resolution derived from other resolutions inherits their threads; a write to a resource cuts the thread named after it, and every cached entry whose threads include that one — directly or transitively — stops being valid at that instant. Since core 0.1.73 every cacheable entry also hangs from the thread named after its own target, so even a pure function’s, which declares none, is cut by a cut of its own name. Precise invalidation, not timeouts. You cut one in What resolution buys you and again, on a file, in Configuration.

Capability. The authority a resolution runs under, carried with the invocation and narrowed — never widened — as it passes down a call chain. What an endpoint declares it requires is what the kernel enforces, per verb, before the endpoint runs: declared equals enforced, and either half without the other is a defect. You declare one on your own endpoint in Multi-verb endpoints, and watch one cross a socket in Part III’s Exercises.

Scope. (Not a capability scope, one urn:cap:… string in a capability, which is what most chapters mean by “a scope”.) The resolution chain a request is resolved in: corridors a host injected ahead of the kernel’s root, innermost first, and whether the root is on the end at all. Every sub-request inherits it. Injecting a corridor (Kernel::issue_in) is the host’s authority, since a corridor placed ahead of the root can stand in for any door; cutting the root off (Confine) is the one chain-changing move an endpoint gets, and inside a severed chain a name outside it is unresolvable, never denied. The chain is part of the cache key. Scope and alias.

Alias. A logical name that resolves to a different backing resource without the caller knowing — a table of exact and prefix rules, installed on the kernel. The rewrite is reported, so the two names are one resource: one cache entry, one golden thread, the floor checked against the backing name, the catalog listing the backing name once, cycles refused. Not the alias mount of Part III, which rewrites on the way out to another kernel and reports nothing. Scope and alias.

Principal. Who asked — a fact a door establishes (a certificate, a passkey, a socket’s peer credential) and stamps on a write as provenance. Not the capability, which is what may happen: an anonymous local caller has authority and no identity, a signed-in caller with a narrow grant has identity and little authority. Since core 0.1.93 the door mints it into the request’s capability as a scope, urn:cap:principal:<iri>, which an endpoint reads with principal() and which widens nothing. Who is asking.

Catalog. urn:kernel:catalog: every bound endpoint’s description, as one RDF graph, assembled from the descriptions rather than maintained beside them. Nobody writes it, so it cannot be out of date. You read your own host’s in What resolution buys you and query it in The graph face.

Manifold. urn:kernel:actions: the capability-scoped list of what the current caller may invoke — the catalog, filtered by authority, per action. It narrows as the capability narrows, and it is computed, not configured. The machine client is where a client reads it instead of being told.

Tool list. What an agent is handed as the things it may do. Here it is not written down for the agent: it is the manifold under the agent’s capability, projected — over MCP, into an editor’s generated commands, into a shell’s completions. The description an endpoint carries is the tool definition, which is why Why an endpoint describes itself calls it load-bearing.

The repositories

One repository per module, all under https://github.com/ikigai-rs. This map is derived from the organization’s repository list (gh repo list ikigai-rs) on 2026-09-26, grouped the way the maintainers group them; a few private repositories (the site, the release tooling, a secrets module and a set of Lisp programs) are omitted. If a name here does not resolve on GitHub, the list moved before this page did — the organization is the truth.

The kernel and the host

  • ikigai-core — the kernel workspace: ikigai-core (kernel, Description/ArgSpec, capabilities, golden threads, clocks), ikigai-vocab (the vocabulary and the Turtle renderer). Those two are the whole workspace — every module crate that once lived here has moved to its own repository. Everything else depends on it.
  • ikigai-cli — the main host and a workspace of its own: the ikigai binary, the embedded host, the engine (the REPL grammar), resolve/wire/scheduler, the IPC and QUIC transports, the MCP projection, and the inbound-HTTP transport library.
  • ikigai-module — the loadable-module format: ModuleSpace, the transports, host-callback resolution. Part II.

Compute and data

  • ikigai-fn — the function library this book chains onto, and the smallest complete module crate: read it first if you are writing one.
  • ikigai-fs — the file workspace, urn:file:*, natively and on a browser’s storage.
  • ikigai-http — the outbound HTTP client as resources.
  • ikigai-text — Unix-like text endpoints, urn:text:*, pure pipeline citizens.
  • ikigai-linkeddata — ikigai-rdf (transreption between RDF syntaxes), ikigai-sparql (the graph face), ikigai-sniff.
  • ikigai-store — the persistent RDF store: a dataset opened from a path that survives a restart, owned by one process so that reads can be cached. ikigai-sparql above queries a graph it is handed; this is where the bytes live.
  • ikigai-jsonld — JSON-LD expand/compact/flatten, lazy-loadable as wasm.
  • ikigai-compress — gzip and zlib as resources, urn:compress:* and urn:decompress:*, registered as transreptors too.
  • ikigai-markdown — Markdown as a graph: one structural lift, every dialect a mapping resource.
  • ikigai-ledger — the work ledger: items, comments, labels and links as resources, and next as a resource with a swappable ranking policy.
  • ikigai-xslt and ikigai-xslt-module — XSLT as a resource, linked or loadable.
  • ikigai-shacl — SHACL validation, native and in the browser. Checking a resource mounts it.
  • ikigai-toolbox — the RDF Toolbox (turtle-lsp in the browser) served as resources: a page that checks any resource’s Turtle face, and its shapes, as the viewer. Checking a resource reads through it.
  • ikigai-sexpr — s-expressions to SPARQL and Turtle, no Lisp engine.
  • ikigai-lisp — the Lisp engine (urn:lisp:eval), whose builtins are the five verbs.
  • ikigai-script — scripts as resources in any host: urn:script:{name} with content-addressed versions, run as a read or for its effects, under the least of what it declares, what its publisher held and the host’s ceiling. Scripts are resources mounts it.
  • ikigai-nl — natural language to a DRAFT script, with the model as a drafter and never an actor. A sentence becomes a draft mounts it.

Personal and platform

  • ikigai-personal — calendar and contacts (macOS EventKit).
  • ikigai-org — the org-mode agenda as the same event graph.
  • ikigai-meeting — meeting scheduling over provider backends.
  • ikigai-llm — urn:llm:ask, one facade over pluggable inference backends.

Polyglot faces

  • ikigai-python — a pure-stdlib Python client and servable peer for the wire protocol.
  • ikigai-deno — the same in zero-dependency TypeScript.

Trust and governance

  • ikigai-sign and ikigai-encrypt — signatures as RDF graphs, and their dual.
  • ikigai-throttle — rate limit, retry, circuit breaker, failover and timeout as space overlays; Failover is what a prefer-mount is made of.
  • ikigai-a11y — layered accessibility configuration and the WCAG contrast floor this book’s gate measures with.
  • ikigai-log — the log’s line grammar and its provenance vocabulary.

Developer tooling and faces

  • ikigai-repo — git, gh and cargo as capability-gated resources.
  • ikigai-browse — repository browsing as resources, with text, HTML and Turtle faces.
  • ikigai-gonk — the work-ledger and repository-browser host: the ledger (items, comments, links and a next-item ranking), the browse family over every repository it is given and SPARQL over its store, through a socket, HTTP and QUIC. The gonk Book teaches it.
  • ikigai-dev-server — retired. The standalone IPC server that served the browse family before gonk did, whose Cargo.toml was its manifest; its repository stays readable for that history.
  • ikigai-web — the standalone HTTP and SPARQL face (published as ikigai-web-server).
  • ikigai-runbook — guided, runnable demos as resources.
  • ikigai-conformance — what “done” means for a module, as one test over a kernel: it walks every endpoint and reports every violation of the recipe this book teaches at once.
  • ikigai-name — persistent-identifier resolution, urn:name:*.
  • ikigai-emacs — the editor as a client, its commands generated from the manifold.
  • ikigai-web-demo — the kernel in the browser, a page assembled by resolution.
  • ikigai-tutorial — this book, and the crates it teaches.

The semantic CMS

Contribute a module

A module is a crate with a space(). This page is the shape of a new one, from the first commit, in the order the pieces matter. The reference is ikigai-fn: one file, eight endpoints, a README that says what each is for, and a three-line CI — the smallest complete example, and the crate this book chains onto.

The crate

[package]
name = "ikigai-yours"
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
description = "One line saying what resources this offers, as `urn:…` names."
# Until it is published this is the honest setting. Publishing is not yours to do —
# see the end of this page.
publish = false

[dependencies]
# The PUBLISHED kernel, at the version whose API you actually use. Never a path to a
# sibling checkout: a path dependency only resolves on the machine that has both.
ikigai-core = "0.1.78"

No OS or platform API unless the module is about one — read files through the kernel (urn:file:…), not std::fs; take time from inv.now(), not SystemTime. A module that keeps to that compiles to wasm32-unknown-unknown for free, and the same crate then links into the native CLI and into a browser host.

The endpoints, described from day one

Every endpoint is a function, a description, and a binding, exactly as Hello, resource built one. The description is not optional paperwork: the engine routes named arguments by it, the catalog is built from it, and an agent’s tool is projected from it. So, from the first endpoint:

  • ArgSpec for every argument — name, summary, optional() where it is, class(..) always: an XSD datatype IRI for a scalar, an rdfs:Class for an entity. The one people leave off, and the one The graph face shows is the point.
  • one_of(..) for enums, default_value(..) where a default exists. The kernel fills a declared default in before dispatch (since ikigai-core 0.1.105), so your code reads the argument and does not apply the default a second time. A default makes its input optional, and Description::validate refuses a description that marks a defaulted input required anyway.
  • Single verb: author flat. More than one verb: one ActionSpec per verb, per Multi-verb endpoints — and the two forms are exclusive per verb: an explicit action’s requires replaces the flat one for that verb rather than adding to it, so restate on each action what it still needs.
  • .requires("urn:cap:…") on every action that needs authority, and no authority check anywhere else. Declared equals enforced; either half alone is a defect.
  • .cacheable() only for a pure function of its declared inputs; when in doubt, do not.
  • Names: nouns, kebab-case, under a prefix you own. urn:iki:yours:word-count, not urn:fn:countWords in somebody else’s namespace.
  • Skolemize: no blank nodes in any graph you emit. Stable IRIs make graphs diffable.
  • Pipeline citizenship: a Sink or Delete that can receive a pipe declares an input named content, because that is where the engine puts the piped value; a Source is piped into through its one required unnamed argument, so a Source with no required by-value input cannot be piped into at all — making every intake optional silently removes it from every pipeline, and nothing warns.

And space():

pub fn space() -> EndpointSpace {
    EndpointSpace::new()
        .bind(Exact::new("urn:iki:yours:word-count"), word_count())
}

A host chains onto it. Your crate does not decide where it is mounted.

State how it fails

Since ikigai-core 0.1.108, every error has an Error::class(). Declare the classes an endpoint can produce with .fails(FailureClass::…) on its Description, or on each ActionSpec for a multi-verb endpoint (ActionSpec.failures holds the declaration):

#![allow(unused)]
fn main() {
extern crate ikigai_core;
use ikigai_core::{ActionSpec, FailureClass, Verb};

let action = ActionSpec::new(Verb::Source)
    .fails(FailureClass::Transient)
    .fails(FailureClass::RetryAfter);
assert_eq!(action.admits_failure(FailureClass::Transient), Some(true));
assert_eq!(action.admits_failure(FailureClass::Unknown), Some(false));
}

Transient says the same request may succeed now; RetryAfter says to wait until a stated time; OverBudget says the input exceeds a bound; Denied says the caller lacks a capability; Refused says a remote authority refused the credential presented; Permanent says the request or current state is wrong; Unknown says the endpoint could not classify it. Permanent is always implied, and Denied is implied beside a nonempty requires. Leaving the declaration unstated makes no promise; it does not mean the action cannot fail. State what the implementation can return so a consumer can decide whether to retry, wait, reduce the input or ask for authority.

The tests

A test builds a kernel over space(), resolves a name, and asserts the answer — the text_of helper in crates/your-endpoints is the four lines. Then add the one test every module in the ecosystem runs: ikigai-conformance walks every endpoint of a kernel and reports every departure from the recipe on this page at once — a missing class, an undeclared scope, a Sink without content. It is what “done” means for a module, and it is a dev-dependency, not a rule you remember. Pin it as a range you read in both directions: under Cargo’s 0.x rules "0.10.0" means >=0.10.0, <0.11.0, and most of the ecosystem’s adopters sat one minor behind the current definition of done for weeks without a test saying so.

Version 0.9 added RECOMPUTE: cut a never-expiring cached answer’s golden threads and recompute it repeatedly, checking that its bytes agree. A cache hit alone cannot prove that the computation is repeatable. Version 0.10 added FAILURES, which compares observed failures with the action’s declared classes. In 0.10 it is opt-in with Suite::check_failures(); it becomes the default from 0.11. A "0.10.0" pin is a ceiling below that default, so adopting it takes a deliberate version move. An action that states no classes is reported as unstated, never as a FAILURES finding.

crates/your-endpoints/tests/conformance.rs runs the suite with check_failures() over space(), scoped to its own word-count endpoint; the endpoints inherited from hello-camel and ikigai-fn are listed separately. It declares word-count pure because its cacheable answer depends only on its input. The test also pins the inherited list so a new endpoint cannot silently escape the walk, and uses Suite::fails_under to check that non-UTF-8 input produces a Permanent failure.

Test the description too (describe().inputs), test a .requires by resolving under an attenuated capability and asserting Denied, and test anything with a clock under FixedClock (Testing an endpoint hermetically). If the crate reads a config home, take it at construction and test against a scratch directory (Configuration).

The README is the pitch

Look at ikigai-fn’s: a table of endpoints — constructor, conventional IRI, what it does — and a usage block showing a host chaining onto space(). A reader decides whether to mount your crate from that table. Say what is true about its maturity; a README that oversells is a bug report waiting to be filed against you.

CI from the first commit

The organization shares one workflow, and a repository calls it. This is the whole .github/workflows/ci.yml of ikigai-fn:

name: ci
on:
  push: { branches: [main] }
  pull_request:
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}   # never cancel main
jobs:
  ci:
    uses: ikigai-rs/.github/.github/workflows/rust.yml@main
    with:
      wasm-lib: true

That runs cargo fmt --check, cargo clippy --all-targets -- -D warnings and cargo test — and, with wasm-lib: true, clippy on the wasm target too, which is what catches a dependency reaching for std::time. Run the same three locally before the first push, so CI is never born red. Every change after the scaffold lands through a pull request with that workflow green.

⚠ A gate that silently covers less than it looks like it does is worse than no gate. Two to know: --all-targets does not see a target behind required-features, and a committed Cargo.lock means CI tests the graph you froze, never the graph a stranger’s fresh cargo add resolves.

Publishing is not yours

Crates are published to crates.io by the maintainer, in a cascade that bumps the crates depending on yours. Open the pull request, get it green, and say it is ready; do not publish, and do not commit a path override to work around a crate that is not published yet — stop and say so instead. The ecosystem’s field guide calls this out because it has cost real days: a version pin that states the true minimum API you use is what keeps a stranger’s build from resolving stale.

Where to start

Copy the shape of crates/your-endpoints in this repository — it already has one worked endpoint, the test helper, and a host — then read ikigai-fn’s src/lib.rs top to bottom. It is shorter than this page.

The other books

Four more books build on this one, and each runs in the page as this one does.

  • The gonk Book: gonk, the work ledger and review host, composed from the pieces this book teaches. Start there for a ledger of your own, in the page and on your machine.
  • Building ikigai UIs: a view is a resource too. It builds a kernel inspector from the cache, the golden threads and the arrangement this book prints as text.
  • The ikigai Architecture: how the pieces fit, carrying one ledger item through capabilities, the door, the overlay chain, caching and the audit axis. Read it after this book’s chapters on dynamic spaces.
  • Running ikigai: the operator’s book. The ikigai binary, its flags and subcommands, and what it takes to serve, secure and run kernels for real, with every command run against a pinned release whenever the books are built.

The library lists all five, with who each is for, what to read first and where to start.

The books are built, tested and published together, from one repository: https://github.com/ikigai-rs/ikigai-tutorial.

Appendix: the kernel in the browser

Every Run button in this book resolves in a real ikigai kernel. It is compiled to WebAssembly and runs in your browser tab, under the same engine as the ikigai command. This appendix covers how that kernel is put together. It compares what the browser gives a kernel with what a native host gives one, and explains why the same crates serve both.

It assumes Binding, and a host of your own: a host is the code that builds a kernel, binds its spaces and hands it what it needs. The page is one more host.

The kernel brings nothing from the platform

ikigai-core reads no clock, opens no socket, starts no thread and touches no file. Everything that comes from outside the program comes from the host that built the kernel, through a seam the host fills:

  • a space to resolve against (Kernel::new);
  • a Meta renderer, so describe and the catalog answer (Kernel::with_meta_renderer);
  • a clock, so the kernel can say what time it is (Kernel::with_clock).

The kernel uses only what it was handed. That is what lets one kernel crate run in a command-line tool, in a long-running service, under a test that stops time, and in a web page.

How the page builds its kernel

The page’s host is the book-wasm crate in this repository. The book’s publishing workflow compiles it for wasm32-unknown-unknown and binds it with wasm-bindgen --target web. The output lands beside the book in wasm/ and is never committed. The first time a page needs it, js/run.js loads that module, and the module builds the page’s kernel:

/// [`page_kernel_with_clock`], over the sheet's inputs in `inputs` and push's registry in
/// `listening`. The caller attaches the kernel to `listening` once it is in an `Arc`.
fn page_kernel_over(
    inputs: Arc<InputStore>,
    listening: Arc<Listening>,
    clock: Option<Arc<dyn Clock>>,
) -> Kernel {
    let kernel = Kernel::with_meta_renderer(
        Arc::new(page_space_over(inputs, listening)),
        Arc::new(ikigai_vocab::TurtleRenderer),
    );
    match clock {
        Some(clock) => kernel.with_clock(clock),
        None => kernel,
    }
}

A few things follow from this:

  • One kernel per page. Every cell on a page runs against the same kernel, so a value cached by one cell is cached for the next. Reloading the page builds a new kernel, so nothing you did on one page is on the next.
  • The same spaces the chapters test. The page’s space is Part I’s, the tic-tac-toe game’s, the spreadsheet’s and the rest, in one fallback. These are the same spaces, with the same bindings and renderer, that the chapters’ Rust tests run against. Nothing is mocked and nothing is replayed.
  • The CLI’s engine. A cell’s line goes through ikigai-engine, the REPL grammar the ikigai command uses, so source a | b, cache, trace and describe mean exactly what they mean at a shell. The engine’s eval_async runs the line on the page’s event loop rather than blocking it.

What the browser provides, and what a native host provides

A browser is a capable host, but a different one. The table compares the two for each thing a kernel or its endpoints might need:

What it needsIn the browserOn a native hostHow the kernel gets it
The timeDate.now()the system clockKernel::with_clock; an endpoint asks Invocation::now
A wake-up later (timers)setTimeout, setIntervalthreads and native timersa timer the host injects
Running async workthe page’s event loopan executor on threadsthe host drives the kernel’s futures
The networkfetch, WebSocket, WebTransport, under the page’s origin rulesTCP, UDP, Unix sockets, QUICan endpoint the host binds
Filesnone, beyond what the page fetchesthe filesystema space the host binds (ikigai-fs natively)
Keeping datathe page’s memory, until a reloaddatabases and files on diska store the host binds
Threadsone for the page’s kernel (the book uses no Web Workers)manynone needed: the kernel is async
Other programsnoneprocesses (git, cargo)an endpoint the host binds
Platform servicesnonethe keychain, calendars, contactsan endpoint the host binds

None of the right-hand column is something the kernel asks the platform for. A browser host and a native host fill the same seams, each from what it has. Where the browser has nothing (files, processes, the keychain), the page binds no space for it, and a name in those families is simply not found there. A few names under urn:file: are the exception, and they are not a file system.

One recorded file

The part “Exploring without Rust” runs on one recorded week of earthquakes, as USGS published it: 178 events of magnitude 4.5 and up, from 2024-01-01 to 2024-01-08, in GeoJSON. At a shell it is a file you save in the CLI’s file workspace, where it is urn:file:quakes-2024-01-01.geojson. The page binds that same name to the same bytes, compiled into the module by the quakes crate, so a cell and a command name one resource. It answers only reads, and it never changes. The JSON-LD context that Giving the data names reads beside it is bound the same way, at urn:file:quakes.context.jsonld, the name the copy you download there has, and so is the week after that Composites, one layer at a time joins, at urn:file:quakes-2024-01-08.geojson. A river of feeds reads the files of its example, examples/rss/ws/ in this book’s repository, and the page binds each one at the name the example’s own REPL gives it, from urn:file:directory.opml to urn:file:views/page.xsl, with the bytes compiled in by the rss-river crate. Its river, urn:file:river.nt, is the copy the example commits, because a cell cannot write the file the chapter’s build line writes.

The first line below asks urn:json:query, a jq program run as a resource, how many events the file holds. The query reads the file through the kernel, so the second line finds the file’s own answer already cached. The third asks the file to describe itself:

178
[computed]
cached
quakes-window — A recorded week of earthquakes
USGS earthquakes of M4.5 and up from 2024-01-01 to 2024-01-08, 178 events, as GeoJSON, recorded once and never changed: the window "Exploring without Rust" runs on.
verbs: Source, Meta
[computed]

Every other name under urn:file: is not found in this page: a sink to one has nowhere to go. The CLI binds the whole family to a directory on your disk.

Rust’s standard library cannot see the browser

The browser has a clock and timers, but a Rust crate compiled for wasm32-unknown-unknown cannot reach them on its own. That target has no operating system, so the standard library has nothing to call. std::time::Instant::now(), SystemTime::now() and std::thread::spawn all compile for it, and then fail when they are called. Everything the browser offers is reached through JavaScript imports, which is what wasm-bindgen, js-sys and web-sys provide.

The same target also runs outside a browser: under a WebAssembly runtime such as wasmtime, where there is no JavaScript at all. So a library that wants to run in both places must not import JavaScript itself. It takes what it needs from its host, the way the kernel does. That is why a crate in this ecosystem says “the host supplies a clock” rather than calling Date.now() itself.

The page supplies its clock like this:

/// The browser's clock: `Date.now()`, milliseconds since 1970. `std::time` has no clock on
/// `wasm32-unknown-unknown`, and ikigai-core reads none itself, so the page supplies one,
/// the way `ikigai-web-demo` does.
#[cfg(target_family = "wasm")]
struct BrowserClock;

#[cfg(target_family = "wasm")]
impl Clock for BrowserClock {
    fn now(&self) -> ikigai_core::Time {
        ikigai_core::Time::from_millis(js_sys::Date::now() as u64)
    }
}

Only a page that asks for a clock gets one, as Time is a resource explains, and this page does not ask. Try it: the time endpoint refuses, and a trace has no durations to print.

error: endpoint error: this kernel has no clock: its host did not give it one (Kernel::with_clock)
trace  urn:iki:tutorial:title
  client      ikigai repl  ·  capability: root (full authority)
  transport   embedded · in-process
 
urn:iki:tutorial:title   title · computed · ThreadId(1) · —   → 27b  resource oriented computing

The — in the trace is where a duration would go. With no clock, the kernel does not guess.

Timers work the same way. A timer is not a clock: a clock answers “what time is it”, and a timer answers “wake me when”. A native host’s timer runs on a thread. In the browser, a host builds one on setTimeout or setInterval, and hands it to whatever needs to wait. ikigai-web-demo does this for its scheduled jobs, and the Retry overlay in ikigai-throttle takes a timer the same way.

Bridging the browser’s async world

The kernel’s futures are Send: they may be moved between threads, which a native host needs. A future that waits on a browser promise is not, because JavaScript values stay on the page’s one thread. So the page cannot simply await fetch inside an endpoint.

The bridge is small. The endpoint starts the browser work as a task of the page’s own, with spawn_local, and waits on a channel that the task completes. The channel’s future is Send, so the kernel never holds anything that is not. This is how the page fetches the RDF Toolbox’s build in Checking a resource:

/// Where the toolbox reads its build, `urn:iki:toolbox:dist:{path}` (`ikigai_toolbox::DIST`):
/// in the page, the published copy of `ikigai-toolbox`'s `build.sh` output, which pages.yml
/// puts in `toolbox/` beside the book's root and which the browser fetches only when a name
/// asks for one of its files. So `urn:toolbox:check:{iri}` composes in the page, from the
/// build's own template, and nothing is fetched until a reader opens a check.
///
/// The kernel's futures must be `Send` and a browser fetch is not, so the fetch runs as a task
/// of the page's own (`spawn_local`) and hands its answer back over a channel, which is.
#[cfg(target_family = "wasm")]
fn dist_space() -> Arc<dyn Space> {
    use ikigai_core::{AsyncFnEndpoint, Description, EndpointSpace, InvokeFuture, UriTemplate};
    let fetched = AsyncFnEndpoint::new(
        "book-toolbox-dist",
        |inv: &ikigai_core::Invocation<'_>| -> InvokeFuture<'_> {
            let path = inv.bindings.get("path").unwrap_or_default().to_string();
            let (answer, answered) = futures::channel::oneshot::channel();
            wasm_bindgen_futures::spawn_local(async move {
                let _ = answer.send(fetch_published(&path).await);
            });
            Box::pin(async move {
                answered
                    .await
                    .unwrap_or_else(|_| Err(Error::Endpoint("the fetch was dropped".into())))
            })
        },
    )
    .with_description(
        Description::new("book-toolbox-dist")
            .title("The toolbox build this book publishes")
            .summary(
                "One file of the RDF Toolbox build published beside this book (`toolbox/`), \
                 fetched by the browser when it is asked for.",
            )
            .verb(Verb::Source)
            .verb(Verb::Meta)
            .output("application/octet-stream"),
    );
    Arc::new(EndpointSpace::new().bind(
        UriTemplate::parse("urn:iki:toolbox:dist:{path}").expect("a constant template"),
        fetched,
    ))
}

/// Built natively there is no published build to fetch, so nothing is bound there: a check
/// page fails on its template exactly as the chapter's own host's does.
#[cfg(not(target_family = "wasm"))]
fn dist_space() -> Arc<dyn Space> {
    Arc::new(ikigai_core::EndpointSpace::new())
}

/// The bytes of `toolbox/{path}` beside the book's root, as the browser fetches them. A name
/// that is not one plain file name is not found without a request: the toolbox asks only for
/// the files on its allowlist, and nothing else under the site is the dist's to serve.
#[cfg(target_family = "wasm")]
async fn fetch_published(path: &str) -> Result<Representation, Error> {
    use js_sys::{Function, Promise, Reflect, Uint8Array};
    use wasm_bindgen_futures::JsFuture;
    #[wasm_bindgen]
    extern "C" {
        #[wasm_bindgen(js_name = fetch)]
        fn fetch_url(url: &str) -> Promise;
    }
    let plain = !path.is_empty()
        && !path.starts_with('.')
        && path
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'));
    if !plain {
        return Err(Error::NotFound(format!(
            "urn:iki:toolbox:dist:{path}: not a file of the published build"
        )));
    }
    let failed = |e: JsValue| Error::Endpoint(format!("toolbox/{path}: {e:?}"));
    // Where the build is published, as `js/toolbox.js` says (`window.ikigaiToolboxBase`, the
    // absolute path of `toolbox/` beside the book's root). mdbook's `path_to_root` would do,
    // but it is a `const` in the page's head, which is not a property of the global object.
    let base = Reflect::get(&js_sys::global(), &JsValue::from_str("ikigaiToolboxBase"))
        .ok()
        .and_then(|v| v.as_string())
        .ok_or_else(|| {
            Error::NotFound(format!(
                "urn:iki:toolbox:dist:{path}: this page does not say where the toolbox build is \
                 published (js/toolbox.js sets window.ikigaiToolboxBase)"
            ))
        })?;
    let url = format!("{base}{path}");
    let response = JsFuture::from(fetch_url(&url)).await.map_err(failed)?;
    let field = |name: &str| Reflect::get(&response, &JsValue::from_str(name));
    if !field("ok").map_err(failed)?.as_bool().unwrap_or(false) {
        let status = field("status").map_err(failed)?.as_f64().unwrap_or(0.0);
        return Err(Error::NotFound(format!("{url}: HTTP {status}")));
    }
    let read: Function = field("arrayBuffer")
        .map_err(failed)?
        .dyn_into()
        .map_err(failed)?;
    let pending: Promise = read
        .call0(&response)
        .map_err(failed)?
        .dyn_into()
        .map_err(failed)?;
    let buffer = JsFuture::from(pending).await.map_err(failed)?;
    Ok(Representation::new(
        ikigai_core::ReprType::new("application/octet-stream"),
        Uint8Array::new(&buffer).to_vec(),
    ))
}

A browser timer uses the same bridge: a task waits on the setTimeout promise, then completes the channel the endpoint is waiting on.

The other direction never happens: the kernel does not call into the page. In Spreadsheet, VII: push the page awaits a future that a cut completes, and then decides on its own turn of the event loop what to redraw. In A space that changes while it runs the live host’s rebuild loop is itself a spawn_local task.

What fails, and how

  • A call that compiles but cannot run. Reading the platform’s time or starting a thread compiles for this target and fails when it runs, so no build catches it. A crate that must run here keeps those calls behind #[cfg(not(target_arch = "wasm32"))], or takes them from its host, and checks for them in its tests.
  • A panic stops the page’s kernel. A panic in WebAssembly is a trap: the module stops, and the page’s kernel with it, until you reload. The page installs console_error_panic_hook, so the reason appears in the browser’s console. The book’s programs stay far inside every bound for this reason.
  • The JavaScript and the WebAssembly must agree. The generated JavaScript and the compiled module share an interface version. The book pins wasm-bindgen exactly, and pins its command-line tool to the same version, because a mismatch fails in the page, not in any build.

The same code natively

book-wasm also builds as an ordinary Rust library, which is how this repository’s gates lint and test it. Natively, the page’s clock is the system’s, and the toolbox’s build is not fetched at all. The chapters’ cells are run by those tests too: each one is read from its page and resolved against a kernel built exactly as the page builds it. So a cell whose answer changes fails the build, not your reading.

A kernel behind a socket is the opposite end: a native host that serves a kernel to other processes, with every seam in the table above filled from the operating system.