Compositions
A composition runs several modules as one program. A module calls an NSID; the composition routes that call to a module that implements it, or to a handler the page or server supplies. Callers can pass callbacks, and policies can veto a call before it is made.
Write one
In TypeScript (or Rust), from bindings of the modules' module.json; the composition.json it writes is the file hosts load, published and reviewed. Commit both.
examples/rust/compose/compose.ts
const ui = c.instance("ui", compose);
const lookup = c.instance("lookup", compose, { input: { n: 0 } });
const even = c.instance("even", compose);
const confirm = c.handler("confirm", "The page asks the user.");
c.route(ui, "com.example.compose.lookup", lookup, "ui needs n doubled", { policies: [policy(even, "even")] });
c.route(ui, "com.example.compose.confirm", confirm, "ui asks the user before acting");
mkdir b && atlibs compose bindings --dir b examples/rust/compose 2>/dev/null
diff b/atlibs.modules.js examples/rust/compose/atlibs.modules.js && echo bindings unchanged
node examples/rust/compose/compose.ts | diff - examples/rust/compose/composition.json && echo composition unchanged
bindings unchanged
composition unchanged
atlibs compose bindings writes atlibs.modules.js and .d.ts (atlibs add does too, for what it
vendors): a route's NSID must be a hook of the caller and its target must implement it, an input key
must be in the module's config, of its type. build() runs the hosts' load checks and names your line:
composition: edge ui com.example.compose.confirm: x (compose) does not implement com.example.compose.confirm
at compose.ts:9:3
- instances: a module under a name, with optional fixed
input(keys from the module'sconfig). The same module may appear twice. - handlers: names the caller (page, server, CLI) must supply.
- edges (
route):fromcallingnsidgoestoan instance, at the entry its moduleimplementsfornsid, or to ahandler.reasonis shown wherever the call is logged (default: the hook's).policiesmust allow it first. - limits: call depth and total routed calls (defaults 8 and 100).
Declare what a module needs: hooks
A module lists the routed NSIDs it calls in hooks, so a composition that forgets one fails at load,
not halfway through a run:
examples/rust/compose/module.json
"hooks": [
{ "nsid": "com.example.compose.lookup", "reason": "the example's lookup: another instance doubles n" },
{ "nsid": "com.example.compose.confirm", "reason": "the example's confirm: the caller's handler" }
]
| hook field | what |
|---|---|
nsid | the contract: a lexicon procedure |
reason | why the module needs it |
required | no route and no default: refuse the composition at load (default: the call answers null) |
default | a module that serves it when nothing routes it (the same publisher's, at the entry its implements names), as instance <instance>-<default>: one per module, shared by the hooks that name it |
combine: "all-allow" | any number of routes, each answering a policy verdict; every one must allow (Policies) |
entries | the entries that call it (default: all) |
A module that serves a contract says so in implements, and the input an instance may give it in
config (fields as in actions):
examples/rust/compose/module.json
"config": [{ "key": "n", "label": "n", "type": "integer", "description": "The n lookup doubles when a call gives none." }],
"calls": [],
"implements": [{ "nsid": "com.example.compose.lookup", "entry": "lookup" }],
An instance's input key outside config, a value of another type, or a missing required field fails
the load. atlibs compose check <file> runs these checks without the wasm (CLI);
JS: checkComposition(manifest, specOf).
Run it
atlibs run examples/rust/compose/composition.json ui caller '{"nsid":"com.example.compose.lookup","input":{"n":4}}' \
--handler confirm=wasm:examples/rust/compose#lookup | jq -c .result.q.output.doubled
8
ui asked lookup to double 4; the even policy allowed it. With 3 the policy denies, and ui gets a
failed result instead:
atlibs run examples/rust/compose/composition.json ui caller '{"nsid":"com.example.compose.lookup","input":{"n":3}}' \
--handler confirm=wasm:examples/rust/compose#lookup | jq -c .result.q
{"error":"policy even denied: n 3 is not even","ok":false}
The run also prints a graph: every routed call, handler call, callback and policy run.
Pass a callback
A caller puts cap("name") in a call's input. The callee sees a capability token and can call it; the
caller gets Event::Callback and answers by calling reply:
examples/rust/compose/src/lib.rs
fn caller_handler(run: Run<Value>) -> Result<Handle, String> {
let mut state = if run.state.is_null() { json!({ "progress": [] }) } else { run.state };
Ok(match run.event {
Event::Start => {
let nsid = run.args["nsid"].as_str().ok_or("nsid is required")?;
let mut input = run.args.get("input").cloned().unwrap_or_else(|| json!({}));
input["progress"] = cap("progress");
let call = Call::new("q", nsid).input(input);
let call = match run.args["proxy"].as_str() {
Some(proxy) => call.proxy(proxy),
None => call,
};
Handle::new().call(call).state(state)
}
Event::Callback { cap, input, reply, .. } if cap == "progress" => {
let list = state["progress"].as_array_mut().ok_or("bad state")?;
list.push(input.clone());
let id = format!("reply-{}", list.len());
Handle::new().call(Call::new(id, reply).input(json!({ "seen": input }))).state(state)
}
Event::Result { id, result, .. } if id == "q" => {
let outcome = match result.output() {
Ok(output) => json!({ "ok": true, "output": output }),
Err(error) => json!({ "ok": false, "error": error }),
};
Handle::new().result(json!({ "q": outcome, "progress": state["progress"] }))
}
_ => Handle::new(),
})
}
examples/rust/compose/src/lib.rs
fn lookup_handler(run: Run<Value>) -> Result<Handle, String> {
let n = run.args["n"].as_i64().ok_or("n is required")?;
let answer = |ack: Value| Handle::new().result(json!({ "doubled": n * 2, "ack": ack, "caller": run.args["caller"] }));
Ok(match (run.event, cap_token(&run.args["progress"])) {
(Event::Start, Some(token)) => Handle::new().call(Call::new("p", token).input(json!({ "step": 1 }))),
(Event::Start, None) => answer(Value::Null),
(Event::Result { id, result, .. }, _) if id == "p" => answer(result.output().cloned().unwrap_or_else(|e| json!({ "error": e }))),
_ => Handle::new(),
})
}
Capabilities are unforgeable per run and die with it. A policy never receives the caller's capabilities.
Handlers from the caller
On the command line a handler is another module's entry, a URL, or a command
(CLI: run). In JS, runCompose takes functions:
import { readFile } from "node:fs/promises";
import { loadComposition, runCompose } from "./javascript/src/compose/index.js";
import { localModule } from "./javascript/src/run-module/index.js";
import { createNodeContext, fileFetch } from "./javascript/src/server/index.js";
const ctx = createNodeContext({ id: "compose-demo", stateDir: ".state/compose-demo" });
const root = `file://${process.cwd()}/`;
const manifest = JSON.parse(await readFile("examples/rust/compose/composition.json", "utf8"));
// every instance here is the module "compose": load the local build for it
const load = () => localModule(new URL("target/wasm32-unknown-unknown/release/compose.wasm", root), new URL("examples/rust/compose/module.json", root), fileFetch);
const composition = await loadComposition(ctx, manifest, { load });
// the handler the manifest declares; a page would ask the user here
const confirm = async (input, { caller }) => ({ confirmed: input.n, by: caller });
const ran = await runCompose(ctx, composition, "ui", "caller", { nsid: "com.example.compose.confirm", input: { n: 1 } }, { handlers: { confirm } });
console.log(JSON.stringify(ran.result.q.output));
{"by":"ui","confirmed":1}
In a page, pass callFor: (instance) => policyCall(stage, instance.module, calls) so each instance runs
under its own module's session (Sign in from a page).
Parameters
Inputs a binding fills. Types as module.json fields; values holds default and enum; a secret
comes from secrets only. {"$param": key} in an instance's input takes the value; {"$host": name},
one only the host gives.
cat > params.json <<'JSON'
{
"$type": "com.fedfork.exec.composition",
"name": "Doubler",
"parameters": [{ "key": "n", "type": "integer", "maximum": 100, "values": { "default": 4 } }],
"instances": [{ "name": "lookup", "module": "compose", "input": { "n": { "$param": "n" } } }],
"exports": [{ "nsid": "com.example.compose.lookup", "instance": "lookup", "entry": "lookup" }]
}
JSON
atlibs run params.json lookup lookup | jq -c .result.doubled
atlibs run params.json lookup lookup --params '{"inputs":{"n":21}}' | jq -c .result.doubled
atlibs run params.json lookup lookup --params '{"inputs":{"n":200}}' 2>&1 | tail -1
8
42
composition: parameter n must be at most 100
--params is {inputs, secrets, host}. JS: bindParameters(manifest, { inputs, secrets, host })
before runCompose, which refuses a placeholder left unbound. exports names the entry serving a
contract: exportOf(manifest, nsid).
A com.fedfork.exec.binding record is a composition pinned by CID, its inputs, and the names of
its secrets. Publishing writes the composition at <name> and at <name>.<its CID>; the CID loads the
latter, so a republish changes no binding.
Run one from your own host
A Rust host loads a published composition pinned by CID, and signs its users in once per composition, with only the scopes its modules need:
rust/host/src/registry.rs
pub async fn published(net: &Net, uri: &str, cid: Option<&str>) -> Result<(String, Composition, Vec<(String, Lib)>), String> {
rust/host/src/signin.rs
pub fn scope_of(comp: &Composition) -> String {
…
pub fn composition_prefix(cid: &str) -> String {
…
pub fn composition_store(state_dir: &Path, cid: &str) -> Result<Store, String> {
registry::module_call gives each instance its own call (its session's scope, attest, did:key,
transports); params::bind, then compose::run_compose, runs an export (params::export_of).
In Rust
atlibs-compose (rust/compose, by git like atlibs-host): a build.rs generates the same bindings as
types, so a wrong route or input does not compile; check(dir) runs atlibs compose check.
examples/rust-compose/src/lib.rs
let lookup = c.instance::<Compose>("lookup").input(ComposeInput::default().n(0));
…
c.route(&ui, compose::Lookup, &lookup, "ui needs n doubled").policy(&even, Compose::even);
cargo run -q --release --manifest-path rust/Cargo.toml -p rust-compose --target host-tuple --target-dir rust/host/target | diff - examples/rust/compose/composition.json
error[E0277]: module `Compose` does not implement `Confirm`
= note: route it to an instance whose module implements it
Stage and publish it
atlibs stage copies a composition and every module it names; atlibs add fetches a published one
with its modules.
atlibs stage --target spa --origin https://you.example/ --out site examples/rust/compose/composition.json > /dev/null
find site -type f | LC_ALL=C sort
site/composition.json
site/modules/compose.json
site/modules/compose.wasm
site/stage.json
atlibs publish composition examples/rust/compose/composition.json --as you.bsky.social
A published composition's instance module keys become your own module records; an instance may also
pin a module with pin: it loads that version, so the module's republish changes nothing.
Previous: Publish a module | Next: Policies