Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Where this actually stands

This chapter exists because a tutorial that leaves you to discover the maturity of a feature by hitting its edges has wasted your afternoon.

Every claim below about a repository other than this one carries the version it was true at, because those repositories move on their own schedules and a sentence about them is a claim, not a fact. This book builds against ikigai-core 0.2.2 and ikigai-module 0.5.1, both stamped on 2026-10-27 (each its crate’s newest release on crates.io at its stamp; the module claims below were written at 0.4.0, carry that stamp, and each still holds at 0.5.1), and its shell examples are replayed against ikigai-cli 0.2.6, the one release pinned in books/ikigai/ikigai-cli.version (the newest on 2026-10-29). A test (crates/book-urns/tests/book_claims.rs) holds those three numbers to the workspace’s lock and that pin, and re-checks each stamped claim below against the published crate it names, so that when one of them stops being true the fix is a diff, not a discovery.

Phase 1

ikigai-module 0.4.0’s own first line still calls it “the dynamically-loadable module format (Phase 1: in-process proof)”. That line undersells the crate — it has grown a socket transport, at 0.2.0 capability enforcement across the boundary, and at 0.3.0 typed errors, golden threads and endpoint cards crossing the wire, since somebody wrote it — which is worth knowing as a habit: a crate’s one-line self-description is the thing least likely to be updated when the crate changes.

What is actually there, at ikigai-module 0.4.0 (0.4.0 over 0.3.2 holds a module’s out-of-band call to the host to its session’s capability, fails a call over an unwired channel at once with a typed error, and has the session transport return the kernel’s own Result; 0.3.2 over 0.3.1 records a failed callback, below; 0.3.1 over 0.3.0 was a constructor form, no new surface):

piecestate
the callback machineryproven — InProcessTransport, exercised end to end
the wire sessionproven through the codec — LoopbackTransport encodes and decodes every message
an out-of-process transportbuilt, Unix only — UdsTransport + serve: the module runs in its own process behind a 0600 socket, and serve refuses peers belonging to another user
a module’s declared capabilityenforced since 0.2.0 — ModuleFloor on the mount, and an endpoint’s own requires honored across the boundary (the crate’s test a_module_endpoints_declared_requirement_is_enforced_like_a_linked_ones); since 0.3.0 the module’s cards cross at connect (ModuleCall::Cards), so the host enforces against the module’s own description and the manifold through a ModuleSpace lists the module’s actions rather than one endpoint named module
the error type across the wiresince 0.3.0 — ModuleReply::ErrorTyped and ModuleCall::HostError: a module-side denial arrives at the host as a Denied, a host resource’s NotFound arrives in the module as a NotFound; before that the wire flattened both to a string
golden threads across the wiresince 0.3.0 — ModuleReply::ResolvedThreaded carries a result’s declared threads; before that a module result through the loopback or wasm session was cached forever with nothing to cut it, and a Sink through the mount left it stale. Since 0.3.2 a callback that failed is a dependency on the wasm path too, by core’s rule for a failed sub-request (record_failure): a NotFound or Unresolved hangs the module’s answer from the missing name, any other failure makes it uncacheable; the native transports (in-process, loopback, Unix socket) already had that, through the host’s own issuer
an embedded wasm runtimenot built — no wasmtime in its manifest; a host embedding one is Phase 2
isolationnot there — a process boundary is not a resource budget
hosts that load modulesone, the browser demo (ikigai-web-demo, WasmModuleSpace, as of 2026-10-10)

Read that table the right way

The order is deliberate and it is not the order most projects build in. The semantics came first — re-entrancy, capability inheritance, the session shape — and every transport since has been that same session with a different pipe under it: a direct call, then the codec over in-memory channels, then a socket.

That is the harder half done first. Marshaling bytes over a socket is well-understood work; a host and a module calling into each other mid-invocation without deadlocking, with authority attenuating correctly across the boundary, is where the design risk lives.

What you should not assume

Do not assume a module is a security boundary. The Unix-socket transport buys you a process boundary — a separate address space, and a socket that refuses peers belonging to another user — and that is worth something. It is not a sandbox: a module reached that way is a native binary with all the ambient filesystem and network access its own process has, the callback is a deliberate hole in whatever isolation you would put around it, and the transport this book demonstrates (InProcessTransport) runs the module inside your own process anyway. A module today is a packaging and lazy-loading mechanism.

Do assume the host enforces what a module declares — since ikigai-module 0.2.0, and not before. At 0.1.10 an endpoint reached through ModuleSpace could declare requires("urn:cap:…") and be invoked by a caller who did not hold it; this book said so, in this paragraph. 0.2.0 closed that: ModuleSpace::new takes a third argument, the mount’s ModuleFloor (every request through the mount must satisfy it), and an endpoint’s own declaration is enforced across the boundary exactly as it is for a linked-in one. Authority attenuates correctly across the boundary as it always did (the module’s callbacks run under the caller’s capability, narrowed — exercise 3 below), and now the module’s own card is a gate too. At 0.3.0 the card itself crosses the wire when the host connects, so the enforcement is host-side against the module’s own description rather than against a floor the host wrote for it. The test that keeps this sentence true:

    /// A module endpoint's declared `requires` is enforced across the boundary — since
    /// ikigai-module 0.2.0. Before it, this declaration was ignored by the host, and the
    /// book said so; the claim flipped when the crate did, and this test is what keeps
    /// the chapter's sentence true.
    #[test]
    fn the_host_enforces_what_the_module_declares() {
        let gated = AsyncFnEndpoint::new(
            "gated-resource",
            |_inv: &Invocation<'_>| -> InvokeFuture<'_> {
                Box::pin(async move { Ok(Representation::new(text_plain_utf8(), b"in".to_vec())) })
            },
        )
        .with_description(
            Description::new("gated-resource")
                .verb(Verb::Source)
                .requires("urn:cap:demo:module"),
        );
        let module =
            EndpointSpace::new().bind(Exact::new("urn:iki:tutorial:module:gated-resource"), gated);
        let host = ModuleSpace::new(
            ["urn:iki:tutorial:module:"],
            Arc::new(InProcessTransport::new(module)),
            ModuleFloor::public(),
        );
        let kernel = Kernel::new(Arc::new(host));
        let request = || {
            Request::new(
                Verb::Source,
                Iri::parse("urn:iki:tutorial:module:gated-resource").expect("iri"),
            )
        };

        let denied = block_on(kernel.issue(
            request(),
            &Capability::root().attenuate(["urn:cap:demo:other"]),
        ))
        .expect_err("the module's own declaration gates the call");
        assert!(matches!(denied, Error::Denied(_)), "{denied:?}");

        let allowed = block_on(kernel.issue(
            request(),
            &Capability::root().attenuate(["urn:cap:demo:module"]),
        ))
        .expect("the right scope reaches the module");
        assert_eq!(String::from_utf8_lossy(&allowed.bytes), "in");
    }

Do not assume you can ship a module to a running host. Nothing loads one at runtime outside the browser demo (as of 2026-10-10).

Do not assume the ABI is stable. Phase 2 exists precisely to change how these messages travel — 0.2.0 changed ModuleSpace::new’s signature once, and 0.3.0 added variants to ModuleCall and ModuleReply, which are exhaustive public enums, so anyone matching on them had a source break while ModuleSpace::new did not move. (The 0.2 bytes on the wire are unchanged; the new messages are appended.)

What is genuinely usable now

  • The dual-mode pattern, as a way to keep a heavy dependency out of hosts that do not need it (Dual-mode crates).
  • The browser case, which is real and running.
  • The InProcessTransport, as a way to develop and test a module’s semantics long before its transport exists — which is what this book’s demo does.

Where isolation would come from (direction, not shipped)

Capabilities constrain authority: which resources a resolution may reach, narrowing as they pass down a call chain. That is what exists today, it is enforced, and it is most of what an agent-facing system needs. What a capability says nothing about is resource consumption — how much memory an endpoint allocates, how long it runs, which syscalls it makes. A module that resolves nothing it was not granted can still allocate until the host dies.

The direction is containment beside the authority model rather than inside it: run the module as WebAssembly under an embedded runtime, so a host can hand it a memory limit, an execution budget and a closed syscall surface as well as a capability. Part of that already exists for an unrelated reason — the browser demo runs modules as wasm, ikigai-xslt-module builds one, and a wasm module has no ambient filesystem or network to begin with. What does not exist is any of the native half: no host in the ecosystem embeds wasmtime, no crate sets a memory budget, and none can stop a computation it has started: the word “fuel” appears in none of them (checked across the organization’s repositories on 2026-10-10; the probe re-checks ikigai-module 0.4.0 and ikigai-cli 0.1.46 by manifest). The nearest thing is a deadline on the answer, not on the work: since ikigai-xslt 0.2.1 every transform is answered within a time budget, five seconds, with a typed Timeout, and the crate says plainly that the work past the deadline is abandoned, never stopped (checked at ikigai-xslt 0.3.0) — the caller is released and the thread keeps running. Since ikigai-core 0.1.108 a deadline can also belong to the whole run: a door that issues with an IssueContext gives it a not_after, every sub-request inherits it and may only shorten it, and an endpoint or an overlay reads what is left with Invocation::time_left (an overlay that would wait for a retry checks the wait fits first). Wire v9, from ikigai-cli 0.1.66, carries the time left across a mount, so a peer’s work is held to the caller’s deadline too. It is still a deadline on the answer: the run is told how long it has, and nothing stops a computation that ignores it, so abandoned-never-stopped holds. Treat this section as the direction and the table above as the state, and do not plan a deployment on the difference.

The kernel since this chapter’s last stamp

The chapters above build against ikigai-core 0.2.2. This chapter was stamped at 0.1.78 on 2026-09-27, and seventeen releases, 0.1.79 to 0.1.95, landed in the thirteen days after it. The list below stops at 0.1.95. The eight releases from 0.1.96 to 0.1.103 are taught in the chapters they touch. Of the nine since, 0.1.104 (the principal vocabulary), 0.1.106 and 0.1.107 change nothing a page shows, and six are worth a line, because they change what an endpoint you write can rely on, or what a page shows:

  • 0.1.105 changed behavior: a declared default is filled in. The kernel fills a declared default_value into the request before dispatch, so an endpoint reads an omitted optional argument as its default rather than as MissingArgument. A default makes its input optional, and Description::validate refuses a description that marks a defaulted input required anyway. Before it, every endpoint applied its own default by hand. Exercise 2 of Exercises runs it.
  • 0.1.108 — failures have a class. Error::class() sorts every error into transient, retry-after, over-budget, denied, refused, permanent or unknown (new variants RetryAfter, OverBudget and Refused among them); Verb::is_idempotent says which verbs may be re-issued; an action may state the classes it fails with (fails(..), ik:mayFail in its contract); a run may carry a shared retry budget, granted as urn:cap:retry:<n>, and a deadline, above. ikigai-throttle 0.2.0 decides by the class, which Preference shows.
  • 0.1.109 — dynamic spaces. A running kernel’s arrangement can gain and lose doors without replacing its root: a DynamicSpace is rebuilt from a declaration resource, and each successful bind cuts the one thread every answer it served hangs from. A space that changes while it runs builds one, and A spreadsheet, VII shows its sibling, Threaded::session.
  • 0.1.110 — views. An alias rule may resolve its rewritten name at the root, under the caller’s own capability (ik:resolveAt "root"), so a dynamic space can hold another name for something the host serves elsewhere, and grant nothing by it. A space that changes while it runs declares one.
  • 0.1.111 — Turtle faces for the scheduler and the constraint window. urn:kernel:scheduler and urn:kernel:constraint answer as=text/turtle as well as text.
  • 0.1.112 — a face that is not offered is refused, and Turtle literals are shared. The kernel’s own operations declare the faces they serve, and refuse any other as= rather than answering text under another name; a trace therefore shows a face the caller left out as defaulted=as. Since ikigai-engine 0.2 the REPL’s trace prints those notes only when asked, as trace --defaults, which A space that changes while it runs asks under its view. turtle_literal and escape_literal are exported, so a module writes a Turtle string the way core does instead of carrying an RDF library for it.
  • 0.2.0 — streams. A representation’s bytes may be a body read as it arrives (Representation<Body>, Endpoint::invoke_body, Kernel::issue_body) rather than one whole buffer. A body that can be read only once is never stored in the cache, and one still being read past the run’s deadline ends with OverBudget. Streams, I: bytes are a representation starts the part about them. Every module this book links moved to 0.2 with it, since under 0.x rules a 0.1 pin could not admit it.

The seventeen from 0.1.79 to 0.1.95 are one line each here, and taught where a chapter teaches them:

  • 0.1.79 — documents and tests only. The check of what a wall keeps out answers only for what the graph shows in full, and a second query lists the paths it cannot answer for.
  • 0.1.80 — Conflict. A typed refusal for a request the resource’s current state says no to, the 409 of the taxonomy, found by this book’s game: a move to a taken square (Tic-tac-toe, VI). The spreadsheet’s market and its read-only past refuse with it (Spreadsheet, V, VI).
  • 0.1.81 — levels and sealed names. Level, the resolved scope a hit reports, and a host’s seals on names, checked when a space is built (Tic-tac-toe, VII, “What a declaration may and may not do”).
  • 0.1.82 — corridors stack, and a read says why it was not cached. Scope::stack (Spreadsheet, VI); every uncacheable read names its reason (Tracing a resolution); a fallback over a composite’s NotFound is cut when the atom changes, and the cache readout marks each row live, cut or expired (Spreadsheet, III); and cut listeners.
  • 0.1.83 — a space built from its declaration. Registry, build and Topology::from_turtle (the declare feature), the inverse of urn:kernel:topology, with every door naming the endpoint that answers it (Tic-tac-toe, VII).
  • 0.1.84 — declarations are bounded. A declaration past a depth, node or text bound is refused rather than overflowing the stack; and Kernel::root_topology, the root’s own arrangement, which Tic-tac-toe, VII’s test compares the coded game with the declared one through.
  • 0.1.85 — the audit release. The capability floor covers every verb an endpoint can be asked for, not only the ones it declares; nothing but the kernel answers inside the kernel’s own namespace; and a seal names a level by the object registered, not by its name.
  • 0.1.86 — exclusions are sticky. A deny-shaped scope survives attenuate and clamp: grants only shrink, exclusions only accumulate.
  • 0.1.87 — the “why” tools. urn:kernel:explain, a dry run of resolution (Tracing a resolution); urn:kernel:dependents, what a cut would recompute (Golden threads in practice); and Description::unsatisfied_scopes, the floor’s own predicate (Who is asking).
  • 0.1.88 and 0.1.90 — vocabulary only. Natural-language drafting terms move into ik:, and ikigai_vocab::space() names itself.
  • 0.1.89 — a space’s name survives only what keeps its doors. Binding onto a named space drops its name, and a module space that needs no configuration names itself. This book binds every such space behind doors of its own, so no page printed differently.
  • 0.1.91 — an action match has its own IRI, and a contract is content-addressed. A match is named by its verb and door, and the contract behind it by a digest of what it declares, which every Meta and catalog graph in this book now prints (What resolution buys you).
  • 0.1.92 — a failed invocation is a trace node. An invocation that ran and failed records an event with the error’s kind, so the trace and the cache agree about what a resolution touched. No chapter teaches it yet.
  • 0.1.93 — who a request is for, and the thread a failure hangs from. A principal is a capability scope, urn:cap:principal:<iri>: a door mints the party it authenticated into the capability (Capability::with_principal), and an endpoint reads it back with Capability::principal (Who is asking, “Identity is not authority”). And Invocation::depends_on lets an atom whose absence is another name’s state hang its NotFound from that name’s thread, so a composite that caught the NotFound and cached a fallback is cut when that name changes; it rides only a NotFound or Unresolved, and any other failure stays uncached. No chapter teaches depends_on yet: the spreadsheet falls back only on an atom’s own NotFound, which the atom’s own thread already covers (Spreadsheet, V, “fall back on an atom’s own NotFound”).
  • 0.1.94 — a percent escape is two hex digits. A match or contract IRI whose escape carries a sign (%+1) is refused where it used to name a door; nothing else about the parse moved.
  • 0.1.95 — lenient in, canonical out; a write that ran and failed cuts. A match or contract IRI is parsed in any spelling that names the same door (an upper-case digest, an unnecessary escape), and every one core emits is the canonical spelling (canonical_match_iri, canonical_contract_iri); every IRI this book prints was already canonical, so none moved. And a Sink or Delete the kernel dispatched cuts its target whether it returned success or an error; a request refused before it ran cuts nothing (Golden threads in practice).

The stamp before this one, 0.1.78, covered the six releases after 0.1.72: the two cache-soundness holes closed and a depth budget (0.1.73), one thread for “the binding set changed” (0.1.74; both in Golden threads in practice), the first temporal corridor (0.1.75) and Limit (0.1.76; both in Scope and alias), lossless by declaration (Transreption, 0.1.77), and the arrangement as a resource, urn:kernel:topology (Tic-tac-toe, VII, 0.1.78).

Exercises

Four, and all four run against your module — crates/your-endpoints/src/module.rs, the same shape as the loadable-module crate this part quotes, with a greeting endpoint under urn:iki:tutorial:yours:module: that calls back to the host’s urn:iki:tutorial:yours:name. The loop is cargo test -p your-endpoints; the test module at the bottom of that file already has the greet helper that builds a kernel and resolves a name under a capability, so a new question is a copy of it with something changed.

1. Break the prefix

Change ModuleSpace::new([MODULE_PREFIX], …) in host_space() to a prefix the request does not match.

  • Right when — the_module_resolves_a_host_resource_mid_invocation fails with no endpoint resolved for urn:iki:tutorial:yours:module:greeting, and a_name_outside_the_module_prefix_does_not_reach_it still passes. Then put it back.
Hint

Notice what did not change when it broke: the module is still compiled in, still constructed, still perfectly able to answer that name. It has an endpoint bound at urn:iki:tutorial:yours:module:greeting in its own space, and the host cannot reach it.

That is routing being a host decision rather than a module’s claim on a name space — the same separation as Binding, and a host of your own, one level up. A module does not get names by asking for them.

2. Chain a callback

Bind a second endpoint in module_space() — one that takes no arguments and returns a constant — and then resolve urn:iki:tutorial:yours:module:greeting with name pointing at that name instead of urn:iki:tutorial:yours:name.

  • Right when — the greeting names whatever your second module endpoint returns, and greeting itself is untouched.
Hint

Follow the path before you write it: the host routes urn:iki:tutorial:yours:module:greeting to the module, the module calls back to the host for a name, and the host routes that straight back into the module — while the first invocation is still open. Re-entrancy into the same module, mid-invocation.

It works, and it works on the loopback transport too. The reason it works is the same reason exercise 1 broke: the module has an IRI and an Issuer, and no way to ask where a name will end up. It cannot tell that the answer came back from itself.

The second endpoint is the shorter half of the exercise: name() in the same file already shows the shape of an endpoint that takes nothing and returns a constant — and remember the prefix: a name the host does not route to the module never reaches it, which is exercise 1 arriving from the other side.

3. Attenuate

Give the host’s urn:iki:tutorial:yours:name a declared capability — .requires("urn:cap:yours:name") on its Description — then resolve the greeting under Capability::root().attenuate(["urn:cap:yours:name"]), and again under an attenuation that grants something else. The greet helper already takes the capability.

  • Right when — with the right scope you get Hello, Ada!; with the wrong one the resolution fails with denied: capability does not grant `urn:cap:yours:name` (declared by `urn:iki:tutorial:yours:name`).
Hint

The interesting part is where that denial happens. It is not the module being refused at the door — the module ran. It is the module’s callback being refused, mid-invocation, because the capability the invocation is carrying is the caller’s, narrowed, and it does not grant what the host resource demands.

That is the property this part has been claiming: a module does not get authority by being a module, it borrows the caller’s, and the borrowing can only narrow. Here it is failing closed in front of you.

⚠ Try the mirror image too: put .requires(…) on the module’s own greeting endpoint instead. Since ikigai-module 0.2.0 that is enforced — the caller is refused before the module runs, with the same message shape. It was not at 0.1.10, and the first edition of this exercise said so; see “Do assume the host enforces what a module declares”, above, for the version and the test.

4. Take the loopback

Swap InProcessTransport::new(module_space()) for LoopbackTransport::new(module_space()) in host_space().

  • Right when — every test passes unchanged, including the chained callback from exercise 2.
Hint

Nothing about your code changes, which is the observation. What changes underneath is that every message in the session — the invocation, each HostCall, each HostResult, the final representation — is now genuinely encoded and decoded, in one process, over an in-memory channel.

So the two transports partition the failure modes. If a test passes in-process and fails on the loopback, the bug is in the encoding: something in the request, the capability or the representation does not survive a round trip. If it fails on both, it is the semantics. That split is worth stealing for anything else you build with a wire format — it is much cheaper than debugging a socket.