Skip to content

Commit 5318a9f

Browse files
committed
docs(kernel-utils): Improve sheaf documentation
1 parent 41dd8cc commit 5318a9f

3 files changed

Lines changed: 342 additions & 4 deletions

File tree

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
# Lift
2+
3+
The lift is the caller-supplied selection policy in the sheaf dispatch
4+
pipeline. It runs when the stalk at an invocation point contains more than one
5+
germ and the sheaf has no data to resolve the ambiguity on its own. The caller
6+
is responsible for writing a lift that is correct for the sections it will
7+
receive.
8+
9+
## Coroutine protocol
10+
11+
The lift is an `async function*` generator, not a plain async function:
12+
13+
```ts
14+
type Lift<M> = (
15+
germs: EvaluatedSection<Partial<M>>[],
16+
context: LiftContext<M>,
17+
) => AsyncGenerator<EvaluatedSection<Partial<M>>, void, unknown[]>;
18+
```
19+
20+
The sheaf drives it with the following protocol:
21+
22+
1. **Prime**`gen.next([])` starts the coroutine. The empty array is
23+
discarded; it exists only to satisfy the generator type.
24+
2. **Yield** — the coroutine yields a candidate germ to try next.
25+
3. **Attempt** — the sheaf calls the candidate's exo method.
26+
4. **Success** — the result is returned; the generator is abandoned.
27+
5. **Failure** — the sheaf calls `gen.next(errors)`, passing the ordered list
28+
of every error thrown so far (cumulative, not just the last). The coroutine
29+
receives this as the resolved value of its `yield` expression.
30+
6. **Exhausted** — if the generator returns without yielding, the sheaf
31+
rethrows the last error.
32+
33+
Most lifts express a fixed priority order and can ignore the error input:
34+
35+
```ts
36+
const awayLift: Lift<AwayMeta> = async function* (germs) {
37+
yield* germs.filter((g) => g.metadata?.mode === 'delegation');
38+
yield* germs.filter((g) => g.metadata?.mode === 'call-home');
39+
};
40+
```
41+
42+
A lift that inspects failure history can read the errors from yield:
43+
44+
```ts
45+
const cautious: Lift<Meta> = async function* (germs) {
46+
for (const germ of germs) {
47+
const errors: unknown[] = yield germ;
48+
// errors is the cumulative list of all failures so far, including the one
49+
// just returned for this germ. Inspect to decide whether to continue.
50+
if (errors.some(isUnrecoverable)) return;
51+
}
52+
};
53+
```
54+
55+
## LiftContext
56+
57+
The second argument to the lift is a `LiftContext`:
58+
59+
```ts
60+
type LiftContext<M> = {
61+
method: string; // the method being dispatched
62+
args: unknown[]; // the invocation arguments
63+
constraints: Partial<M>; // metadata keys identical across every germ
64+
};
65+
```
66+
67+
**`constraints`** are metadata keys whose values are the same on every germ in
68+
the stalk. Because all candidates agree on these keys, they carry no
69+
information useful for choosing between them — the sheaf strips them from each
70+
germ and delivers them separately. A lift that needs to know, say, the agreed
71+
`protocol` version reads it from `context.constraints.protocol` rather than
72+
from any individual germ.
73+
74+
**`args`** is available for cases where the lift itself must inspect the call.
75+
Most of the time, however, arg-dependent selection is better expressed as
76+
`callable` metadata on the sections than as conditional logic in the lift.
77+
78+
Consider a swap where each provider has a different cost curve over volume.
79+
Encode each provider's cost as `callable` metadata evaluated at dispatch time:
80+
81+
```ts
82+
const sections: PresheafSection<SwapMeta>[] = [
83+
{
84+
exo: providerAExo,
85+
metadata: callable((args) => ({ cost: providerACost(Number(args[0])) })),
86+
},
87+
{
88+
exo: providerBExo,
89+
metadata: callable((args) => ({ cost: providerBCost(Number(args[0])) })),
90+
},
91+
];
92+
```
93+
94+
By the time the lift runs, `germ.metadata.cost` already holds the concrete
95+
cost for this specific invocation — the swap amount has been applied. A lift
96+
that sorts by cost needs no knowledge of `args` at all:
97+
98+
```ts
99+
const cheapestFirst: Lift<SwapMeta> = async function* (germs) {
100+
yield* [...germs].sort(
101+
(a, b) => (a.metadata?.cost ?? 0) - (b.metadata?.cost ?? 0),
102+
);
103+
};
104+
```
105+
106+
This is why evaluable metadata exists: the arg-dependent logic lives with the
107+
sections that own it, and the lift stays a pure selection policy.
108+
109+
## Semantic equivalence assumption
110+
111+
Two sections may differ in real ways — one might use TCP and the other UDP; one
112+
might be a Rust implementation and the other JavaScript. The semantic
113+
equivalence contract does not require that two sections be identical. It
114+
requires only that **if two sections are indistinguishable by metadata, their
115+
differences are immaterial to the authority invoker**.
116+
117+
The sheaf relies on the following separation of responsibilities:
118+
119+
- **Section constructors** are responsible for advertising every feature that
120+
matters to callers. If transport protocol, latency tier, cost curve, or
121+
freshness guarantee could affect the invoker's decision, it belongs in the
122+
section's metadata. Omitting a distinguishing feature is a declaration that
123+
callers need not care about it.
124+
125+
- **Lift constructors** are responsible for selecting among the features that
126+
section constructors have chosen to expose. The lift cannot see what was not
127+
advertised.
128+
129+
This is a semantic contract, not a runtime enforcement — the sheaf cannot
130+
verify it. When a section constructor omits a feature from metadata, they are
131+
asserting: for any authority invoker using this sheaf, that feature is
132+
irrelevant. If the assertion is wrong, the collapse step may silently discard a
133+
candidate that the lift would have ranked differently.
134+
135+
> One `getBalance` provider uses a fully-synced node; another uses a lagging
136+
> replica. If both are tagged `{ cost: 1 }` with no freshness field, the
137+
> section constructors are asserting that freshness is immaterial to callers of
138+
> this sheaf. If that is not true, `{ cost: 1, freshness: 'lagging' }` vs
139+
> `{ cost: 1, freshness: 'live' }` would let the lift choose.

packages/kernel-utils/src/sheaf/README.md

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ over a presheaf of capabilities. The sheaf grants revocable dispatch sections
77
via `getSection`, tracks all delegated authority, and supports point-wise
88
revocation.
99

10+
See [USAGE.md](./USAGE.md) for annotated examples and [LIFT.md](./LIFT.md) for
11+
the lift coroutine protocol and semantic equivalence assumption.
12+
1013
## Concepts
1114

1215
**Presheaf section** (`PresheafSection`) — The input data: a capability (exo)
@@ -33,15 +36,21 @@ entries.
3336
> Stalk at `("getBalance", "alice")` might contain two germs (cost 1 vs 100);
3437
> stalk at `("transfer", ...)` might contain one.
3538
36-
**Lift** — An async function that selects one germ from a multi-germ stalk.
39+
**Lift** — An `async function*` coroutine that yields candidates from a
40+
multi-germ stalk in preference order. See [LIFT.md](./LIFT.md) for the
41+
coroutine protocol, `LiftContext`, and the semantic equivalence assumption
42+
required of all lifts.
43+
3744
At dispatch time, metadata is decomposed into **constraints** (keys with the
3845
same value across every germ — topologically determined, not a choice) and
3946
**options** (the remaining keys — the lift's actual decision space). The lift
4047
receives only options on each germ; constraints arrive separately in the
4148
context.
4249

4350
> `argmin` by cost, `argmin` by latency, or any custom selection logic. The
44-
> lift is never invoked for single-germ stalks.
51+
> lift is never invoked when the stalk resolves to a single germ — either
52+
> because only one section matched, or because all matching sections had
53+
> identical metadata and collapsed to one representative.
4554
4655
**Sheaf** — The authority manager returned by `sheafify`. Holds the presheaf
4756
data (captured at construction time) and a registry of all granted sections.
@@ -50,7 +59,8 @@ data (captured at construction time) and a registry of all granted sections.
5059
const sheaf = sheafify({ name: 'Wallet', sections });
5160
```
5261

53-
- `sheaf.getSection({ guard?, lift })` — produce a revocable dispatch exo
62+
- `sheaf.getSection({ guard, lift })` — produce a revocable dispatch exo
63+
- `sheaf.getDiscoverableSection({ guard, lift, schema })` — same, but the exo exposes its guard
5464
- `sheaf.revokePoint(method, ...args)` — revoke every granted section whose
5565
guard covers the point
5666
- `sheaf.getExported()` — union guard of all active (non-revoked) sections
@@ -62,13 +72,25 @@ At each invocation point `(method, args)` within a granted section:
6272

6373
```
6474
getStalk(sections, method, args) presheaf → stalk (filter by guard)
75+
evaluateMetadata(stalk, args) metadata specs → concrete values
6576
collapseEquivalent(stalk) locality condition (quotient by metadata)
6677
decomposeMetadata(collapsed) restriction map (constraints / options)
6778
lift(stripped, { method, args, operational selection (extra-theoretic)
6879
constraints })
69-
dispatch to collapsed[index].exo evaluation
80+
dispatch to chosen.exo evaluation
7081
```
7182

83+
The pipeline short-circuits at two points: if only one section matches the
84+
guard, it is invoked directly without evaluate/collapse/lift; if all matching
85+
sections collapse to an identical germ, the single representative is invoked
86+
without calling the lift.
87+
88+
`callable` and `source` metadata specs make the stalk shape depend on the
89+
invocation arguments. A `swap(amount)` section can produce `{ cost: 'low' }`
90+
for small amounts and `{ cost: 'high' }` for large ones, yielding a different
91+
set of germs — and potentially a different lift outcome — for the same method
92+
called with different arguments.
93+
7294
## Design choices
7395

7496
**Germ identity is metadata identity.** The collapse step quotients by
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
# Usage
2+
3+
## Single provider
4+
5+
When there is only one section per invocation point, no lift is needed — the
6+
dispatch short-circuits before the lift is ever called. Provide a no-op lift
7+
as a placeholder:
8+
9+
```ts
10+
import { M } from '@endo/patterns';
11+
import { makeDefaultExo } from '<internal>';
12+
import { sheafify } from '@metamask/kernel-utils';
13+
14+
const noop = async function* (germs) {
15+
yield* germs;
16+
};
17+
18+
const priceGuard = M.interface('PriceService', {
19+
getPrice: M.callWhen(M.await(M.string())).returns(M.await(M.number())),
20+
});
21+
22+
const priceExo = makeDefaultExo('PriceService', priceGuard, {
23+
async getPrice(token) {
24+
return fetchPrice(token);
25+
},
26+
});
27+
28+
const sheaf = sheafify({
29+
name: 'PriceService',
30+
sections: [{ exo: priceExo }],
31+
});
32+
33+
const section = sheaf.getSection({ guard: priceGuard, lift: noop });
34+
// section is a revocable dispatch exo; call it like any capability
35+
const price = await E(section).getPrice('ETH');
36+
```
37+
38+
## Multiple providers with a lift
39+
40+
When the stalk at a given invocation point contains more than one germ, the
41+
sheaf calls the lift to choose. The lift is an `async function*` coroutine that
42+
yields candidates in preference order; it receives accumulated errors as the
43+
argument to each subsequent `.next()` so it can adapt its ranking.
44+
45+
The idiomatic pattern is a generator that `yield*`s candidates filtered by
46+
metadata, expressing priority tiers in source order:
47+
48+
```ts
49+
import { sheafify, constant } from '@metamask/kernel-utils';
50+
import type { Lift } from '@metamask/kernel-utils';
51+
52+
type WalletMeta = { mode: 'fast' | 'reliable' };
53+
54+
const preferFast: Lift<WalletMeta> = async function* (germs) {
55+
yield* germs.filter((g) => g.metadata?.mode === 'fast');
56+
yield* germs.filter((g) => g.metadata?.mode === 'reliable');
57+
};
58+
59+
const sheaf = sheafify<WalletMeta>({
60+
name: 'Wallet',
61+
sections: [
62+
{ exo: fastExo, metadata: constant({ mode: 'fast' }) },
63+
{ exo: reliableExo, metadata: constant({ mode: 'reliable' }) },
64+
],
65+
});
66+
67+
// guard restricts which methods callers may invoke
68+
const section = sheaf.getSection({ guard: clientGuard, lift: preferFast });
69+
```
70+
71+
The sheaf drives the generator: it primes it with `gen.next([])`, calls the
72+
chosen candidate, then passes any thrown errors back as `gen.next(errors)` so
73+
the lift can adapt before yielding the next candidate.
74+
75+
Use the `constant`, `source`, or `callable` helpers to build metadata specs:
76+
77+
```ts
78+
import { constant, source, callable } from '@metamask/kernel-utils';
79+
80+
// static value known at construction time
81+
constant({ mode: 'fast' });
82+
83+
// JS source string compiled once in the sheaf's compartment at construction time
84+
source(`(args) => ({ cost: args[0] > 9000 ? 'high' : 'low' })`);
85+
86+
// live function evaluated at each dispatch — useful when cost varies by argument,
87+
// e.g. a swap whose metadata encodes volume-based cost tiers
88+
callable((args) => ({ cost: Number(args[0]) > 9000 ? 'high' : 'low' }));
89+
```
90+
91+
## Discoverable sections
92+
93+
`getDiscoverableSection` works like `getSection` but the returned exo exposes
94+
its guard — it can be introspected by the caller to discover what methods and
95+
argument shapes it accepts. Use this when the recipient needs to advertise
96+
capability to a third party. It requires a `schema` map describing each method:
97+
98+
```ts
99+
import type { MethodSchema } from '@metamask/kernel-utils';
100+
101+
const schema: Record<string, MethodSchema> = {
102+
getPrice: { description: 'Get the current price of a token.' },
103+
};
104+
105+
const section = sheaf.getDiscoverableSection({
106+
guard: clientGuard,
107+
lift,
108+
schema,
109+
});
110+
```
111+
112+
`getSection` is the non-discoverable variant (no `schema` required).
113+
114+
`getGlobalSection` and `getDiscoverableGlobalSection` derive the guard
115+
automatically from the union of all presheaf sections. They are `@deprecated`
116+
as a nudge toward explicit guards once the caller knows the section set —
117+
explicit guards make the capability's scope visible at the call site. When
118+
sections are assembled dynamically (e.g., rebuilt at runtime from a set of
119+
grants that changes) and the union guard isn't known until after `sheafify`
120+
runs, the global variants are the right choice.
121+
122+
## Revocation
123+
124+
```ts
125+
// revoke every granted section whose guard covers this invocation point
126+
sheaf.revokePoint('getPrice', 'ETH');
127+
128+
// revoke all granted sections at once
129+
sheaf.revokeAll();
130+
131+
// union guard of all currently active (non-revoked) sections
132+
const exported = sheaf.getExported();
133+
```
134+
135+
## Remote sections
136+
137+
`makeRemoteSection` wraps a CapTP remote reference as a `PresheafSection`,
138+
fetching the remote's guard once at construction and forwarding all calls via
139+
`E()`. This lets you mix local exos and remote capabilities in the same sheaf:
140+
141+
```ts
142+
import { makeRemoteSection, constant } from '@metamask/kernel-utils';
143+
144+
const remoteSection = await makeRemoteSection(
145+
'RemoteWallet', // name for the wrapper exo
146+
remoteCapRef, // CapTP reference
147+
constant({ mode: 'remote' }), // optional metadata
148+
);
149+
150+
const sheaf = sheafify({
151+
name: 'Mixed',
152+
sections: [localSection, remoteSection],
153+
});
154+
```
155+
156+
## Lift composition
157+
158+
`@metamask/kernel-utils` exports helpers for building lifts from composable
159+
parts, useful when lift logic would otherwise be duplicated across callers:
160+
161+
```ts
162+
import {
163+
proxyLift,
164+
withFilter,
165+
withRanking,
166+
fallthrough,
167+
} from '@metamask/kernel-utils';
168+
```
169+
170+
- **`withRanking(comparator, inner)`** — sort germs by comparator before
171+
passing to `inner`
172+
- **`withFilter(predicate, inner)`** — remove germs that fail `predicate`
173+
before passing to `inner`
174+
- **`fallthrough(liftA, liftB)`** — try all candidates from `liftA` first;
175+
if all fail, try `liftB`
176+
- **`proxyLift(inner)`** — forward yielded candidates up and error arrays down;
177+
useful when wrapping a lift in middleware

0 commit comments

Comments
 (0)