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");
}