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