~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atlibs

0
0
main
309 lines · 12.2 KB Raw History
# 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](#in-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`

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

```bash run
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
```

```output
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:

```text norun
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's `config`).
  The same module may appear twice.
- **handlers**: names the caller (page, server, CLI) must supply.
- **edges** (`route`): `from` calling `nsid` goes `to` an instance, at the entry its module
  `implements` for `nsid`, or to a `handler`. `reason` is shown wherever the call is logged (default: the
  hook's). `policies` must 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`

```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](policies.md)) |
| `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`

```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](cli.md#compose));
JS: `checkComposition(manifest, specOf)`.

## Run it

```bash run
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
```

```output
8
```

`ui` asked `lookup` to double 4; the `even` policy allowed it. With 3 the policy denies, and `ui` gets a
failed result instead:

```bash run
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
```

```output
{"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`

```rust
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`

```rust
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](cli.md#run)). In JS, `runCompose` takes functions:

```js run
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));
```

```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](browser/oauth.md)).

## 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.

```bash run
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
```

```output
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`

```rust
pub async fn published(net: &Net, uri: &str, cid: Option<&str>) -> Result<(String, Composition, Vec<(String, Lib)>), String> {
```

`rust/host/src/signin.rs`

```rust
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`

```rust
    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);
```

```bash run=gate
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
```

```text norun
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.

```bash run
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
```

```output
site/composition.json
site/modules/compose.json
site/modules/compose.wasm
site/stage.json
```

```bash run=account
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](modules/publish.md) | Next: [Policies](policies.md)