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

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 the Sink action and run the test. The write goes through for the narrow reader, and no test in the crate but this one notices — the code is unchanged and correct. Declared = enforced means the declaration is the enforcement, and a description is code you have to read with that in mind.

What this is not

It is not a module boundary. 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"));
}