Introduction
A resource-oriented computing kernel in Rust, taught from the outside in.
What ikigai is, in one paragraph
Everything is a resource named by a URI. You never call a function; you resolve a
name against a kernel, which routes it to a bound endpoint and hands back a
representation — bytes plus a media type. There are five verbs, not a vocabulary of
methods: Source (read), Sink (write), Exists, Delete, and Meta (describe
yourself). Transreptors convert one representation into another, so the same resource
can arrive as text, as Turtle, or as HTML. Golden threads track what a result was
derived from, so a write invalidates exactly what it should. Capabilities gate
authority and attenuate as they pass down a call chain.
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. A function call gives you none of that; it is a jump with arguments.
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
whole model — a catalog, a manifold, a pipe, a format change, a cache hit, a cut thread,
a trace — and then the REPL grammar those commands are written in, with a Run button
wherever this 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.
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.
How to read this book
The published copy is at https://ikigai-rs.github.io/ikigai-tutorial/, rebuilt from main
on every merge.
Every Rust block in these pages is compiled and run by mdbook test, and 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 books/ikigai --open
# check that it still tells the truth
./scripts/test-books.sh
The code lives in
crates/ — one crate per
part, plus the two that gate the book itself.
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. 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. This, too, is part of the story I hope to continue to unveil in the coming weeks.
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
Twenty minutes, nothing compiled
Two front doors, and this is the second. The first is already open: every Run button
in this book resolves against a kernel compiled into the page — Running
it says how — and it binds only Part I’s names. This
chapter is the other door: the full host, ikigai, on your machine, with everything it
links. Eight commands, no Rust, and by the end you have seen the whole model — a catalog,
a manifold, a pipe, a format change, a cache hit, a cut thread, and a trace.
0. Install
cargo install ikigai-cli --locked
ikigai --version
⚠ The crate is
ikigai-cli; only the binary is calledikigai.cargo install ikigaidoes not fail — it fetches an unrelated crate by another author.--lockedbuilds the dependency versions the release was tested with rather than whatever the registry resolves today.
Every command below is one-shot: -c runs a line and exits, --plain drops the
decoration. Run ikigai with no arguments for the REPL, which is the same grammar with
a memory — and the sixth command needs that memory, so it says so.
1. Everything resolvable
ikigai --plain -c 'source urn:kernel:catalog'
Every endpoint the host binds, describing itself, as one Turtle graph — several hundred lines on a full install. Nobody wrote this document; it is assembled from each endpoint’s own description, which is why it cannot be out of date.
2. Everything you may do
ikigai --plain -c 'source urn:kernel:actions'
One IRI per line: the endpoints the capability you hold may invoke. As root that is nearly everything. It is the same list an agent would be handed as its tools, computed from the same descriptions, and it narrows as authority narrows — the difference between this and the catalog is the whole capability model in one diff.
3. A pipe
ikigai --plain -c 'source urn:iki:fn:toUpper in="a b" | urn:iki:fn:reverseList'
A B
[2 computed]
The first resolution’s output became the second’s unnamed argument. [2 computed] is the
kernel counting: two resolutions, neither served from cache. There is no function call in
that line — two names, resolved in order.
4. Another format
ikigai --plain -c 'describe urn:iki:fn:toUpper text/turtle'
describe is the Meta verb, and the trailing type is as=: the same endpoint’s
description as a graph rather than as text. Try text/plain for the human face. A
resource does not have a format; it has whatever the kernel can reach.
5. A cache hit
This one needs the REPL, because the cache lives in the process and a -c run exits.
Start ikigai, then write a file into the workspace and read it back twice:
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
The second read came from the cache; the file was not opened. cache asks without
resolving — a probe, not a call. A write is never served from a cache, so the sink
says so.
6. Cut a thread
Still in the REPL. A golden thread is what a cached answer hangs from: the file
endpoint declared that its answer depends on a thread named after the file, and
urn:kernel:cut is the resource for cutting a thread by hand:
ikigai> sink urn:kernel:cut urn:file:notes.txt
cut urn:file:notes.txt
[uncacheable]
ikigai> cache urn:file:notes.txt
not cached
The cached read stopped being valid at that instant, and so would anything derived from it — a composite that had read the file inherits its thread. Writing to the file does the same cut without your help; this is the resource for doing it on somebody else’s behalf, which is what a filesystem watcher does when a file changes out from under the kernel.
Now the part the first draft of this page got wrong. Try the same cut on a pure function:
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"
cached
Still cached. A cut invalidates the entries whose declared threads include the one
you cut, and toUpper — a pure function of its arguments — declared none: same input,
same answer, forever, and no thread to hang that on. The entry is valid until the kernel
forgets it. Nothing was named wrongly here; there was simply nothing to invalidate, and
urn:kernel:cache shows exactly that:
ikigai> source urn:kernel:cache
cache
entries 3
urn:file:notes.txt text/plain 17 B 1 thread
urn:iki:fn:toUpper application/json 268 B 0 threads
urn:iki:fn:toUpper text/plain 3 B 0 threads
Two entries for toUpper — its description, fetched once to route the arguments, and the
answer — each with 0 threads. The file’s entry has 1 thread, and it is still
listed even though section 6 cut it and the probe said not cached: a cut bumps a
generation and nothing more, and a stale entry is evicted lazily, the next time
something resolves the name. cache only looks. Read the file again and the entry is
replaced.
7. Which threads have been cut
ikigai> source urn:kernel:threads
threads (cut generations)
urn:file:notes.txt gen 2
urn:iki:fn:toUpper gen 1
Every thread ever cut in this kernel, with its generation — the file’s twice (once by the
sink that wrote it, once by hand), toUpper’s once. A generation bumps whether or not
anything depended on the thread: cutting is cheap and blind, and validity is decided
lazily, when an entry is next looked up, by comparing the generation it was made at with
the one now.
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 · cached · main · 0ms → 3b A B
One real resolution, recorded: who asked, under what authority, over what transport, and
then the tree — each node saying whether it was computed or served (served, here: the
cut in section 6 did not touch it), on which thread, how long it took. A composite shows its sub-resolutions as children. This is the same
issue_traced the book’s tests call, rendered.
What you have seen
A system that can list what exists and what you may do, resolve names into other names’ arguments, change a representation’s format on request, serve an answer without recomputing it, invalidate precisely — what declared the thread, nothing else, and never by timeout — and show you its own execution. None of it needed a line of Rust. All of it is what Resolution describes, and Part I is where you build an endpoint that gets every one of these properties for free.
Every name printed on this page is checked, on every commit, against a written-down
claim about the CLI (books/ikigai/cli-vocabulary.txt) — and that file is checked against
a real binary by a test that has to be run by hand, because CI has no ikigai. Every
transcript on this page is replayed against a real binary by another
(cargo test -p book-urns --test book_transcripts -- --ignored), which is how the first
draft’s section 6 was caught claiming a cut had invalidated something it had not. If a
command here fails on a newer CLI, those two are where to look first.
The REPL grammar
One grammar drives the ikigai REPL, its -c one-shot, the cells in this book, and the
browser demo’s terminal — the same engine, with a different host behind it. This chapter
is that grammar, one runnable line per construct. Where the in-page kernel binds the
names, the line has a Run button; the rest run at a shell.
A resolution
source <iri> [input]
source <iri> key=value …
source is the verb. A bare word after the IRI is positional and fills whichever
declared argument is left unnamed; key=value names one, and key has to be an argument
the endpoint declared — the engine routes by the description, which is why a typo in a
name is an error rather than a silently ignored argument.
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.
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.
The pipe: |
source a [input] | b | c
The whole output of one stage becomes the next stage’s unnamed argument, by value. Each
stage after the first is written as its IRI alone — it is a source, so the verb is
implied.
A B [2 computed]
The tally counts every resolution the line performed. 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 resolutions — one split, three upper-cases. At a shell the CLI adds a line saying
how wide the fan-out ran ([fan-out 3 → 1 wide] on a single thread, 3 → 3 wide on a
scheduled host); the engine itself only counts.
The fork: ( a ; b )
source a [input] | ( b ; c )
Fan the same input to each branch and join their outputs.
A B C c b a [3 computed]
Run this after the map cell above and the tally reads 1 cached · 2 computed: the split
was already in the cache, and the engine counts what it served as well as what it ran.
Composition: urn:iki:fn:compose
Not a grammar construct — an endpoint — but the one the grammar exists to serve.
compose takes a shape, a text resource with $a{<iri>} transclusion markers, resolves
every marker through the kernel, and splices the answers in, recursively:
ikigai --plain -c '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
The page is one resource that pulled the others in. That is how the browser demo builds its whole page, and how every part of a composed answer stays under its own golden thread: the composite is cacheable only while every part is.
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.
The pipe form stores the upstream value. A successful sink cuts the golden thread named
after the target, which is how a write invalidates every cached read that declared that
thread — the file endpoint’s reads do; a pure function’s do not, having nothing to hang
one on.
ok [uncacheable] aTitleFromTheGrammarChapter [computed]
Describing: describe
describe <iri> [type]
The Meta verb. The type defaults to text/turtle; text/plain is the human face, and
any other type the host can reach through a transreptor works too.
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]
Probing the cache: cache
cache <iri> [args]
Would this be served from the cache right now? Answered without resolving, so asking does not change the answer.
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`)
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 rather
than bound, so they do not appear here; source reaches them.
urn:iki:fn:toUpper → toUpper
urn:iki:fn:reverseList → reverseList
urn:iki:fn:compose → compose
urn:iki:fn:conditional → conditional
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
Eleven names in this page, in the order they were bound — ikigai-fn’s eight, then
this book’s three chained on top, exactly as Binding
wrote them. 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 — a full
language whose builtins are the five verbs. Out of scope for this book, and worth
knowing exists.
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:
| verb | meaning |
|---|---|
Source | give me a representation of this |
Sink | here is a new state for this |
Exists | is there anything at this name? |
Delete | remove it |
Meta | describe 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, and no cut touches
it; that is what .cacheable() on such a function is claiming.)
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.
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 a scoped capability first refuses something, and Part III leans on them hardest.
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
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 — 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:
cargo install ikigai-cli --locked
⚠ The crate is
ikigai-cli. Only the binary is calledikigai— andcargo install ikigaidoes not fail, it fetches an unrelated crate by another author and leaves you with something that is not this.--lockedbuilds against the dependency versions the release was tested with rather than whatever the registry resolves today.
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.
Two commands worth running on your first day, 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 favour.
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() and
.depends_on(TITLE) — “cache this, and treat it as valid until the thread named
urn:iki:tutorial:title is cut.” 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 on purpose.
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 — hello_camel::kernel() compiled to WebAssembly, 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 and the capability — a result computed under one
authority is never handed to a caller holding another. You will not feel that here, where
everything runs as root, but it is why .cacheable() is safe to say on an endpoint whose
answer depends on who is asking.
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 declared .depends_on(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.
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) · — → 25b resourceOrientedComputing └─ 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. Run it after the cell above and the byte count
and the text change, because the title did — same kernel.)
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:endpoint:camel-case:action:source> .
<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> .
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 theArgSpecis 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.
assert!(catalog.contains("<urn:ikigai:endpoint:title:action:source> a ik:Action"));
assert!(catalog.contains("<urn:ikigai:endpoint:title:action:sink> a ik:Action"));
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.
@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:endpoint:camel-case:action:source> .
<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:endpoint:camel-case:action:source> a ik:Action ;
ik:verb "Source" ;
ik:output "text/plain;charset=utf-8" ;
ik:input <urn:ikigai:endpoint:camel-case: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.
cargo run -p hello-camel -- --catalog
prints the same graph from the tutorial binary.
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 ikigai with no arguments 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
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 a cut does not do: urn:iki:fn:toUpper declared no thread (a pure function has
nothing to hang one on), so cutting a thread by that name would leave its entry exactly as
cached as before. urn:kernel:threads shows every thread that has ever been cut, with its
generation — the file’s twice.
⚠ The Turtle the CLI prints may differ in shape from the tutorial host’s: which
ikigai-vocabrenders 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.
The next two chapters 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");
}
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
- The engine routes named arguments by it.
in="hello"finds the right slot because the endpoint declared a slot calledin. - 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.
- The catalog is built from it.
urn:kernel:catalogandurn:kernel:actionsare assembled out of these descriptions, not maintained separately. - The agent tool list is it. Projected over MCP, an endpoint’s description becomes a tool definition. Nobody writes that by hand.
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 anrdfs:Classfor 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:toUpperbreaks the rule, and so does its siblingurn: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.
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
Sinkmutates 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.18) reads a set of IKIGAI_* variables — IKIGAI_FILES, the one you
meet first, in The file workspace, and IKIGAI_GRANTS,
IKIGAI_SMTP_HOST, IKIGAI_PASSKEY_ORIGIN and others carrying deployment facts. 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, created if missing.
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.
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/abc123grants 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/. Neither hello-camel nor
loadable-module has an identity of any kind, 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 callingstd::fsdirectly.
Two reasons, and the second is the one that catches people:
std::fsbypasses the jail and the capability check entirely.- 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::fsread 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
ikigai --plain -c 'sink urn:file:notes.txt "a note"'
ikigai --plain -c 'source urn:file:notes.txt'
ikigai --plain -c 'exists urn:file:notes.txt'
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.
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 everycargo test— Hello, resource does exactly that withcamel_case().describe().id.
1. 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 aturn:iki:tutorial:yours:lower-camel-case.hello_camel::camelis the pure function undercamel-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", andcargo test -p hello-camelstill passes untouched. Ifcamel-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. 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 yourlower-camel-casefrom exercise 1 (or ofword-count, if you skipped it — aseparatormakes sense there too): theArgSpeclist in the description and the implementation. - Run —
cargo test -p your-endpoints - Right when — resolving with no
separatorgives the same answers as before, and resolving withseparator=","camel-cases"a,b,c"into"aBC". A test asserting yourArgSpecis present is worth writing too —your_thing().describe().inputsis a list you can look at. - Read again — Why an endpoint describes itself, the ArgSpecs section.
Hint
The trap here is the interesting part of the exercise, and it will not announce itself:
a declared default_value is not injected into the invocation. Nothing fills the
argument in for you. Declare ArgSpec::new("separator").optional().default_value(" ") and
then call inv.inline_str("separator") on a request that omitted it, and you get
Err(MissingArgument("separator")) — not " ".
So the description and the implementation each carry half of the same decision, and only one of them is checked by a compiler:
let separator = inv.inline_str("separator").unwrap_or(/* your default */);
Which means the failure mode to watch for is the two halves disagreeing — a description that promises one default while the code applies another. Nothing in the system catches that; a test that resolves without the argument and asserts the declared default’s behaviour does.
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:titlealready answers from your host — it is the resource What resolution buys you writes to, and yourspace()chains onto the one that binds it — so the only new thing is a version ofcamel-casethat 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::Referenceand gets the camel-cased contents oftitle. ThenSinka 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 forcamel-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())),
}
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 aturn: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 and the capability — 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.
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 the four endpoints 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
contentandas. 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/turtleand 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:
/// 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`.
///
/// 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.
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"]),
)
}
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.
#[test]
fn the_kernel_selects_the_transreptor_from_its_declaration() {
let kernel = kernel_in(None);
let plan = kernel
.select_transreptor("text/turtle", "text/markdown")
.expect("a route from Turtle to Markdown exists now");
assert_eq!(plan.len(), 1, "one hop");
assert_eq!(plan[0].endpoint, "urn:iki:tutorial:markdown");
assert_eq!(plan[0].to, "text/markdown");
// ...and no route to a type nothing produces.
assert!(kernel
.select_transreptor("text/turtle", "application/pdf")
.is_none());
}
And then the payoff. camel-case has never heard of Markdown. Ask for its description
as Markdown and the kernel renders Turtle, finds the route, and runs it:
/// `Meta … as=text/markdown` on an endpoint that knows nothing about Markdown. The
/// renderer emits Turtle; the kernel finds the route; the answer is Markdown.
#[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()));
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. ASourcewithas=reaches the endpoint withasas an ordinary argument — an endpoint that offers faces (text and Turtle, say) reads it and answers, and one that does not ignores it. Routing aSourcethrough a transreptor chain is what the pipe does explicitly:source X | urn:rdf:transrept as=…. If you expectedsource urn:iki:tutorial:camel-case as=text/markdownto produce Markdown, that is the chapter’s one disappointment, and it is stated here so it is not discovered at a keyboard.
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.
⚠ Delete
.requires(WRITE)from theSinkaction 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. Where this actually stands says
plainly that an endpoint reached through a module can declare requires and not be
gated by the host today. Everything on this page is about a linked-in endpoint, where the
kernel resolving the name is the kernel enforcing the floor. 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
title
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
graphnames — the query above could have joinedik:Endpointto itsrdfs:subClassOfrelatives — and a transreptor shows up as bothik:Endpointandik: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 aclassat 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");
}
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
/// 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
FixedClockexists 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, 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:greet:hello 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.2.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 marshalling, 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:greet:` 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:greet:"],
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:greet:. It has no Exact for urn:greet:hello,
and could not write one without knowing what the module offers. Prefix routing is what
lets a host delegate a region of the name space 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-xsltexists 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:greet:hello` — 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 hello() -> AsyncFnEndpoint {
AsyncFnEndpoint::new("hello", |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("hello")
.title("Greet")
.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:greet:hello"), hello())
}
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:greet:hello name=urn:host:name
module asks the host for urn:host:name
out Hello, Peter!
Three resolutions happened, in this order:
- The host resolved
urn:greet:hello. Nothing in the host’s own space matched, so theFallbackreached theModuleSpace, whose prefixurn:greet:did. - The module’s endpoint ran, and resolved
urn:host:name— back through the host. - The host answered
Peterfrom 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:greet:hello").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 marshalling. 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 nourn:greet:hello.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, orHostCall— “resolve this for me and call me back.”
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.1, ikigai-jsonld
0.1.1 and ikigai-shacl 0.1.1, the versions published as this was written — 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
How it is wired
The module feature turns on optional dependencies rather than changing the crate’s
identity:
[dependencies.ikigai-module]
version = "0.1.6"
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-module 0.2.0 and its
shell examples were probed against ikigai-cli 0.1.18, both on 2026-09-08; a test
(crates/book-urns/tests/book_claims.rs) can re-check each stamped claim 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.2.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 and, at 0.2.0, capability enforcement across the boundary 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.2.0:
| piece | state |
|---|---|
| the callback machinery | proven — InProcessTransport, exercised end to end |
| the wire session | proven through the codec — LoopbackTransport encodes and decodes every message |
| an out-of-process transport | built, 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 capability | enforced 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) |
| an embedded wasm runtime | not built — no wasmtime in its manifest; a host embedding one is Phase 2 |
| isolation | not there — a process boundary is not a resource budget |
| hosts that load modules | one, the browser demo (ikigai-web-demo, WasmModuleSpace, as of 2026-09-08) |
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. Marshalling 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. 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", |_inv: &Invocation<'_>| -> InvokeFuture<'_> {
Box::pin(async move { Ok(Representation::new(text_plain_utf8(), b"in".to_vec())) })
})
.with_description(
Description::new("gated")
.verb(Verb::Source)
.requires("urn:cap:demo:module"),
);
let module = EndpointSpace::new().bind(Exact::new("urn:greet:gated"), gated);
let host = ModuleSpace::new(
["urn:greet:"],
Arc::new(InProcessTransport::new(module)),
ModuleFloor::public(),
);
let kernel = Kernel::new(Arc::new(host));
let request = || Request::new(Verb::Source, Iri::parse("urn:greet:gated").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-09-08).
Do not assume the ABI is stable. Phase 2 exists precisely to change how these messages
travel — and 0.2.0 already changed ModuleSpace::new’s signature once.
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, and no
crate sets an execution or memory budget — the word “fuel” appears in none of them
(checked across the organization’s public repositories on 2026-09-08; the probe re-checks
ikigai-module 0.2.0 and ikigai-cli 0.1.18 by manifest). Treat this section as the
direction and the table above as the state, and do not plan a deployment on the
difference.
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_invocationfails withno endpoint resolved for urn:iki:tutorial:yours:module:greeting, anda_name_outside_the_module_prefix_does_not_reach_itstill 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
greetingitself is untouched.
Hint
Follow the path before you write it: the host routes urn:greet:hello 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 withdenied: 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 owngreetingendpoint instead. Sinceikigai-module0.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 last chapter of Part II ended 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 for two chapters. 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
ikigai-dev-server, which offers development tooling — git, gh and cargo as resources,
graph operations, SPARQL, and an archive of explanations and annotations over a set of
repositories:
ikigai-dev # default socket: ~/.ikigai/dev.sock
ikigai --connect ~/.ikigai/dev.sock
ikigai --connect ~/.ikigai/dev.sock -c 'source urn:repo:status'
Its interesting property is not in any of its code. It is in its Cargo.toml, which its
own README calls the module manifest: the binary links only what it serves. There is no
calendar in it, no contacts, no EventKit — 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.
That is a second boundary, and it sits underneath the capability model rather than
inside it. The contrast the README draws is with the same omnibus 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. That same server does link
ikigai-llm, deliberately, and its manifest argues the case: an outbound HTTP client to local inference is not the platform-authority class the gating exists to exclude, and the rejected alternative — mountingurn:llm:from the main host — would have coupled every fresh explanation to the uptime of the very process the dev seam exists to stand apart from.
What a broken connection is allowed to do
One last thing that will matter in two chapters. 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 chapter.
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.
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. A peer speaking a different wire version 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’sDefaultHasher(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 whatopenssl x509 -noout -fingerprint -sha256prints, minus the colons and the case.segment_id— a namespace, not an identity. It names the tenant directory this client’surn: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 enrolment: 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(),
})
})
}
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 enrolment 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.
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:
- Per-identity grants — a
clients.jsonmaps 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. - A fixed ceiling —
serve --cap urn:cap:personal:calendar:read:freebusygives every authenticated client the same authority and nothing else. It also remains the outer bound under posture 1. - 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 the next one:
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.
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 the last
two chapters, 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.
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 transient failure 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.
“Transient” is a typed property, not a guess about the message: exactly Timeout and
Unavailable. Everything else — a bad argument, an endpoint error, a resource that is not
there — is permanent, and re-issuing it 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.
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:=/Users/brian/.ikigai/dev.sock"
That line points at the development server from
A kernel behind a socket, which owns an archive held under an exclusive lock —
exactly one process may have it open, so every other process on the machine reaches it
through the socket. 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
All three chapters in one wiring: dial the peer, decide what its answers mean, compose it in front of what this machine serves itself.
#![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/dev.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/dev.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
Three chapters of 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 for the kernel’s own Lisp. 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 machine client
The previous chapter 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.
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.issuerecords 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 wants | ikigai already has |
|---|---|
tools/list | urn:kernel:actions — the manifold, already narrowed to this capability |
| an input schema | the ArgSpec contracts on each action’s Description |
tools/call | kernel 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.
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:
urn:kernel:actions— deterministic narrowing. Which actions this capability may invoke, and which of those match the shape of what you have.urn:agent:select— a model, used only for what is left over after step 1.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:askdoes 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-cli0.1.18).ikigai mcpwith no--grantor--scopeprintsrunning 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-ipcis#![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 beyondreports 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_backgroundandconnect_when_upare 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 withikigai_core::Error::Unresolved, not with a transport error. - Read again — A kernel behind a socket, both halves: the same
Resolvertrait, 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, andtwo_hosts::mounts::{prefer, overriding, compose}are the three shapes. - Run —
cargo test -p your-endpoints --test beyond - Right when — the prefer-mount answers
3for"a b c"and the counter shows the peer was tried first; the override fails withError::Unavailable; and a prefer-mount overdead(Error::Denied)fails withError::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.
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 editingmodule.rs:name()there has norequires, and Part II’s exercise 3 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 isHello, Ada!; withattenuate(["urn:cap:yours:other"])the resolution fails withError::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.
One wire, three languages
Everything before this part was Rust, 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
(pure standard library, no dependencies) and ikigai-deno
(zero-dependency TypeScript). 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 I | in Python and TypeScript |
|---|---|
| Resolution | connect() and source(): a name resolved over the wire, a representation back |
| Hello, resource | an endpoint as a decorated function, served on a socket |
| Why an endpoint describes itself | the signature is the contract: ArgSpecs derived from annotations, describe() as data |
| Binding, and a host of your own | ikigai --mount urn:py:=<socket> — a mount is a binding, and the host owns the name |
| What resolution buys you | is_cached, source_traced: the kernel’s cache and trace, seen from outside |
| capabilities | a scoped connect(), and a typed DeniedError |
Then a notebook: the catalog as a graph, in rdflib, with one SPARQL query.
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; every one of them was run against ikigai-cli 0.1.18 as it was written.
⚠ Names under
urn:py:andurn: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/ and was run against a served ikigai-cli 0.1.18 as it was written.
pip install rdflib /path/to/ikigai-python # a checkout; not on PyPI yet
ikigai serve /tmp/ikbook-kernel.sock & # a kernel to talk to
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 — and it is the mounting kernel, not this process,
# that will honor 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.
@endpoint("urn:py:shout", summary="Uppercase a string, loudly", cacheable=True)
def shout(text: Annotated[str, "the text to shout"]) -> str:
return text.upper() + "!"
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:
$ 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
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
python3 examples/python/hello.py /tmp/py-hello.sock &
ikigai --plain --mount urn:py:=/tmp/py-hello.sock -c 'source urn:py:hello who=Ada'
Hello, Ada!
[computed]
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:
$ 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]
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. cacheable=True on the decorated function only marks the
representation; served straight from Python nothing is cached, and mounted through a Rust
kernel the same function’s answers are — the cache verdict flips to HIT on the second
call. 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`)
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).
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/ and was run
against a served ikigai-cli 0.1.18 as it was written. ikigai-deno is not on JSR yet, so
the listings import it by path — a sibling checkout at ../../../ikigai-deno; edit the
import if yours is elsewhere.
ikigai serve /tmp/ikbook-kernel.sock &
deno run -A examples/deno/client.ts /tmp/ikbook-kernel.sock
Resolution
// 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
// 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, and the mounting kernel is what honors 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}!`);
export const shout = endpoint("urn:ts:shout", {
summary: "Uppercase a string, loudly",
args: [{
name: "text",
required: true,
summary: "the text to shout",
class: XSD_STRING,
}],
cacheable: true,
}, ({ text }) => `${String(text).toUpperCase()}!`);
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
// 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
deno run -A examples/deno/hello.ts /tmp/ts-hello.sock &
ikigai --plain --mount urn:ts:=/tmp/ts-hello.sock -c 'source urn:ts:hello who=Ada'
ikigai --plain --mount urn:ts:=/tmp/ts-hello.sock -c 'source urn:ts:shout text="resource oriented computing" | urn:iki:fn:reverseList'
Hello, Ada!
[computed]
RESOURCE ORIENTED COMPUTING!
[2 computed]
The second line is the part to notice: a pipe from a TypeScript endpoint into a Rust one, in the host’s grammar, with neither knowing the other’s language. The host owns the topology; the peers own their answers.
What resolution buys you
// 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
// 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.
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).
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, because ikigai-fn 0.2.0 declares no class on its in input; an
undeclared class is invisible to the question, which is the argument for declaring
one. 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 eleven. 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, 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 repositories
ikigai-core— the kernel,Description/ArgSpec, capabilities, golden threadsikigai-fn— the smallest complete example of a space, and the one this book chains ontoikigai-cli— a real host: transports, the engine grammar, MCP projectionikigai-xslt— one crate showing both the linked and the loadable shape side by sideikigai-dev-server— the linkage-gated server from Part III, whoseCargo.tomlis its manifestikigai-emacs— the client whose command surface is generated from the manifoldikigai-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, 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. An entry that declared no
thread, a pure function’s, is untouched by any cut. 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.
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-08, 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),ikigai-store. Everything else depends on it.ikigai-cli— the main host and a workspace of its own: theikigaibinary, 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-jsonld— JSON-LD expand/compact/flatten, lazy-loadable as wasm.ikigai-xsltandikigai-xslt-module— XSLT as a resource, linked or loadable.ikigai-shacl— SHACL validation, native and in the browser.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.
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-signandikigai-encrypt— signatures as RDF graphs, and their dual.ikigai-throttle— rate limit, retry, circuit breaker, failover and timeout as space overlays;Failoveris 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-dev-server— the standalone IPC server for those, whoseCargo.tomlis its manifest.ikigai-web— the standalone HTTP and SPARQL face (published asikigai-web-server).ikigai-runbook— guided, runnable demos as resources.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
ikigai-cms— personal content as one RDF graph.ikigai-cms-web— the reading room over it.
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.66"
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:
ArgSpecfor every argument —name,summary,optional()where it is,class(..)always: an XSD datatype IRI for a scalar, anrdfs:Classfor 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 — and remember that a declared default is not injected; your code applies it.- Single verb: author flat. More than one verb: one
ActionSpecper verb, per Multi-verb endpoints. .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, noturn:fn:countWordsin somebody else’s namespace. - Skolemize: no blank nodes in any graph you emit. Stable IRIs make graphs diffable.
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.
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. 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-targetsdoes not see a target behindrequired-features, and a committedCargo.lockmeans CI tests the graph you froze, never the graph a stranger’s freshcargo addresolves.
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.